diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index af6f060a4..bdfd0c382 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -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() { } /> + + + + + + } + /> (null); + const [token, setToken] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const location = useLocation(); + const navigate = useNavigate(); + const { toast } = useToast(); + const [commitMutation] = useMutation( + 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 ( + <> + + Confirm Invitation - Probo + + +
+ + + + Invitation Confirmation + + + Complete your account setup to join the organization + + + + + {isConfirmed ? ( +
+

+ Your invitation has been confirmed successfully! +

+ +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + setToken(e.target.value)} + placeholder="Enter your invitation token" + disabled={isLoading} + required + /> +

+ The token has been automatically filled from the URL if + available +

+
+ +
+ + setPassword(e.target.value)} + placeholder="Create a password" + disabled={isLoading} + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your password" + disabled={isLoading} + required + /> +
+ + +
+ )} +
+ + + {!isConfirmed && ( + + Back to Login + + )} + +
+
+ + ); +} diff --git a/apps/console/src/pages/SettingsPage.tsx b/apps/console/src/pages/SettingsPage.tsx index 138e52d98..87710e97b 100644 --- a/apps/console/src/pages/SettingsPage.tsx +++ b/apps/console/src/pages/SettingsPage.tsx @@ -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(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({ />
- - + + setInviteFullName(e.target.value)} + placeholder="Enter full name" + />
- + diff --git a/apps/console/src/pages/__generated__/ConfirmInvitationPageMutation.graphql.ts b/apps/console/src/pages/__generated__/ConfirmInvitationPageMutation.graphql.ts new file mode 100644 index 000000000..3ab659158 --- /dev/null +++ b/apps/console/src/pages/__generated__/ConfirmInvitationPageMutation.graphql.ts @@ -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; diff --git a/apps/console/src/pages/__generated__/SettingsPageInviteUserMutation.graphql.ts b/apps/console/src/pages/__generated__/SettingsPageInviteUserMutation.graphql.ts new file mode 100644 index 000000000..2875d6892 --- /dev/null +++ b/apps/console/src/pages/__generated__/SettingsPageInviteUserMutation.graphql.ts @@ -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; diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 613a2a160..cc8065d1a 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -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! +} diff --git a/pkg/server/api/console/v1/schema/schema.go b/pkg/server/api/console/v1/schema/schema.go index ac20d58b8..55e33799a 100644 --- a/pkg/server/api/console/v1/schema/schema.go +++ b/pkg/server/api/console/v1/schema/schema.go @@ -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)) { diff --git a/pkg/server/api/console/v1/types/types.go b/pkg/server/api/console/v1/types/types.go index cdd2f6fa2..88a0ab82e 100644 --- a/pkg/server/api/console/v1/types/types.go +++ b/pkg/server/api/console/v1/types/types.go @@ -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 { } diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index 86ee55a36..54871317d 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -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()) diff --git a/pkg/usrmgr/usrmgr.go b/pkg/usrmgr/usrmgr.go index 880cd45d8..9b8c9a872 100644 --- a/pkg/usrmgr/usrmgr.go +++ b/pkg/usrmgr/usrmgr.go @@ -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 + }, + ) +}