From 7971f88fcd14e95cc2f9b0a3727949303a2f24e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 17 Feb 2026 17:08:05 +0400 Subject: [PATCH] Create flow to add password after account activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- .../ActivateAccountPageMutation.graphql.ts | 28 +--- .../iam/CreatePasswordPageMutation.graphql.ts | 93 +++++++++++++ .../pages/iam/auth/ActivateAccountPage.tsx | 24 +++- .../src/pages/iam/auth/CreatePasswordPage.tsx | 114 ++++++++++++++++ apps/console/src/routes.tsx | 6 + pkg/iam/auth_service.go | 43 +++--- pkg/server/api/connect/v1/schema.graphql | 5 +- pkg/server/api/connect/v1/schema/schema.go | 126 ++++++------------ pkg/server/api/connect/v1/types/types.go | 7 +- pkg/server/api/connect/v1/v1_resolver.go | 10 +- 10 files changed, 313 insertions(+), 143 deletions(-) create mode 100644 apps/console/src/__generated__/iam/CreatePasswordPageMutation.graphql.ts create mode 100644 apps/console/src/pages/iam/auth/CreatePasswordPage.tsx diff --git a/apps/console/src/__generated__/iam/ActivateAccountPageMutation.graphql.ts b/apps/console/src/__generated__/iam/ActivateAccountPageMutation.graphql.ts index 7952f14e3..dd2ad0dbd 100644 --- a/apps/console/src/__generated__/iam/ActivateAccountPageMutation.graphql.ts +++ b/apps/console/src/__generated__/iam/ActivateAccountPageMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<852ecee082c322aba273f04575353fbd>> * @lightSyntaxTransform * @nogrep */ @@ -10,7 +10,6 @@ import { ConcreteRequest } from 'relay-runtime'; export type ActivateAccountInput = { - password?: string | null | undefined; token: string; }; export type ActivateAccountPageMutation$variables = { @@ -18,9 +17,7 @@ export type ActivateAccountPageMutation$variables = { }; export type ActivateAccountPageMutation$data = { readonly activateAccount: { - readonly profile: { - readonly id: string; - } | null | undefined; + readonly createPasswordToken: string | null | undefined; } | null | undefined; }; export type ActivateAccountPageMutation = { @@ -54,19 +51,8 @@ v1 = [ { "alias": null, "args": null, - "concreteType": "Profile", - "kind": "LinkedField", - "name": "profile", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "id", - "storageKey": null - } - ], + "kind": "ScalarField", + "name": "createPasswordToken", "storageKey": null } ], @@ -91,16 +77,16 @@ return { "selections": (v1/*: any*/) }, "params": { - "cacheID": "57d92d0ae2a6220af84fbd9fe27f7aa3", + "cacheID": "388c90522ecf3a3365e171a623fb2d2e", "id": null, "metadata": {}, "name": "ActivateAccountPageMutation", "operationKind": "mutation", - "text": "mutation ActivateAccountPageMutation(\n $input: ActivateAccountInput!\n) {\n activateAccount(input: $input) {\n profile {\n id\n }\n }\n}\n" + "text": "mutation ActivateAccountPageMutation(\n $input: ActivateAccountInput!\n) {\n activateAccount(input: $input) {\n createPasswordToken\n }\n}\n" } }; })(); -(node as any).hash = "0e073ce00eb7c435a875b5797f3e6db0"; +(node as any).hash = "c27b723383934423bf92926820426653"; export default node; diff --git a/apps/console/src/__generated__/iam/CreatePasswordPageMutation.graphql.ts b/apps/console/src/__generated__/iam/CreatePasswordPageMutation.graphql.ts new file mode 100644 index 000000000..0d3ad36ab --- /dev/null +++ b/apps/console/src/__generated__/iam/CreatePasswordPageMutation.graphql.ts @@ -0,0 +1,93 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type ResetPasswordInput = { + password: string; + token: string; +}; +export type CreatePasswordPageMutation$variables = { + input: ResetPasswordInput; +}; +export type CreatePasswordPageMutation$data = { + readonly resetPassword: { + readonly success: boolean; + } | null | undefined; +}; +export type CreatePasswordPageMutation = { + response: CreatePasswordPageMutation$data; + variables: CreatePasswordPageMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "ResetPasswordPayload", + "kind": "LinkedField", + "name": "resetPassword", + "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": "CreatePasswordPageMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "CreatePasswordPageMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "aedb0b874e2e55a106724665d51f840e", + "id": null, + "metadata": {}, + "name": "CreatePasswordPageMutation", + "operationKind": "mutation", + "text": "mutation CreatePasswordPageMutation(\n $input: ResetPasswordInput!\n) {\n resetPassword(input: $input) {\n success\n }\n}\n" + } +}; +})(); + +(node as any).hash = "d9bd91787098b9272f0046ad09554a1a"; + +export default node; diff --git a/apps/console/src/pages/iam/auth/ActivateAccountPage.tsx b/apps/console/src/pages/iam/auth/ActivateAccountPage.tsx index 4f96b8433..8f29cf178 100644 --- a/apps/console/src/pages/iam/auth/ActivateAccountPage.tsx +++ b/apps/console/src/pages/iam/auth/ActivateAccountPage.tsx @@ -7,16 +7,14 @@ import { useMutation } from "react-relay"; import { Link, useNavigate, useSearchParams } from "react-router"; import { graphql } from "relay-runtime"; -import type { ActivateAccountPageMutation } from "#/__generated__/iam/ActivateAccountPageMutation.graphql"; +import type { ActivateAccountPageMutation$data, ActivateAccountPageMutation } from "#/__generated__/iam/ActivateAccountPageMutation.graphql"; const activateAccountMutation = graphql` mutation ActivateAccountPageMutation( $input: ActivateAccountInput! ) { activateAccount(input: $input) { - profile { - id - } + createPasswordToken } } `; @@ -39,7 +37,7 @@ export default function ActivateAccountPage() { variables: { input: { token }, }, - onCompleted: (_, errors: GraphQLError[] | null) => { + onCompleted: (response: ActivateAccountPageMutation$data, errors: GraphQLError[] | null) => { if (errors) { for (const err of errors) { if (err.extensions?.code === "ALREADY_AUTHENTICATED") { @@ -63,7 +61,21 @@ export default function ActivateAccountPage() { ), variant: "success", }); - void navigate("/", { replace: true }); + + const { activateAccount } = response; + + if (!activateAccount) { + throw new Error("mutation data missing"); + } + + if (activateAccount.createPasswordToken) { + void navigate( + { pathname: "/auth/create-password", search: `?token=${activateAccount.createPasswordToken}` }, + { replace: true }, + ); + } else { + void navigate("/", { replace: true }); + } }, onError: (e) => { toast({ diff --git a/apps/console/src/pages/iam/auth/CreatePasswordPage.tsx b/apps/console/src/pages/iam/auth/CreatePasswordPage.tsx new file mode 100644 index 000000000..67e9aa649 --- /dev/null +++ b/apps/console/src/pages/iam/auth/CreatePasswordPage.tsx @@ -0,0 +1,114 @@ +import { formatError } from "@probo/helpers"; +import { usePageTitle } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { Button, Field, useToast } from "@probo/ui"; +import { useMutation } from "react-relay"; +import { Link, useNavigate, useSearchParams } from "react-router"; +import { graphql } from "relay-runtime"; +import { z } from "zod"; + +import type { CreatePasswordPageMutation } from "#/__generated__/iam/CreatePasswordPageMutation.graphql"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; + +const createPasswordMutation = graphql` + mutation CreatePasswordPageMutation($input: ResetPasswordInput!) { + resetPassword(input: $input) { + success + } + } +`; + +const schema = z.object({ + password: z.string().min(8), +}); + +export default function CreatePasswordPage() { + const { __ } = useTranslate(); + const { toast } = useToast(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + usePageTitle(__("Create Password")); + + const { register, handleSubmit, formState } = useFormWithSchema(schema, { + defaultValues: { + password: "", + }, + }); + + const [createPassword, isCreatingPassword] = useMutation(createPasswordMutation); + + const onSubmit = (data: z.infer) => { + createPassword({ + variables: { + input: { + password: data.password, + token: searchParams.get("token") ?? "", + }, + }, + onCompleted: (_, e) => { + if (e) { + toast({ + title: __("Password creation failed"), + description: formatError(__("Password creation failed"), e), + variant: "error", + }); + return; + } + + toast({ + title: __("Success"), + description: __("Account created successfully"), + variant: "success", + }); + void navigate("/auth/login", { replace: true }); + }, + onError: (e) => { + toast({ + title: __("Password creation failed"), + description: e.message, + variant: "error", + }); + }, + }); + }; + + return ( +
+
+

{__("Create a password")}

+

+ {__("Set a password for your account, with at least 8 characters")} +

+
+ +
void handleSubmit(onSubmit)(e)} className="space-y-4"> + + + + + +
+

+ {__("Already have an account?")} + {" "} + + {__("Log in here")} + +

+
+
+ ); +} diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index da22b7004..c44d39b6b 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -68,6 +68,12 @@ const routes = [ () => import("./pages/iam/auth/ActivateAccountPage"), ), }, + { + path: "create-password", + Component: lazy( + () => import("./pages/iam/auth/CreatePasswordPage"), + ), + }, { path: "forgot-password", Component: lazy(() => import("./pages/iam/auth/ForgotPasswordPage")), diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index 854277580..5eec430c4 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -45,7 +45,7 @@ type ( NewPassword string } - CreateIdentityFromInvitationRequest struct { + ActivateAccountRequest struct { InvitationToken string } @@ -87,7 +87,7 @@ func NewAuthService(svc *Service) *AuthService { return &AuthService{Service: svc} } -func (req CreateIdentityFromInvitationRequest) Validate() error { +func (req ActivateAccountRequest) Validate() error { v := validator.New() v.Check(req.InvitationToken, "invitationToken", validator.NotEmpty()) @@ -132,8 +132,8 @@ func (req CreateIdentityWithPasswordRequest) Validate() error { func (s *AuthService) ActivateAccount( ctx context.Context, - req *CreateIdentityFromInvitationRequest, -) (*coredata.MembershipProfile, *coredata.Session, error) { + req *ActivateAccountRequest, +) (*coredata.MembershipProfile, *string, error) { if err := req.Validate(); err != nil { return nil, nil, fmt.Errorf("invalid request: %w", err) } @@ -144,11 +144,12 @@ func (s *AuthService) ActivateAccount( } var ( - scope = coredata.NewScopeFromObjectID(payload.Data.InvitationID) - invitation = &coredata.Invitation{} - profile *coredata.MembershipProfile - session *coredata.Session - now = time.Now() + scope = coredata.NewScopeFromObjectID(payload.Data.InvitationID) + invitation = &coredata.Invitation{} + profile *coredata.MembershipProfile + identity *coredata.Identity + now = time.Now() + createPasswordToken *string ) err = s.pg.WithTx( @@ -185,7 +186,7 @@ func (s *AuthService) ActivateAccount( } } - identity := &coredata.Identity{} + identity = &coredata.Identity{} if err := identity.LoadByID(ctx, tx, profile.IdentityID); err != nil { return fmt.Errorf("cannot load identity: %w", err) } @@ -220,12 +221,6 @@ func (s *AuthService) ActivateAccount( return fmt.Errorf("cannot expire pending invitations: %w", err) } - session = coredata.NewRootSession(identity.ID, coredata.AuthMethodPassword, s.sessionDuration) - err = session.Insert(ctx, tx) - if err != nil { - return fmt.Errorf("cannot insert session: %w", err) - } - return nil }, ) @@ -234,7 +229,21 @@ func (s *AuthService) ActivateAccount( return nil, nil, err } - return profile, session, nil + if identity.HashedPassword == nil { + token, err := statelesstoken.NewToken( + s.tokenSecret, + TokenTypePasswordReset, + s.passwordResetTokenValidity, + PasswordResetData{Email: identity.EmailAddress}, + ) + if err != nil { + return nil, nil, fmt.Errorf("cannot generate password create token: %w", err) + } + + createPasswordToken = &token + } + + return profile, createPasswordToken, nil } func (s AuthService) ResetPassword( diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql index cfbe0389e..a2b859a4f 100644 --- a/pkg/server/api/connect/v1/schema.graphql +++ b/pkg/server/api/connect/v1/schema.graphql @@ -796,13 +796,10 @@ type SignOutPayload { } type ActivateAccountPayload { + createPasswordToken: String profile: Profile } -type CreatePasswordPayload { - success: Boolean! -} - type ForgotPasswordPayload { success: Boolean! } diff --git a/pkg/server/api/connect/v1/schema/schema.go b/pkg/server/api/connect/v1/schema/schema.go index f61a68df9..8521f1c14 100644 --- a/pkg/server/api/connect/v1/schema/schema.go +++ b/pkg/server/api/connect/v1/schema/schema.go @@ -80,7 +80,8 @@ type ComplexityRoot struct { } ActivateAccountPayload struct { - Profile func(childComplexity int) int + CreatePasswordToken func(childComplexity int) int + Profile func(childComplexity int) int } AssumeOrganizationSessionPayload struct { @@ -108,10 +109,6 @@ type ComplexityRoot struct { Organization func(childComplexity int) int } - CreatePasswordPayload struct { - Success func(childComplexity int) int - } - CreatePersonalAPIKeyPayload struct { PersonalAPIKeyEdge func(childComplexity int) int Token func(childComplexity int) int @@ -654,6 +651,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.AcceptInvitationPayload.Membership(childComplexity), true + case "ActivateAccountPayload.createPasswordToken": + if e.complexity.ActivateAccountPayload.CreatePasswordToken == nil { + break + } + + return e.complexity.ActivateAccountPayload.CreatePasswordToken(childComplexity), true case "ActivateAccountPayload.profile": if e.complexity.ActivateAccountPayload.Profile == nil { break @@ -731,13 +734,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.CreateOrganizationPayload.Organization(childComplexity), true - case "CreatePasswordPayload.success": - if e.complexity.CreatePasswordPayload.Success == nil { - break - } - - return e.complexity.CreatePasswordPayload.Success(childComplexity), true - case "CreatePersonalAPIKeyPayload.personalAPIKeyEdge": if e.complexity.CreatePersonalAPIKeyPayload.PersonalAPIKeyEdge == nil { break @@ -3281,13 +3277,10 @@ type SignOutPayload { } type ActivateAccountPayload { + createPasswordToken: String profile: Profile } -type CreatePasswordPayload { - success: Boolean! -} - type ForgotPasswordPayload { success: Boolean! } @@ -4327,6 +4320,35 @@ func (ec *executionContext) fieldContext_AcceptInvitationPayload_invitation(_ co return fc, nil } +func (ec *executionContext) _ActivateAccountPayload_createPasswordToken(ctx context.Context, field graphql.CollectedField, obj *types.ActivateAccountPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ActivateAccountPayload_createPasswordToken, + func(ctx context.Context) (any, error) { + return obj.CreatePasswordToken, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_ActivateAccountPayload_createPasswordToken(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ActivateAccountPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _ActivateAccountPayload_profile(ctx context.Context, field graphql.CollectedField, obj *types.ActivateAccountPayload) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -4756,35 +4778,6 @@ func (ec *executionContext) fieldContext_CreateOrganizationPayload_membership(_ return fc, nil } -func (ec *executionContext) _CreatePasswordPayload_success(ctx context.Context, field graphql.CollectedField, obj *types.CreatePasswordPayload) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_CreatePasswordPayload_success, - func(ctx context.Context) (any, error) { - return obj.Success, nil - }, - nil, - ec.marshalNBoolean2bool, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_CreatePasswordPayload_success(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "CreatePasswordPayload", - 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) _CreatePersonalAPIKeyPayload_personalAPIKeyEdge(ctx context.Context, field graphql.CollectedField, obj *types.CreatePersonalAPIKeyPayload) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6636,6 +6629,8 @@ func (ec *executionContext) fieldContext_Mutation_activateAccount(ctx context.Co IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { + case "createPasswordToken": + return ec.fieldContext_ActivateAccountPayload_createPasswordToken(ctx, field) case "profile": return ec.fieldContext_ActivateAccountPayload_profile(ctx, field) } @@ -16764,6 +16759,8 @@ func (ec *executionContext) _ActivateAccountPayload(ctx context.Context, sel ast switch field.Name { case "__typename": out.Values[i] = graphql.MarshalString("ActivateAccountPayload") + case "createPasswordToken": + out.Values[i] = ec._ActivateAccountPayload_createPasswordToken(ctx, field, obj) case "profile": out.Values[i] = ec._ActivateAccountPayload_profile(ctx, field, obj) default: @@ -17037,45 +17034,6 @@ func (ec *executionContext) _CreateOrganizationPayload(ctx context.Context, sel return out } -var createPasswordPayloadImplementors = []string{"CreatePasswordPayload"} - -func (ec *executionContext) _CreatePasswordPayload(ctx context.Context, sel ast.SelectionSet, obj *types.CreatePasswordPayload) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, createPasswordPayloadImplementors) - - 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("CreatePasswordPayload") - case "success": - out.Values[i] = ec._CreatePasswordPayload_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 createPersonalAPIKeyPayloadImplementors = []string{"CreatePersonalAPIKeyPayload"} func (ec *executionContext) _CreatePersonalAPIKeyPayload(ctx context.Context, sel ast.SelectionSet, obj *types.CreatePersonalAPIKeyPayload) graphql.Marshaler { diff --git a/pkg/server/api/connect/v1/types/types.go b/pkg/server/api/connect/v1/types/types.go index d26fa8c91..f2fe5262d 100644 --- a/pkg/server/api/connect/v1/types/types.go +++ b/pkg/server/api/connect/v1/types/types.go @@ -39,7 +39,8 @@ type ActivateAccountInput struct { } type ActivateAccountPayload struct { - Profile *Profile `json:"profile,omitempty"` + CreatePasswordToken *string `json:"createPasswordToken,omitempty"` + Profile *Profile `json:"profile,omitempty"` } type AssumeOrganizationSessionInput struct { @@ -91,10 +92,6 @@ type CreateOrganizationPayload struct { Membership *Membership `json:"membership"` } -type CreatePasswordPayload struct { - Success bool `json:"success"` -} - type CreatePersonalAPIKeyInput struct { Name string `json:"name"` ExpiresAt time.Time `json:"expiresAt"` diff --git a/pkg/server/api/connect/v1/v1_resolver.go b/pkg/server/api/connect/v1/v1_resolver.go index c8f62ae26..8f31cde77 100644 --- a/pkg/server/api/connect/v1/v1_resolver.go +++ b/pkg/server/api/connect/v1/v1_resolver.go @@ -384,9 +384,9 @@ func (r *mutationResolver) SignOut(ctx context.Context) (*types.SignOutPayload, // ActivateAccount is the resolver for the signUpFromInvitation field. func (r *mutationResolver) ActivateAccount(ctx context.Context, input types.ActivateAccountInput) (*types.ActivateAccountPayload, error) { - user, session, err := r.iam.AuthService.ActivateAccount( + user, createPasswordToken, err := r.iam.AuthService.ActivateAccount( ctx, - &iam.CreateIdentityFromInvitationRequest{ + &iam.ActivateAccountRequest{ InvitationToken: input.Token, }, ) @@ -411,11 +411,9 @@ func (r *mutationResolver) ActivateAccount(ctx context.Context, input types.Acti return nil, gqlutils.Internal(ctx) } - w := gqlutils.HTTPResponseWriterFromContext(ctx) - r.sessionCookie.Set(w, session) - return &types.ActivateAccountPayload{ - Profile: types.NewProfile(user), + CreatePasswordToken: createPasswordToken, + Profile: types.NewProfile(user), }, nil }