Add user invitation system

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-14 10:43:34 +01:00
parent ec637401db
commit ebc88ec7e8
10 changed files with 1189 additions and 23 deletions

View File

@@ -42,6 +42,9 @@ const PeopleOverviewPage = lazy(() => import("./pages/PeopleOverviewPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
const ConfirmEmailPage = lazy(() => import("./pages/ConfirmEmailPage"));
const ConfirmInvitationPage = lazy(
() => import("./pages/ConfirmInvitationPage")
);
const CreateOrganizationPage = lazy(
() => import("./pages/CreateOrganizationPage")
);
@@ -303,6 +306,16 @@ function App() {
</Suspense>
}
/>
<Route
path="confirm-invitation"
element={
<Suspense>
<VisitorErrorBoundaryWithLocation>
<ConfirmInvitationPage />
</VisitorErrorBoundaryWithLocation>
</Suspense>
}
/>
</Route>
<Route

View File

@@ -0,0 +1,222 @@
import { useState, useEffect } from "react";
import { useLocation, useNavigate } from "react-router";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { Link } from "react-router";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { PayloadError } from "relay-runtime";
import { ConfirmInvitationPageMutation } from "./__generated__/ConfirmInvitationPageMutation.graphql";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const ConfirmInvitationMutation = graphql`
mutation ConfirmInvitationPageMutation($input: ConfirmInvitationInput!) {
confirmInvitation(input: $input) {
success
}
}
`;
export default function ConfirmInvitationPage() {
const [isLoading, setIsLoading] = useState(false);
const [isConfirmed, setIsConfirmed] = useState(false);
const [error, setError] = useState<string | null>(null);
const [token, setToken] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [confirmPassword, setConfirmPassword] = useState<string>("");
const location = useLocation();
const navigate = useNavigate();
const { toast } = useToast();
const [commitMutation] = useMutation<ConfirmInvitationPageMutation>(
ConfirmInvitationMutation
);
useEffect(() => {
// Extract token from URL and prefill the form
const searchParams = new URLSearchParams(location.search);
const urlToken = searchParams.get("token");
if (urlToken) {
setToken(urlToken);
}
}, [location.search]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
if (!token.trim()) {
setError("Please enter a confirmation token");
setIsLoading(false);
return;
}
if (!password) {
setError("Please enter a password");
setIsLoading(false);
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
setIsLoading(false);
return;
}
if (password.length < 8) {
setError("Password must be at least 8 characters long");
setIsLoading(false);
return;
}
try {
commitMutation({
variables: {
input: {
token: token.trim(),
password: password,
},
},
onCompleted: (response, errors: PayloadError[] | null) => {
if (errors) {
throw new Error(
errors[0]?.message || "Failed to confirm invitation"
);
}
setIsConfirmed(true);
toast({
title: "Success",
description: "Your invitation has been confirmed successfully",
});
setIsLoading(false);
},
onError: (err) => {
setError(
err.message || "Failed to confirm invitation. Please try again."
);
setIsLoading(false);
},
});
} catch (error) {
setError(
error instanceof Error
? error.message
: "Failed to confirm invitation. Please try again."
);
setIsLoading(false);
}
};
return (
<>
<Helmet>
<title>Confirm Invitation - Probo</title>
</Helmet>
<div className="flex flex-col items-center justify-center min-h-[70vh] p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl font-bold text-center">
Invitation Confirmation
</CardTitle>
<CardDescription className="text-center">
Complete your account setup to join the organization
</CardDescription>
</CardHeader>
<CardContent>
{isConfirmed ? (
<div className="space-y-4 text-center">
<p className="text-green-600 dark:text-green-400">
Your invitation has been confirmed successfully!
</p>
<Button onClick={() => navigate("/login")} className="w-full">
Proceed to Login
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="token">Invitation Token</Label>
<Input
id="token"
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Enter your invitation token"
disabled={isLoading}
required
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
The token has been automatically filled from the URL if
available
</p>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Create a password"
disabled={isLoading}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
disabled={isLoading}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Confirming..." : "Complete Registration"}
</Button>
</form>
)}
</CardContent>
<CardFooter className="flex justify-center">
{!isConfirmed && (
<Link
to="/login"
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
Back to Login
</Link>
)}
</CardFooter>
</Card>
</div>
</>
);
}

View File

@@ -37,6 +37,7 @@ import {
} from "@/components/ui/dialog";
import type { SettingsPageQuery as SettingsPageQueryType } from "./__generated__/SettingsPageQuery.graphql";
import type { SettingsPageUpdateOrganizationMutation as SettingsPageUpdateOrganizationMutationType } from "./__generated__/SettingsPageUpdateOrganizationMutation.graphql";
import type { SettingsPageInviteUserMutation as SettingsPageInviteUserMutationType } from "./__generated__/SettingsPageInviteUserMutation.graphql";
const settingsPageQuery = graphql`
query SettingsPageQuery($organizationID: ID!) {
@@ -74,6 +75,14 @@ const updateOrganizationMutation = graphql`
}
`;
const inviteUserMutation = graphql`
mutation SettingsPageInviteUserMutation($input: InviteUserInput!) {
inviteUser(input: $input) {
success
}
}
`;
function SettingsPageContent({
queryRef,
}: {
@@ -88,7 +97,9 @@ function SettingsPageContent({
const [isEditNameOpen, setIsEditNameOpen] = useState(false);
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteFullName, setInviteFullName] = useState("");
const [inviteRole, setInviteRole] = useState("Member");
const [isInviting, setIsInviting] = useState(false);
const [organizationName, setOrganizationName] = useState(
organization.name || ""
);
@@ -99,6 +110,9 @@ function SettingsPageContent({
updateOrganizationMutation
);
const [inviteUser] =
useMutation<SettingsPageInviteUserMutationType>(inviteUserMutation);
const handleUpdateName = () => {
updateOrganization({
variables: {
@@ -169,16 +183,54 @@ function SettingsPageContent({
};
const handleInviteMember = () => {
// This is a placeholder for the actual invite functionality
// In a real implementation, we would use a GraphQL mutation to invite the user
// For now, we just show a toast message and close the dialog
toast({
title: "Invitation sent",
description: `An invitation has been sent to ${inviteEmail}`,
variant: "default",
if (!inviteEmail || !inviteFullName) {
toast({
title: "Missing information",
description:
"Please provide both email and full name for the invitation",
variant: "destructive",
});
return;
}
setIsInviting(true);
inviteUser({
variables: {
input: {
organizationId: organization.id,
email: inviteEmail,
fullName: inviteFullName,
},
},
onCompleted: (response) => {
setIsInviting(false);
if (response.inviteUser?.success) {
toast({
title: "Invitation sent",
description: `An invitation has been sent to ${inviteEmail}`,
variant: "default",
});
setIsInviteOpen(false);
setInviteEmail("");
setInviteFullName("");
} else {
toast({
title: "Error sending invitation",
description: "The invitation could not be sent. Please try again.",
variant: "destructive",
});
}
},
onError: (error) => {
setIsInviting(false);
toast({
title: "Error sending invitation",
description: error.message,
variant: "destructive",
});
},
});
setIsInviteOpen(false);
setInviteEmail("");
};
return (
@@ -388,24 +440,23 @@ function SettingsPageContent({
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<select
id="role"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
>
<option value="Admin">Admin</option>
<option value="Member">Member</option>
<option value="Viewer">Viewer</option>
</select>
<Label htmlFor="fullName">Full Name</Label>
<Input
id="fullName"
type="text"
value={inviteFullName}
onChange={(e) => setInviteFullName(e.target.value)}
placeholder="Enter full name"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsInviteOpen(false)}>
Cancel
</Button>
<Button onClick={handleInviteMember}>Send Invitation</Button>
<Button onClick={handleInviteMember} disabled={isInviting}>
{isInviting ? "Sending..." : "Send Invitation"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,93 @@
/**
* @generated SignedSource<<8326ebf0af891dd062245db55f40abba>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ConfirmInvitationInput = {
password: string;
token: string;
};
export type ConfirmInvitationPageMutation$variables = {
input: ConfirmInvitationInput;
};
export type ConfirmInvitationPageMutation$data = {
readonly confirmInvitation: {
readonly success: boolean;
};
};
export type ConfirmInvitationPageMutation = {
response: ConfirmInvitationPageMutation$data;
variables: ConfirmInvitationPageMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "ConfirmInvitationPayload",
"kind": "LinkedField",
"name": "confirmInvitation",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ConfirmInvitationPageMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ConfirmInvitationPageMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "b274132385a2cb59047b47de69712aa8",
"id": null,
"metadata": {},
"name": "ConfirmInvitationPageMutation",
"operationKind": "mutation",
"text": "mutation ConfirmInvitationPageMutation(\n $input: ConfirmInvitationInput!\n) {\n confirmInvitation(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "f1f013a448277ebffb14e954b2c1797c";
export default node;

View File

@@ -0,0 +1,94 @@
/**
* @generated SignedSource<<7eb47f6f0589a4b7c7e65fd4c313422c>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type InviteUserInput = {
email: string;
fullName: string;
organizationId: string;
};
export type SettingsPageInviteUserMutation$variables = {
input: InviteUserInput;
};
export type SettingsPageInviteUserMutation$data = {
readonly inviteUser: {
readonly success: boolean;
};
};
export type SettingsPageInviteUserMutation = {
response: SettingsPageInviteUserMutation$data;
variables: SettingsPageInviteUserMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "InviteUserPayload",
"kind": "LinkedField",
"name": "inviteUser",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "SettingsPageInviteUserMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "SettingsPageInviteUserMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "732d61a66a202dc879bb81c72fb2fb24",
"id": null,
"metadata": {},
"name": "SettingsPageInviteUserMutation",
"operationKind": "mutation",
"text": "mutation SettingsPageInviteUserMutation(\n $input: InviteUserInput!\n) {\n inviteUser(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "d09b74116ff70680029667104b5b34db";
export default node;

View File

@@ -386,6 +386,8 @@ type Mutation {
deletePolicy(input: DeletePolicyInput!): DeletePolicyPayload!
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
inviteUser(input: InviteUserInput!): InviteUserPayload!
confirmInvitation(input: ConfirmInvitationInput!): ConfirmInvitationPayload!
}
input CreateVendorInput {
@@ -712,3 +714,22 @@ input UnassignTaskInput {
type UnassignTaskPayload {
task: Task!
}
input InviteUserInput {
organizationId: ID!
email: String!
fullName: String!
}
type InviteUserPayload {
success: Boolean!
}
input ConfirmInvitationInput {
token: String!
password: String!
}
type ConfirmInvitationPayload {
success: Boolean!
}

View File

@@ -65,6 +65,10 @@ type ComplexityRoot struct {
Success func(childComplexity int) int
}
ConfirmInvitationPayload struct {
Success func(childComplexity int) int
}
Control struct {
Category func(childComplexity int) int
CreatedAt func(childComplexity int) int
@@ -185,9 +189,14 @@ type ComplexityRoot struct {
FrameworkEdge func(childComplexity int) int
}
InviteUserPayload struct {
Success func(childComplexity int) int
}
Mutation struct {
AssignTask func(childComplexity int, input types.AssignTaskInput) int
ConfirmEmail func(childComplexity int, input types.ConfirmEmailInput) int
ConfirmInvitation func(childComplexity int, input types.ConfirmInvitationInput) int
CreateControl func(childComplexity int, input types.CreateControlInput) int
CreateFramework func(childComplexity int, input types.CreateFrameworkInput) int
CreateOrganization func(childComplexity int, input types.CreateOrganizationInput) int
@@ -202,6 +211,7 @@ type ComplexityRoot struct {
DeleteTask func(childComplexity int, input types.DeleteTaskInput) int
DeleteVendor func(childComplexity int, input types.DeleteVendorInput) int
ImportFramework func(childComplexity int, input types.ImportFrameworkInput) int
InviteUser func(childComplexity int, input types.InviteUserInput) int
UnassignTask func(childComplexity int, input types.UnassignTaskInput) int
UpdateControl func(childComplexity int, input types.UpdateControlInput) int
UpdateFramework func(childComplexity int, input types.UpdateFrameworkInput) int
@@ -441,6 +451,8 @@ type MutationResolver interface {
UpdatePolicy(ctx context.Context, input types.UpdatePolicyInput) (*types.UpdatePolicyPayload, error)
DeletePolicy(ctx context.Context, input types.DeletePolicyInput) (*types.DeletePolicyPayload, error)
ConfirmEmail(ctx context.Context, input types.ConfirmEmailInput) (*types.ConfirmEmailPayload, error)
InviteUser(ctx context.Context, input types.InviteUserInput) (*types.InviteUserPayload, error)
ConfirmInvitation(ctx context.Context, input types.ConfirmInvitationInput) (*types.ConfirmInvitationPayload, error)
}
type OrganizationResolver interface {
LogoURL(ctx context.Context, obj *types.Organization) (*string, error)
@@ -498,6 +510,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.ConfirmEmailPayload.Success(childComplexity), true
case "ConfirmInvitationPayload.success":
if e.complexity.ConfirmInvitationPayload.Success == nil {
break
}
return e.complexity.ConfirmInvitationPayload.Success(childComplexity), true
case "Control.category":
if e.complexity.Control.Category == nil {
break
@@ -865,6 +884,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.ImportFrameworkPayload.FrameworkEdge(childComplexity), true
case "InviteUserPayload.success":
if e.complexity.InviteUserPayload.Success == nil {
break
}
return e.complexity.InviteUserPayload.Success(childComplexity), true
case "Mutation.assignTask":
if e.complexity.Mutation.AssignTask == nil {
break
@@ -889,6 +915,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.ConfirmEmail(childComplexity, args["input"].(types.ConfirmEmailInput)), true
case "Mutation.confirmInvitation":
if e.complexity.Mutation.ConfirmInvitation == nil {
break
}
args, err := ec.field_Mutation_confirmInvitation_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.ConfirmInvitation(childComplexity, args["input"].(types.ConfirmInvitationInput)), true
case "Mutation.createControl":
if e.complexity.Mutation.CreateControl == nil {
break
@@ -1057,6 +1095,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.ImportFramework(childComplexity, args["input"].(types.ImportFrameworkInput)), true
case "Mutation.inviteUser":
if e.complexity.Mutation.InviteUser == nil {
break
}
args, err := ec.field_Mutation_inviteUser_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.InviteUser(childComplexity, args["input"].(types.InviteUserInput)), true
case "Mutation.unassignTask":
if e.complexity.Mutation.UnassignTask == nil {
break
@@ -1908,6 +1958,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
inputUnmarshalMap := graphql.BuildUnmarshalerMap(
ec.unmarshalInputAssignTaskInput,
ec.unmarshalInputConfirmEmailInput,
ec.unmarshalInputConfirmInvitationInput,
ec.unmarshalInputCreateControlInput,
ec.unmarshalInputCreateFrameworkInput,
ec.unmarshalInputCreateOrganizationInput,
@@ -1922,6 +1973,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
ec.unmarshalInputDeleteTaskInput,
ec.unmarshalInputDeleteVendorInput,
ec.unmarshalInputImportFrameworkInput,
ec.unmarshalInputInviteUserInput,
ec.unmarshalInputUnassignTaskInput,
ec.unmarshalInputUpdateControlInput,
ec.unmarshalInputUpdateFrameworkInput,
@@ -2416,6 +2468,8 @@ type Mutation {
deletePolicy(input: DeletePolicyInput!): DeletePolicyPayload!
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
inviteUser(input: InviteUserInput!): InviteUserPayload!
confirmInvitation(input: ConfirmInvitationInput!): ConfirmInvitationPayload!
}
input CreateVendorInput {
@@ -2742,6 +2796,25 @@ input UnassignTaskInput {
type UnassignTaskPayload {
task: Task!
}
input InviteUserInput {
organizationId: ID!
email: String!
fullName: String!
}
type InviteUserPayload {
success: Boolean!
}
input ConfirmInvitationInput {
token: String!
password: String!
}
type ConfirmInvitationPayload {
success: Boolean!
}
`, BuiltIn: false},
}
var parsedSchema = gqlparser.MustLoadSchema(sources...)
@@ -2950,6 +3023,29 @@ func (ec *executionContext) field_Mutation_confirmEmail_argsInput(
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_confirmInvitation_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
arg0, err := ec.field_Mutation_confirmInvitation_argsInput(ctx, rawArgs)
if err != nil {
return nil, err
}
args["input"] = arg0
return args, nil
}
func (ec *executionContext) field_Mutation_confirmInvitation_argsInput(
ctx context.Context,
rawArgs map[string]any,
) (types.ConfirmInvitationInput, error) {
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input"))
if tmp, ok := rawArgs["input"]; ok {
return ec.unmarshalNConfirmInvitationInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐConfirmInvitationInput(ctx, tmp)
}
var zeroVal types.ConfirmInvitationInput
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_createControl_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
@@ -3272,6 +3368,29 @@ func (ec *executionContext) field_Mutation_importFramework_argsInput(
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_inviteUser_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
arg0, err := ec.field_Mutation_inviteUser_argsInput(ctx, rawArgs)
if err != nil {
return nil, err
}
args["input"] = arg0
return args, nil
}
func (ec *executionContext) field_Mutation_inviteUser_argsInput(
ctx context.Context,
rawArgs map[string]any,
) (types.InviteUserInput, error) {
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input"))
if tmp, ok := rawArgs["input"]; ok {
return ec.unmarshalNInviteUserInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐInviteUserInput(ctx, tmp)
}
var zeroVal types.InviteUserInput
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_unassignTask_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
@@ -4262,6 +4381,44 @@ func (ec *executionContext) fieldContext_ConfirmEmailPayload_success(_ context.C
return fc, nil
}
func (ec *executionContext) _ConfirmInvitationPayload_success(ctx context.Context, field graphql.CollectedField, obj *types.ConfirmInvitationPayload) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_ConfirmInvitationPayload_success(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.Success, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(bool)
fc.Result = res
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_ConfirmInvitationPayload_success(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "ConfirmInvitationPayload",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Boolean does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Control_id(ctx context.Context, field graphql.CollectedField, obj *types.Control) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Control_id(ctx, field)
if err != nil {
@@ -6374,6 +6531,44 @@ func (ec *executionContext) fieldContext_ImportFrameworkPayload_frameworkEdge(_
return fc, nil
}
func (ec *executionContext) _InviteUserPayload_success(ctx context.Context, field graphql.CollectedField, obj *types.InviteUserPayload) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_InviteUserPayload_success(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.Success, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(bool)
fc.Result = res
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_InviteUserPayload_success(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "InviteUserPayload",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Boolean does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Mutation_createVendor(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Mutation_createVendor(ctx, field)
if err != nil {
@@ -7549,6 +7744,100 @@ func (ec *executionContext) fieldContext_Mutation_confirmEmail(ctx context.Conte
return fc, nil
}
func (ec *executionContext) _Mutation_inviteUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Mutation_inviteUser(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().InviteUser(rctx, fc.Args["input"].(types.InviteUserInput))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*types.InviteUserPayload)
fc.Result = res
return ec.marshalNInviteUserPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐInviteUserPayload(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Mutation_inviteUser(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Mutation",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "success":
return ec.fieldContext_InviteUserPayload_success(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type InviteUserPayload", field.Name)
},
}
ctx = graphql.WithFieldContext(ctx, fc)
if fc.Args, err = ec.field_Mutation_inviteUser_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
ec.Error(ctx, err)
return fc, err
}
return fc, nil
}
func (ec *executionContext) _Mutation_confirmInvitation(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Mutation_confirmInvitation(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().ConfirmInvitation(rctx, fc.Args["input"].(types.ConfirmInvitationInput))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*types.ConfirmInvitationPayload)
fc.Result = res
return ec.marshalNConfirmInvitationPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐConfirmInvitationPayload(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Mutation_confirmInvitation(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Mutation",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "success":
return ec.fieldContext_ConfirmInvitationPayload_success(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type ConfirmInvitationPayload", field.Name)
},
}
ctx = graphql.WithFieldContext(ctx, fc)
if fc.Args, err = ec.field_Mutation_confirmInvitation_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
ec.Error(ctx, err)
return fc, err
}
return fc, nil
}
func (ec *executionContext) _Organization_id(ctx context.Context, field graphql.CollectedField, obj *types.Organization) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Organization_id(ctx, field)
if err != nil {
@@ -13689,6 +13978,40 @@ func (ec *executionContext) unmarshalInputConfirmEmailInput(ctx context.Context,
return it, nil
}
func (ec *executionContext) unmarshalInputConfirmInvitationInput(ctx context.Context, obj any) (types.ConfirmInvitationInput, error) {
var it types.ConfirmInvitationInput
asMap := map[string]any{}
for k, v := range obj.(map[string]any) {
asMap[k] = v
}
fieldsInOrder := [...]string{"token", "password"}
for _, k := range fieldsInOrder {
v, ok := asMap[k]
if !ok {
continue
}
switch k {
case "token":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token"))
data, err := ec.unmarshalNString2string(ctx, v)
if err != nil {
return it, err
}
it.Token = data
case "password":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("password"))
data, err := ec.unmarshalNString2string(ctx, v)
if err != nil {
return it, err
}
it.Password = data
}
}
return it, nil
}
func (ec *executionContext) unmarshalInputCreateControlInput(ctx context.Context, obj any) (types.CreateControlInput, error) {
var it types.CreateControlInput
asMap := map[string]any{}
@@ -14270,6 +14593,47 @@ func (ec *executionContext) unmarshalInputImportFrameworkInput(ctx context.Conte
return it, nil
}
func (ec *executionContext) unmarshalInputInviteUserInput(ctx context.Context, obj any) (types.InviteUserInput, error) {
var it types.InviteUserInput
asMap := map[string]any{}
for k, v := range obj.(map[string]any) {
asMap[k] = v
}
fieldsInOrder := [...]string{"organizationId", "email", "fullName"}
for _, k := range fieldsInOrder {
v, ok := asMap[k]
if !ok {
continue
}
switch k {
case "organizationId":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("organizationId"))
data, err := ec.unmarshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, v)
if err != nil {
return it, err
}
it.OrganizationID = data
case "email":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email"))
data, err := ec.unmarshalNString2string(ctx, v)
if err != nil {
return it, err
}
it.Email = data
case "fullName":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("fullName"))
data, err := ec.unmarshalNString2string(ctx, v)
if err != nil {
return it, err
}
it.FullName = data
}
}
return it, nil
}
func (ec *executionContext) unmarshalInputUnassignTaskInput(ctx context.Context, obj any) (types.UnassignTaskInput, error) {
var it types.UnassignTaskInput
asMap := map[string]any{}
@@ -14944,6 +15308,45 @@ func (ec *executionContext) _ConfirmEmailPayload(ctx context.Context, sel ast.Se
return out
}
var confirmInvitationPayloadImplementors = []string{"ConfirmInvitationPayload"}
func (ec *executionContext) _ConfirmInvitationPayload(ctx context.Context, sel ast.SelectionSet, obj *types.ConfirmInvitationPayload) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, confirmInvitationPayloadImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("ConfirmInvitationPayload")
case "success":
out.Values[i] = ec._ConfirmInvitationPayload_success(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.processDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var controlImplementors = []string{"Control", "Node"}
func (ec *executionContext) _Control(ctx context.Context, sel ast.SelectionSet, obj *types.Control) graphql.Marshaler {
@@ -16059,6 +16462,45 @@ func (ec *executionContext) _ImportFrameworkPayload(ctx context.Context, sel ast
return out
}
var inviteUserPayloadImplementors = []string{"InviteUserPayload"}
func (ec *executionContext) _InviteUserPayload(ctx context.Context, sel ast.SelectionSet, obj *types.InviteUserPayload) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, inviteUserPayloadImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("InviteUserPayload")
case "success":
out.Values[i] = ec._InviteUserPayload_success(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.processDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var mutationImplementors = []string{"Mutation"}
func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler {
@@ -16253,6 +16695,20 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "inviteUser":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
return ec._Mutation_inviteUser(ctx, field)
})
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "confirmInvitation":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
return ec._Mutation_confirmInvitation(ctx, field)
})
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
@@ -18486,6 +18942,25 @@ func (ec *executionContext) marshalNConfirmEmailPayload2ᚖgithubᚗcomᚋgetpro
return ec._ConfirmEmailPayload(ctx, sel, v)
}
func (ec *executionContext) unmarshalNConfirmInvitationInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐConfirmInvitationInput(ctx context.Context, v any) (types.ConfirmInvitationInput, error) {
res, err := ec.unmarshalInputConfirmInvitationInput(ctx, v)
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalNConfirmInvitationPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐConfirmInvitationPayload(ctx context.Context, sel ast.SelectionSet, v types.ConfirmInvitationPayload) graphql.Marshaler {
return ec._ConfirmInvitationPayload(ctx, sel, &v)
}
func (ec *executionContext) marshalNConfirmInvitationPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐConfirmInvitationPayload(ctx context.Context, sel ast.SelectionSet, v *types.ConfirmInvitationPayload) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._ConfirmInvitationPayload(ctx, sel, v)
}
func (ec *executionContext) marshalNControl2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐControl(ctx context.Context, sel ast.SelectionSet, v *types.Control) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
@@ -19132,6 +19607,25 @@ func (ec *executionContext) marshalNInt2int(ctx context.Context, sel ast.Selecti
return res
}
func (ec *executionContext) unmarshalNInviteUserInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐInviteUserInput(ctx context.Context, v any) (types.InviteUserInput, error) {
res, err := ec.unmarshalInputInviteUserInput(ctx, v)
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalNInviteUserPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐInviteUserPayload(ctx context.Context, sel ast.SelectionSet, v types.InviteUserPayload) graphql.Marshaler {
return ec._InviteUserPayload(ctx, sel, &v)
}
func (ec *executionContext) marshalNInviteUserPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐInviteUserPayload(ctx context.Context, sel ast.SelectionSet, v *types.InviteUserPayload) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._InviteUserPayload(ctx, sel, v)
}
func (ec *executionContext) marshalNNode2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐNode(ctx context.Context, sel ast.SelectionSet, v types.Node) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {

View File

@@ -33,6 +33,15 @@ type ConfirmEmailPayload struct {
Success bool `json:"success"`
}
type ConfirmInvitationInput struct {
Token string `json:"token"`
Password string `json:"password"`
}
type ConfirmInvitationPayload struct {
Success bool `json:"success"`
}
type Control struct {
ID gid.GID `json:"id"`
Version int `json:"version"`
@@ -247,6 +256,16 @@ type ImportFrameworkPayload struct {
FrameworkEdge *FrameworkEdge `json:"frameworkEdge"`
}
type InviteUserInput struct {
OrganizationID gid.GID `json:"organizationId"`
Email string `json:"email"`
FullName string `json:"fullName"`
}
type InviteUserPayload struct {
Success bool `json:"success"`
}
type Mutation struct {
}

View File

@@ -510,6 +510,39 @@ func (r *mutationResolver) ConfirmEmail(ctx context.Context, input types.Confirm
return &types.ConfirmEmailPayload{Success: true}, nil
}
// InviteUser is the resolver for the inviteUser field.
func (r *mutationResolver) InviteUser(ctx context.Context, input types.InviteUserInput) (*types.InviteUserPayload, error) {
user := UserFromContext(ctx)
organizations, err := r.usrmgrSvc.ListOrganizationsForUserID(ctx, user.ID)
if err != nil {
panic(fmt.Errorf("failed to list organizations for user: %w", err))
}
for _, organization := range organizations {
if organization.ID == input.OrganizationID {
err := r.usrmgrSvc.InviteUser(ctx, input.OrganizationID, input.FullName, input.Email)
if err != nil {
return nil, err
}
return &types.InviteUserPayload{Success: true}, nil
}
}
return nil, fmt.Errorf("organization not found")
}
// ConfirmInvitation is the resolver for the confirmInvitation field.
func (r *mutationResolver) ConfirmInvitation(ctx context.Context, input types.ConfirmInvitationInput) (*types.ConfirmInvitationPayload, error) {
err := r.usrmgrSvc.ConfirmInvitation(ctx, input.Token, input.Password)
if err != nil {
return nil, err
}
return &types.ConfirmInvitationPayload{Success: true}, nil
}
// LogoURL is the resolver for the logoUrl field.
func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, obj.ID.TenantID())

View File

@@ -74,12 +74,18 @@ type (
UserID gid.GID `json:"uid"`
Email string `json:"email"`
}
InvitationData struct {
OrganizationID gid.GID `json:"organization_id"`
Email string `json:"email"`
FullName string `json:"full_name"`
}
)
// Token types
const (
TokenTypeEmailConfirmation = "email_confirmation"
TokenTypePasswordReset = "password_reset"
TokenTypeEmailConfirmation = "email_confirmation"
TokenTypeOrganizationInvitation = "organization_invitation"
)
var (
@@ -90,6 +96,14 @@ var (
[1] %s
`
invitationEmailSubject = "Join Probo"
invitationEmailTemplate = `
You have been invited to join Probo!
Please click the link below to sign up[1]
[1] %s
`
)
func (e ErrInvalidCredentials) Error() string {
@@ -514,3 +528,115 @@ func (s Service) ListUsersForTenant(
return page.NewPage(users, cursor), nil
}
func (s Service) InviteUser(
ctx context.Context,
organizationID gid.GID,
fullName string,
emailAddress string,
) error {
if !strings.Contains(emailAddress, "@") {
return &ErrInvalidEmail{emailAddress}
}
if fullName == "" {
return &ErrInvalidFullName{fullName}
}
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeOrganizationInvitation,
1*time.Hour,
InvitationData{OrganizationID: organizationID, Email: emailAddress, FullName: fullName},
)
if err != nil {
return fmt.Errorf("cannot generate confirmation token: %w", err)
}
confirmationInvitationUrl := url.URL{
Scheme: "https",
Host: s.hostname,
Path: "/confirm-invitation",
RawQuery: url.Values{
"token": []string{confirmationToken},
}.Encode(),
}
confirmationEmail := coredata.NewEmail(
fullName,
emailAddress,
invitationEmailSubject,
fmt.Sprintf(invitationEmailTemplate, confirmationInvitationUrl.String()),
)
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := confirmationEmail.Insert(ctx, conn); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
return nil
},
)
}
func (s Service) ConfirmInvitation(ctx context.Context, tokenString string, password string) error {
token, err := statelesstoken.ValidateToken[InvitationData](
s.tokenSecret,
TokenTypeOrganizationInvitation,
tokenString,
)
if err != nil {
return fmt.Errorf("cannot validate organization invitation token: %w", err)
}
if len(password) < 8 {
return &ErrInvalidPassword{len(password)}
}
now := time.Now()
hashedPassword, err := s.hp.HashPassword([]byte(password))
if err != nil {
return fmt.Errorf("cannot hash password: %w", err)
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByEmail(ctx, tx, token.Data.Email); err != nil {
var errUserNotFound *coredata.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
user = &coredata.User{
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
EmailAddress: token.Data.Email,
HashedPassword: hashedPassword,
EmailAddressVerified: true,
FullName: token.Data.FullName,
CreatedAt: now,
UpdatedAt: now,
}
if err := user.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert user: %w", err)
}
}
}
uo := coredata.UserOrganization{
UserID: user.ID,
OrganizationID: token.Data.OrganizationID,
CreatedAt: now,
}
if err := uo.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert user organization: %w", err)
}
return nil
},
)
}