diff --git a/.changeset/tall-words-type.md b/.changeset/tall-words-type.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tall-words-type.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/mosaic/references/testing.md b/.claude/skills/mosaic/references/testing.md index 39a52aec92f..af221d5e4d7 100644 --- a/.claude/skills/mosaic/references/testing.md +++ b/.claude/skills/mosaic/references/testing.md @@ -104,8 +104,9 @@ is the worked example. - **`fapi.ts`**: typed builders for FAPI JSON (`fapiEnvironment`, `fapiUser`, `fapiSession`, `fapiClient`, `fapiOrganization`, `fapiMembership`, - `fapiInvitation`, `fapiSuggestion`, `fapiToken`). They return the wire shapes - from `@clerk/shared/types`, so a fixture can't invent a field. + `fapiInvitation`, `fapiSuggestion`, `fapiToken`, `fapiSessionVerification`, + `fapiVerification`). They return the wire shapes from `@clerk/shared/types`, + so a fixture can't invent a field. `fapiEnvironment(overrides)` shallow-merges per section, for example `{ organization_settings: { enabled: false } }`. - **`fake-fapi.ts`**: @@ -115,6 +116,18 @@ is the worked example. - `holdRequests(method, path)` holds matching requests open. Assert the in-flight UI, then `release()` to let them through to `serveFapi`, or `fail(code)` to answer with a 400 Clerk error. +- **`fake-fapi/`**: fakes for larger endpoint families, kept out of + `fake-fapi.ts` and registered by `serveFapi`. + - `verification.ts` fakes session reverification (`/verify` and its + prepare and attempt endpoints). Seed it with + `serveFapi({ verification: { firstFactors, secondFactors, secrets } })`: + the factors the user has, and the password or code each strategy accepts. + The fake follows the server: level `second_factor` starts on the second + factor, `multi_factor` goes first factor then second, and a user with no + second factor is downgraded to `first_factor`. It rejects calls the server + rejects: before start, in the wrong status, with a strategy the user + doesn't have, and an email, phone, or passkey attempt that wasn't + prepared. Wrong passwords and codes answer 422. - **`render.tsx`**: `renderWithClerk(ui)` renders inside `ClerkContextProvider` and `MosaicProvider`, loads Clerk, and returns `{ clerk, navigate, ... }`. `navigate` is the router Clerk was loaded with, called with the path. @@ -149,8 +162,11 @@ click. - `setActive` only touches the session when the document has focus. The setup focuses the window, so don't blur it by accident. -- clerk-js treats a **422** as "unauthenticated" and refetches the client. Use - 400s for ordinary failures, which is what `holdRequests(...).fail()` sends. +- clerk-js treats a **422** from a session touch (`setActive`, focus) or a token + request as "unauthenticated" and refetches the client. Answer those with + 400s, which is what `holdRequests(...).fail()` sends. Form errors on other + endpoints are real 422s, for example a wrong password or code, and are + handled as ordinary errors. - After a touch, `getToken` is served from the token the touch returned, so there is no tokens request to hold during an organization switch. To observe Clerk's transitive state, route the action through navigation and make diff --git a/.claude/skills/mosaic/references/views.md b/.claude/skills/mosaic/references/views.md index aedf67aef06..84253fd8355 100644 --- a/.claude/skills/mosaic/references/views.md +++ b/.claude/skills/mosaic/references/views.md @@ -39,8 +39,8 @@ Two shapes, chosen by whether the slice fetches its own data: - A **wrapper composes** model + controller + view, and the view is a pure function of props (`user-button.tsx`). - A **leaf view owns its controller** and takes the effect as a prop - (`UserProfileDeleteSectionView` calls `useUserProfileDeleteSectionController` - with its `onDelete`). Still no Clerk — the effect arrives from above. + (`UserProfileUsernameRowView` calls `useUserProfileEditUsernameController` + with its `onSubmit`). Still no Clerk — the effect arrives from above. ## Where the strings live diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index f622ef0d6a3..a9bb741067d 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -8,6 +8,12 @@ import type { import { http, HttpResponse, type JsonBodyType } from 'msw'; import { setupWorker } from 'msw/browser'; +import { + createVerificationState, + type FakeVerificationSeed, + type FakeVerificationState, + verificationHandlers, +} from './fake-fapi/verification'; import { fapiClient, type FapiEnvironment, @@ -29,9 +35,12 @@ export interface FakeFapiState { memberships: OrganizationMembershipJSON[]; invitations: UserOrganizationInvitationJSON[]; suggestions: OrganizationSuggestionJSON[]; + verification: FakeVerificationState; } -export type FakeFapiSeed = Partial; +export type FakeFapiSeed = Partial> & { + verification?: FakeVerificationSeed; +}; const unhandled: string[] = []; @@ -76,16 +85,19 @@ function missing() { } export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { + const { verification, ...rest } = seed; const state: FakeFapiState = { environment: fapiEnvironment(), client: fapiClient(), memberships: [], invitations: [], suggestions: [], - ...seed, + ...rest, + verification: createVerificationState(verification), }; worker.use( + ...verificationHandlers(state, fapiUrl), http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)), http.get(fapiUrl('/v1/client'), () => envelope(state.client, null)), http.post(fapiUrl('/v1/client/sessions/:id/tokens'), ({ params }) => { diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/verification.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/verification.ts new file mode 100644 index 00000000000..6bc62122ef4 --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/verification.ts @@ -0,0 +1,264 @@ +import type { + ClientJSON, + SessionJSON, + SessionVerificationLevel, + SessionVerificationStatus, + SignInFirstFactorJSON, + SignInSecondFactorJSON, + VerificationJSON, +} from '@clerk/shared/types'; +import { http, HttpResponse } from 'msw'; + +import { fapiSessionVerification, fapiVerification } from '../fapi'; + +export type VerificationSecretStrategy = 'password' | 'email_code' | 'phone_code' | 'totp' | 'backup_code'; + +export interface FakeVerificationState { + level: SessionVerificationLevel; + status: SessionVerificationStatus | null; + firstFactors: SignInFirstFactorJSON[]; + secondFactors: SignInSecondFactorJSON[]; + secrets: Partial>; + firstFactorVerification: VerificationJSON | null; + secondFactorVerification: VerificationJSON | null; +} + +export type FakeVerificationSeed = Partial>; + +export interface VerificationHost { + client: ClientJSON; + verification: FakeVerificationState; +} + +export function createVerificationState(seed: FakeVerificationSeed = {}): FakeVerificationState { + return { + level: 'first_factor', + status: null, + firstFactors: [{ strategy: 'password' }], + secondFactors: [], + secrets: {}, + firstFactorVerification: null, + secondFactorVerification: null, + ...seed, + }; +} + +function envelope(response: object, client: ClientJSON) { + return HttpResponse.json({ response, client }); +} + +function findSession(state: VerificationHost, id: unknown): SessionJSON | undefined { + return state.client.sessions.find(session => session.id === id); +} + +function missing() { + return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); +} + +type Stage = 'first' | 'second'; + +type VerificationRequest = { params: Record; request: Request }; + +const REVERIFICATION_LEVELS: readonly string[] = ['first_factor', 'second_factor', 'multi_factor']; +const PREPARABLE_STRATEGIES: readonly string[] = ['email_code', 'phone_code', 'passkey']; + +function apiError(status: number, code: string, message: string, longMessage = message, paramName?: string) { + return HttpResponse.json( + { + errors: [ + { + code, + message, + long_message: longMessage, + ...(paramName ? { meta: { param_name: paramName } } : {}), + }, + ], + }, + { status }, + ); +} + +const verificationErrors = { + notStarted: () => + apiError( + 400, + 'session_reverification_missing', + 'is missing', + 'You need to start a new session verification flow first', + ), + invalidState: (action: string, resolution: string) => + apiError( + 400, + 'invalid_action_for_session_reverification', + 'Invalid action for session reverification', + `We were unable to ${action} for this session, as it's not ready for ${resolution}.`, + ), + invalidParam: (param: string, value: string | null) => + apiError( + 422, + 'form_param_value_invalid', + 'is invalid', + `${value} does not match one of the allowed values for parameter ${param}`, + param, + ), + missingResource: (param: string) => + apiError( + 422, + 'form_resource_not_found', + 'is missing', + `The resource associated with the supplied ${param} was not found.`, + param, + ), + notSent: () => + apiError( + 400, + 'verification_not_sent', + 'not sent', + 'You need to send a verification code before attempting to verify.', + ), + passwordIncorrect: () => + apiError( + 422, + 'form_password_incorrect', + 'Password is incorrect. Try again, or use another method.', + undefined, + 'password', + ), + codeIncorrect: () => apiError(422, 'form_code_incorrect', 'is incorrect', 'Incorrect code', 'code'), +}; + +function verificationReply(state: VerificationHost, session: SessionJSON) { + const { level, status, firstFactors, secondFactors, firstFactorVerification, secondFactorVerification } = + state.verification; + const complete = status === 'complete'; + return envelope( + fapiSessionVerification(session, { + level, + status: status ?? 'needs_first_factor', + supported_first_factors: complete ? null : firstFactors, + supported_second_factors: status === 'needs_second_factor' ? secondFactors : null, + first_factor_verification: firstFactorVerification, + second_factor_verification: secondFactorVerification, + }), + state.client, + ); +} + +function setStageVerification(state: VerificationHost, stage: Stage, verification: VerificationJSON) { + state.verification = + stage === 'first' + ? { ...state.verification, firstFactorVerification: verification } + : { ...state.verification, secondFactorVerification: verification }; +} + +export function verificationHandlers(state: VerificationHost, url: (path: string) => string) { + const guard = (stage: Stage, action: string, session: SessionJSON | undefined, strategy: string | null) => { + if (!session) { + return missing(); + } + const { status, firstFactors, secondFactors } = state.verification; + if (status === null) { + return verificationErrors.notStarted(); + } + const expected = stage === 'first' ? 'needs_first_factor' : 'needs_second_factor'; + if (status !== expected) { + return verificationErrors.invalidState(`${action} ${stage} factor`, `${stage} factor verification`); + } + const factors: Array<{ strategy: string }> = stage === 'first' ? firstFactors : secondFactors; + if (!factors.some(factor => factor.strategy === strategy)) { + return verificationErrors.invalidParam('strategy', strategy); + } + return undefined; + }; + + const prepare = + (stage: Stage) => + async ({ params, request }: VerificationRequest) => { + const session = findSession(state, params.id); + const body = new URLSearchParams(await request.text()); + const strategy = body.get('strategy'); + const rejected = + guard(stage, 'prepare', session, strategy) ?? + (strategy && PREPARABLE_STRATEGIES.includes(strategy) + ? undefined + : verificationErrors.invalidParam('strategy', strategy)); + if (rejected || !session || !strategy) { + return rejected; + } + const idParam = + strategy === 'email_code' ? 'email_address_id' : strategy === 'phone_code' ? 'phone_number_id' : null; + if (idParam) { + const factors: Array> = + stage === 'first' ? state.verification.firstFactors : state.verification.secondFactors; + const id = body.get(idParam); + if (!factors.some(factor => factor.strategy === strategy && factor[idParam] === id)) { + return verificationErrors.missingResource(idParam); + } + } + setStageVerification(state, stage, fapiVerification(strategy)); + return verificationReply(state, session); + }; + + const attempt = + (stage: Stage) => + async ({ params, request }: VerificationRequest) => { + const session = findSession(state, params.id); + const body = new URLSearchParams(await request.text()); + const strategy = body.get('strategy'); + const rejected = guard(stage, 'attempt', session, strategy); + if (rejected || !session || !strategy) { + return rejected; + } + const prepared = + stage === 'first' ? state.verification.firstFactorVerification : state.verification.secondFactorVerification; + if (PREPARABLE_STRATEGIES.includes(strategy) && prepared?.strategy !== strategy) { + return verificationErrors.notSent(); + } + if (strategy !== 'passkey' && state.verification.secrets[strategy as VerificationSecretStrategy] === undefined) { + throw new Error(`fake FAPI: no secret seeded for strategy "${strategy}"`); + } + if (strategy === 'password') { + if (body.get('password') !== state.verification.secrets.password) { + return verificationErrors.passwordIncorrect(); + } + } else if (strategy !== 'passkey') { + if (body.get('code') !== state.verification.secrets[strategy as VerificationSecretStrategy]) { + return verificationErrors.codeIncorrect(); + } + } + setStageVerification(state, stage, fapiVerification(strategy, { status: 'verified', attempts: 1 })); + const status = + stage === 'first' && state.verification.level === 'multi_factor' ? 'needs_second_factor' : 'complete'; + state.verification = { ...state.verification, status }; + return verificationReply(state, session); + }; + + return [ + http.post(url('/v1/client/sessions/:id/verify'), async ({ params, request }) => { + const session = findSession(state, params.id); + if (!session) { + return missing(); + } + const requested = new URLSearchParams(await request.text()).get('level'); + if (!requested || !REVERIFICATION_LEVELS.includes(requested)) { + return verificationErrors.invalidParam('level', requested); + } + const level = + requested === 'first_factor' || state.verification.secondFactors.length === 0 + ? 'first_factor' + : (requested as SessionVerificationLevel); + state.verification = { + ...state.verification, + level, + status: level === 'second_factor' ? 'needs_second_factor' : 'needs_first_factor', + firstFactorVerification: null, + secondFactorVerification: null, + }; + return verificationReply(state, session); + }), + http.post(url('/v1/client/sessions/:id/verify/prepare_first_factor'), prepare('first')), + http.post(url('/v1/client/sessions/:id/verify/prepare_second_factor'), prepare('second')), + http.post(url('/v1/client/sessions/:id/verify/attempt_first_factor'), attempt('first')), + http.post(url('/v1/client/sessions/:id/verify/attempt_second_factor'), attempt('second')), + ]; +} diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index 1473d6e67c6..b8d310f53d8 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -11,12 +11,15 @@ import type { OrganizationMembershipJSON, OrganizationSettingsJSON, OrganizationSuggestionJSON, + PublicKeyCredentialRequestOptionsJSON, PublicOrganizationDataJSON, SessionJSON, + SessionVerificationJSON, TokenJSON, UserJSON, UserOrganizationInvitationJSON, UserSettingsJSON, + VerificationJSON, } from '@clerk/shared/types'; type Settings = Omit; @@ -292,6 +295,55 @@ export function fapiSession(overrides: Partial & Pick = { + password: 'verification_password', + email_code: 'verification_otp', + phone_code: 'verification_otp', + totp: 'verification_totp', + backup_code: 'verification_backup_code', + passkey: 'verification_passkey', +}; + +export function fapiPasskeyRequestOptions(): PublicKeyCredentialRequestOptionsJSON { + return { + allowCredentials: [], + challenge: 'Y2hhbGxlbmdl', + rpId: 'clerk.abcef.12345.prod.lclclerk.com', + timeout: 60000, + userVerification: 'preferred', + }; +} + +export function fapiVerification(strategy: string, overrides: Partial = {}): VerificationJSON { + return { + object: verificationObjects[strategy] ?? 'verification', + status: 'unverified', + strategy, + attempts: 0, + expire_at: farFuture, + ...(strategy === 'passkey' ? { nonce: JSON.stringify(fapiPasskeyRequestOptions()) } : {}), + ...overrides, + } as VerificationJSON; +} + +export function fapiSessionVerification( + session: SessionJSON, + overrides: Partial = {}, +): SessionVerificationJSON { + return { + object: 'session_verification', + id: `sessverif_${session.id}`, + status: 'needs_first_factor', + level: 'first_factor', + session, + first_factor_verification: null, + second_factor_verification: null, + supported_first_factors: [], + supported_second_factors: [], + ...overrides, + }; +} + export function fapiClient(sessions: SessionJSON[] = []): ClientJSON { return { object: 'client', diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts new file mode 100644 index 00000000000..ab512135048 --- /dev/null +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts @@ -0,0 +1,243 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ReverificationController } from '../../features/reverification'; +import { deferred } from '../../machines/__tests__/test-utils'; +import { useDestructiveController } from './destructive.controller'; + +const idleReverification = { status: 'idle', visible: false, reset: vi.fn() } as ReverificationController; + +describe('useDestructiveController', () => { + it('starts closed and opens from the opener or from onOpenChange', () => { + const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve() })); + expect(result.current.open).toBe(false); + expect(result.current.isDeleting).toBe(false); + + act(() => result.current.openDestructiveDialog()); + expect(result.current.open).toBe(true); + + act(() => result.current.onOpenChange(false)); + expect(result.current.open).toBe(false); + + act(() => result.current.onOpenChange(true)); + expect(result.current.open).toBe(true); + expect(result.current.isDeleting).toBe(false); + }); + + it('ignores the opener once the dialog is already open', () => { + const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve() })); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); + + act(() => result.current.openDestructiveDialog()); + + expect(result.current.isDeleting).toBe(true); + }); + + it('stays open and pending until the action resolves, then closes', async () => { + const pending = deferred(); + const onDelete = vi.fn(() => pending.promise); + const { result } = renderHook(() => useDestructiveController({ onDelete })); + act(() => result.current.onOpenChange(true)); + + act(() => { + void result.current.onDelete(); + }); + expect(onDelete).toHaveBeenCalledOnce(); + expect(result.current.open).toBe(true); + expect(result.current.isDeleting).toBe(true); + + await act(async () => { + pending.resolve(); + await pending.promise; + }); + await waitFor(() => expect(result.current.open).toBe(false)); + expect(result.current.isDeleting).toBe(false); + }); + + it('stays open with a message when the action rejects, and a retry can succeed', async () => { + const onDelete = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('Your subscription is still active.')) + .mockResolvedValueOnce(undefined); + const { result } = renderHook(() => useDestructiveController({ onDelete })); + act(() => result.current.onOpenChange(true)); + + await act(async () => { + await result.current.onDelete(); + }); + expect(result.current.open).toBe(true); + expect(result.current.isDeleting).toBe(false); + expect(result.current.errorMessage).toBe('Something went wrong'); + + await act(async () => { + await result.current.onDelete(); + }); + await waitFor(() => expect(result.current.open).toBe(false)); + expect(onDelete).toHaveBeenCalledTimes(2); + }); + + it('closes without a message when reverification is cancelled', async () => { + const onDelete = vi.fn(() => + Promise.reject(new ClerkRuntimeError('cancelled', { code: 'reverification_cancelled' })), + ); + const { result } = renderHook(() => useDestructiveController({ onDelete, reverification: idleReverification })); + act(() => result.current.onOpenChange(true)); + + await act(async () => { + await result.current.onDelete(); + }); + + expect(result.current.open).toBe(false); + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('ignores a close while the action is in flight, unless reverification is active', async () => { + const pending = deferred(); + const { result } = renderHook(() => + useDestructiveController({ onDelete: () => pending.promise, reverification: idleReverification }), + ); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); + + act(() => result.current.onOpenChange(false)); + expect(result.current.open).toBe(true); + expect(result.current.isDeleting).toBe(true); + + await act(async () => { + pending.resolve(); + await pending.promise; + }); + }); + + it('lets an active reverification close the dialog while the action is still pending', () => { + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => new Promise(() => {}), + reverification: { + status: 'ready', + visible: true, + reset: vi.fn(), + onCancel: vi.fn(), + } as ReverificationController, + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); + + act(() => result.current.onOpenChange(false)); + + expect(result.current.open).toBe(false); + }); + + it('ignores a close while reverification is retrying the action', () => { + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => new Promise(() => {}), + reverification: { status: 'retrying', visible: true, reset: vi.fn() } as ReverificationController, + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); + + act(() => result.current.onOpenChange(false)); + + expect(result.current.open).toBe(true); + expect(result.current.isDeleting).toBe(true); + }); + + it('passes reverification through', () => { + const { result } = renderHook(() => + useDestructiveController({ onDelete: () => Promise.resolve(), reverification: idleReverification }), + ); + + expect(result.current.reverification).toBe(idleReverification); + }); + + it('is on the confirm step without reverification', () => { + const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve() })); + + expect(result.current.step).toBe('confirm'); + }); + + it.each([ + [false, 'confirm'], + [true, 'verify'], + ] as const)('while deleting, reverification visible=%s uses the %s step', (visible, step) => { + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => new Promise(() => {}), + reverification: { status: 'ready', visible, reset: vi.fn() } as ReverificationController, + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); + + expect(result.current.step).toBe(step); + }); + + it('stays on the confirm step when not deleting, whatever the reverification status', () => { + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => Promise.resolve(), + reverification: { + status: 'ready', + visible: true, + reset: vi.fn(), + onCancel: vi.fn(), + } as ReverificationController, + }), + ); + act(() => result.current.onOpenChange(true)); + + expect(result.current.step).toBe('confirm'); + }); + + it('releases the previous reverification before a new delete starts', () => { + const reset = vi.fn(); + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => new Promise(() => {}), + reverification: { status: 'idle', visible: false, reset } as ReverificationController, + }), + ); + act(() => result.current.onOpenChange(true)); + expect(reset).not.toHaveBeenCalled(); + + act(() => { + void result.current.onDelete(); + }); + + expect(reset).toHaveBeenCalledOnce(); + }); + + it('cancels reverification when the dialog closes', () => { + const onCancel = vi.fn(); + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => new Promise(() => {}), + reverification: { status: 'ready', visible: true, reset: vi.fn(), onCancel } as ReverificationController, + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); + + act(() => result.current.onOpenChange(false)); + + expect(onCancel).toHaveBeenCalledOnce(); + expect(result.current.open).toBe(false); + }); +}); diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.ts new file mode 100644 index 00000000000..53501e69bd8 --- /dev/null +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.ts @@ -0,0 +1,79 @@ +import { isReverificationCancelledError } from '@clerk/shared/error'; +import { useState } from 'react'; + +import type { ReverificationController } from '../../features/reverification'; +import type { DestructiveControlledProps } from './destructive'; + +export type DestructiveController = Pick< + DestructiveControlledProps, + 'open' | 'isDeleting' | 'errorMessage' | 'onOpenChange' | 'reverification' | 'step' +> & { + onDelete: () => Promise; + openDestructiveDialog: () => void; +}; + +type DestructiveState = + | { status: 'closed' } + | { status: 'open-needs-confirmation' } + | { status: 'open-pending' } + | { status: 'open-error'; errorMessage: string }; + +/** Optional controller for use with Destructive block */ +export function useDestructiveController({ + onDelete, + reverification, +}: { + onDelete: () => Promise; + reverification?: ReverificationController; +}): DestructiveController { + const [destructiveState, setDestructiveState] = useState({ status: 'closed' }); + const { status } = destructiveState; + + const openDestructiveDialog = () => { + if (status === 'closed') { + setDestructiveState({ status: 'open-needs-confirmation' }); + } + }; + + const isDeleting = status === 'open-pending'; + const step = isDeleting && reverification?.visible ? 'verify' : 'confirm'; + + return { + open: status !== 'closed', + isDeleting, + errorMessage: status === 'open-error' ? destructiveState.errorMessage : undefined, + reverification, + step, + openDestructiveDialog, + onDelete: async () => { + if (status === 'open-needs-confirmation' || status === 'open-error') { + reverification?.reset(); + setDestructiveState({ status: 'open-pending' }); + try { + await onDelete(); + setDestructiveState({ status: 'closed' }); + } catch (error: unknown) { + if (isReverificationCancelledError(error)) { + setDestructiveState({ status: 'closed' }); + return; + } + // TODO: Better error handling, localization + setDestructiveState({ status: 'open-error', errorMessage: 'Something went wrong' }); + } + } + }, + onOpenChange: nextIsOpen => { + const canCancel = Boolean(reverification?.onCancel); + if (!nextIsOpen && isDeleting && !canCancel) { + return; + } + + if (nextIsOpen) { + setDestructiveState({ status: 'open-needs-confirmation' }); + } else { + reverification?.onCancel?.(); + setDestructiveState({ status: 'closed' }); + } + }, + }; +} diff --git a/packages/mosaic/src/blocks/destructive/destructive.feature.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.feature.test.tsx new file mode 100644 index 00000000000..8e29986f811 --- /dev/null +++ b/packages/mosaic/src/blocks/destructive/destructive.feature.test.tsx @@ -0,0 +1,212 @@ +import { reverificationError } from '@clerk/shared/authorization-errors'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it, vi } from 'vitest'; + +import { fapiUrl, serveFapi, worker } from '../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiSession, fapiUser } from '../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../__tests__/feature/render'; +import { useReverificationFlow } from '../../features/reverification'; +import { Destructive } from './destructive'; +import { useDestructiveController } from './destructive.controller'; + +const session = fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }); + +const CONFIRM_TITLE = 'Delete account?'; +const VERIFY = '/v1/client/sessions/sess_1/verify'; + +function Host({ + action, + cleanupMs, + settleMs, + beforeRunMs, +}: { + action: () => Promise; + cleanupMs?: number; + settleMs?: number; + beforeRunMs?: number; +}) { + const [run, reverification] = useReverificationFlow(action); + const controller = useDestructiveController({ + onDelete: async () => { + if (beforeRunMs !== undefined) { + await new Promise(resolve => setTimeout(resolve, beforeRunMs)); + } + try { + const result = await run(); + if (settleMs !== undefined) { + await new Promise(resolve => setTimeout(resolve, settleMs)); + } + return result; + } catch (error) { + if (cleanupMs !== undefined) { + await new Promise(resolve => setTimeout(resolve, cleanupMs)); + } + throw error; + } + }, + reverification, + }); + + return ( + <> + + {controller.open ? 'open' : 'closed'} + + + ); +} + +async function openVerification() { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.type(await screen.findByRole('textbox'), 'Delete'); + await user.click(screen.getByRole('button', { name: 'Delete account' })); + return { user }; +} + +async function untilConfirmStepIsGone() { + await waitFor(() => expect(screen.queryByText(CONFIRM_TITLE)).toBeNull()); +} + +function watchForConfirmStep() { + const log: string[] = []; + const inspect = (records: MutationRecord[]) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (node.textContent?.includes(CONFIRM_TITLE)) { + log.push(`added: ${(node as Element).outerHTML?.slice(0, 120) ?? node.textContent}`); + } + } + } + }; + const observer = new MutationObserver(inspect); + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + inspect(observer.takeRecords()); + observer.disconnect(); + return log; + }; +} + +const passwordVerification = { + client: fapiClient([session]), + verification: { secrets: { password: 'hunter2' }, firstFactors: [{ strategy: 'password' as const }] }, +}; + +describe('Destructive with reverification', () => { + it('closes from the error card without going back to the confirm step', async () => { + const action = vi.fn().mockResolvedValueOnce(reverificationError()).mockResolvedValue({ done: true }); + serveFapi({ client: fapiClient([session]) }); + await renderWithClerk( + , + ); + worker.use( + http.post(fapiUrl(VERIFY), () => + HttpResponse.json({ errors: [{ code: 'some_new_error', message: 'Nope' }] }, { status: 400 }), + ), + ); + const { user } = await openVerification(); + + expect(await screen.findByText('Something went wrong')).toBeVisible(); + const buttons = screen.getAllByRole('button', { name: 'Close' }); + await untilConfirmStepIsGone(); + const stopWatching = watchForConfirmStep(); + await user.click(buttons[buttons.length - 1]); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(stopWatching()).toEqual([]); + + expect(screen.getByLabelText('Outcome')).toHaveTextContent('closed'); + }); + + for (const cleanupMs of [undefined, 400]) { + it(`closes from the factor card without going back to the confirm step (host cleans up after ${cleanupMs ?? 0}ms)`, async () => { + const action = vi.fn().mockResolvedValueOnce(reverificationError()).mockResolvedValue({ done: true }); + serveFapi(passwordVerification); + await renderWithClerk( + , + ); + const { user } = await openVerification(); + + expect(await screen.findByLabelText('Password')).toBeVisible(); + await untilConfirmStepIsGone(); + const stopWatching = watchForConfirmStep(); + await user.click(screen.getAllByRole('button', { name: 'Close' })[0]); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(stopWatching()).toEqual([]); + + expect(screen.getByLabelText('Outcome')).toHaveTextContent('closed'); + }); + } + + for (const settleMs of [undefined, 400]) { + it(`does not go back to the confirm step after a successful verification (host settles after ${settleMs ?? 0}ms)`, async () => { + const action = vi.fn().mockResolvedValueOnce(reverificationError()).mockResolvedValue({ done: true }); + serveFapi(passwordVerification); + await renderWithClerk( + , + ); + const { user } = await openVerification(); + + const field = await screen.findByLabelText('Password'); + await untilConfirmStepIsGone(); + const stopWatching = watchForConfirmStep(); + await user.type(field, 'hunter2{Enter}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull(), { timeout: 3000 }); + expect(stopWatching()).toEqual([]); + + expect(screen.getByLabelText('Outcome')).toHaveTextContent('closed'); + }); + } + + it('does not show the previous attempt again when the host starts the next run after a delay', async () => { + const action = vi + .fn() + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValue({ done: true }); + serveFapi(passwordVerification); + await renderWithClerk( + , + ); + const { user } = await openVerification(); + expect(await screen.findByLabelText('Password')).toBeVisible(); + await user.click(screen.getAllByRole('button', { name: 'Close' })[0]); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.type(await screen.findByRole('textbox'), 'Delete'); + const stopWatching = watchForConfirmStep(); + await user.click(screen.getByRole('button', { name: 'Delete account' })); + await new Promise(resolve => setTimeout(resolve, 150)); + expect(screen.queryByLabelText('Password')).toBeNull(); + expect(await screen.findByLabelText('Password', undefined, { timeout: 3000 })).toBeVisible(); + expect(stopWatching()).toEqual([]); + }); +}); diff --git a/packages/mosaic/src/blocks/destructive/destructive.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.test.tsx index 797ca23ed6e..04688252a8a 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.test.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.test.tsx @@ -5,10 +5,15 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../components/button'; import { Menu } from '../../components/menu'; +import type { ReverificationController } from '../../features/reverification'; import { MosaicProvider } from '../../MosaicProvider'; import type { DestructiveControlledProps, DestructiveHandleProps } from './destructive'; import { Destructive } from './destructive'; +vi.mock('../../features/reverification', () => ({ + Reverification: ({ status }: { status: string }) => {status}, +})); + function renderBlock(overrides: Partial = {}) { return render( @@ -28,6 +33,33 @@ function renderBlock(overrides: Partial = {}) { } const confirmButton = () => screen.getByRole('button', { name: 'Delete account' }); +const startingReverification = { + status: 'loading' as const, + onCancel: vi.fn(), + visible: false, + reset: vi.fn(), +}; + +function readyReverification(status: 'ready' | 'retrying'): ReverificationController { + const view = { + step: 'password' as const, + value: '', + onValueChange: () => {}, + isPending: status === 'retrying', + onSubmit: () => {}, + onShowMethods: () => {}, + onShowHelp: () => {}, + onBack: () => {}, + onEmailSupport: () => {}, + onResend: () => {}, + canResend: false, + methods: [], + onSelectMethod: () => {}, + }; + return status === 'retrying' + ? { status, visible: true, reset: () => {}, ...view } + : { status, visible: true, reset: () => {}, ...view }; +} describe('Destructive', () => { it('renders nothing until the caller opens it', () => { @@ -143,6 +175,64 @@ describe('Destructive', () => { await user.click(confirmButton()); expect(onDelete).not.toHaveBeenCalled(); }); + + it('keeps the delete confirmation pending while verification is starting', () => { + renderBlock({ isDeleting: true, reverification: startingReverification }); + + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); + expect(screen.getByRole('textbox')).toBeDisabled(); + expect(confirmButton()).toHaveAttribute('aria-busy', 'true'); + expect(screen.queryByTestId('reverification')).not.toBeInTheDocument(); + }); + + it('shows reverification in the same dialog and card', () => { + renderBlock({ isDeleting: true, step: 'verify', reverification: readyReverification('ready') }); + + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); + expect(screen.getByTestId('reverification')).toHaveTextContent('ready'); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('keeps the retrying factor visible until the hook becomes inactive', () => { + const props = { + open: true, + onOpenChange: vi.fn(), + title: 'Delete account?', + description: 'All of your data will be permanently deleted.', + fieldLabel: 'Type “Delete account” below to continue', + confirmationValue: 'Delete account', + actionLabel: 'Delete account', + onDelete: vi.fn(), + }; + const { rerender } = render( + + + , + ); + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); + expect(screen.getByTestId('reverification')).toHaveTextContent('retrying'); + + rerender( + + {} }} + /> + , + ); + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByText('Delete failed.')).toBeInTheDocument(); + }); }); function renderWithHandle(onDelete: DestructiveHandleProps['onDelete']) { diff --git a/packages/mosaic/src/blocks/destructive/destructive.tsx b/packages/mosaic/src/blocks/destructive/destructive.tsx index 65e7958d1f9..4fd87db9da4 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.tsx @@ -3,10 +3,12 @@ import { useEffect, useId, useState } from 'react'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; -import type { DialogFocusTarget, DialogHandle, DialogTriggerProps } from '../../components/dialog'; +import type { DialogFocusTarget, DialogHandle, DialogRootProps, DialogTriggerProps } from '../../components/dialog'; import { Dialog } from '../../components/dialog'; import { Field } from '../../components/field'; +import { Flow } from '../../components/flow'; import { Input } from '../../components/input'; +import { Reverification, type ReverificationController } from '../../features/reverification'; import { type FromPayload, resolveFromPayload as resolve } from '../../utils/resolve-from-payload'; import { useConfirmationController } from '../confirmation/confirmation.controller'; @@ -15,7 +17,7 @@ export interface DestructiveControlledProps { /** Whether the dialog is open */ open: boolean; /** Callback when open state changes */ - onOpenChange: (open: boolean) => void; + onOpenChange: NonNullable; /** Element that opens the dialog */ trigger?: DialogTriggerProps['render']; /** Dialog heading */ @@ -31,14 +33,18 @@ export interface DestructiveControlledProps { /** Text of the cancel button (default: "Cancel") */ cancelLabel?: string; /** Callback when delete is confirmed, by button or by Enter */ - onDelete: () => void; + onDelete: () => Promise | void; /** Whether the delete action is in progress */ isDeleting?: boolean; /** Error message to display if the delete action fails */ errorMessage?: string; + reverification?: ReverificationController; + step?: 'confirm' | 'verify'; } -type DestructiveCardProps = Omit; +type DestructiveCardProps = Omit & { + onClose?: () => void; +}; function DestructiveCard({ open, @@ -52,6 +58,9 @@ function DestructiveCard({ onDelete, isDeleting = false, errorMessage, + reverification, + step = 'confirm', + onClose, }: DestructiveCardProps) { const formId = useId(); const [typedValue, setTypedValue] = useState(''); @@ -65,7 +74,6 @@ function DestructiveCard({ }, [open]); const isConfirmed = typedValue === confirmationValue; - // The action sits in the footer, outside the form, so `form={formId}` associates the two. // That is what makes Enter in the field submit. Both guards are re-checked here because // neither spelling stops a native submit: `focusableWhenDisabled` only marks the button @@ -73,10 +81,90 @@ function DestructiveCard({ const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (isConfirmed && !isDeleting) { - onDelete(); + // Errors are meant to be handled on the outside, so we swallow them here + onDelete()?.catch(() => {}); } }; + const confirmation = ( + <> + + {title} + {description} + + +
+ + {fieldLabel} + setTypedValue(event.target.value)} + /> + + {errorMessage} + + +
+
+ + + {cancelLabel} + + } + /> + + {actionLabel} + + + + ); + + const content = reverification ? ( + + {() => ( + <> + {confirmation} + + {reverification ? ( + + ) : null} + + + )} + + ) : ( + confirmation + ); + return ( - - {title} - {description} - - -
- - {fieldLabel} - setTypedValue(event.target.value)} - /> - - {errorMessage} - - -
-
- - - {cancelLabel} - - } - /> - - {actionLabel} - - + {content}
); @@ -148,6 +189,7 @@ function ControlledDestructive({ open, onOpenChange, trigger, ...props }: Destru {trigger ? : null} onOpenChange(false, { trigger: null, triggerId: null, event: undefined })} {...props} /> diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx deleted file mode 100644 index 0265eb693c6..00000000000 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx +++ /dev/null @@ -1,715 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { createActor } from '../../../machine/createActor'; -import { deferred, tick } from '../../../machines/__tests__/test-utils'; -import { - type ReverificationDeps, - reverificationMachine, - useReverificationController, -} from '../reverification.controller'; -import type { ReverificationModel, ReverificationReadyModel } from '../reverification.model'; -import type { ReverificationMethod, ReverificationResult } from '../reverification.types'; - -const password: ReverificationMethod = { id: 'password', stage: 'first', strategy: 'password' }; -const email: ReverificationMethod = { - id: 'email_code:idn_1', - stage: 'first', - strategy: 'email_code', - identifier: 'a***@ex.com', - emailAddressId: 'idn_1', -}; -const totp: ReverificationMethod = { id: 'totp', stage: 'second', strategy: 'totp' }; - -function firstFactorResult(overrides: Partial = {}): ReverificationResult { - return { - status: 'needs_first_factor', - methods: [password, email], - startingMethod: password, - ...overrides, - }; -} - -function seatedDeps(overrides: Partial = {}): ReverificationDeps { - return { - start: vi.fn(async () => firstFactorResult()), - prepare: vi.fn(async () => {}), - attempt: vi.fn(async () => firstFactorResult({ status: 'complete' })), - finish: vi.fn(async () => {}), - cancel: vi.fn(), - ...overrides, - }; -} - -function startActor(deps: ReverificationDeps = seatedDeps()) { - const actor = createActor(reverificationMachine, { context: { deps } }).start(); - actor.send({ type: 'START' }); - return actor; -} - -function readyModel(overrides: Partial = {}): ReverificationReadyModel { - return { - status: 'ready', - isActive: true, - supportEmail: 'support@example.com', - start: vi.fn(async () => firstFactorResult()), - prepare: vi.fn(async () => {}), - attempt: vi.fn(async () => firstFactorResult({ status: 'complete' })), - finish: vi.fn(async () => {}), - cancel: vi.fn(), - ...overrides, - }; -} - -describe('reverificationMachine', () => { - it('starts verification and lands on the starting method', async () => { - const actor = startActor(); - expect(actor.getSnapshot().value).toBe('starting'); - await tick(); - expect(actor.getSnapshot().value).toBe('verifying'); - expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('password'); - }); - - it('returns to verifying with the error when an attempt fails', async () => { - const actor = startActor( - seatedDeps({ attempt: vi.fn(async () => Promise.reject(new Error('That password is incorrect.'))) }), - ); - await tick(); - actor.send({ type: 'TYPE', value: 'bad' }); - actor.send({ type: 'SUBMIT' }); - await tick(); - expect(actor.getSnapshot().value).toBe('verifying'); - expect(actor.getSnapshot().context.errorMessage).toBe('That password is incorrect.'); - }); - - it('prepares an email code once when that method is selected', async () => { - const prepare = vi.fn(async () => {}); - const actor = startActor(seatedDeps({ prepare })); - await tick(); - actor.send({ type: 'SHOW_METHODS' }); - expect(actor.getSnapshot().value).toBe('methodPicker'); - actor.send({ type: 'SELECT_METHOD', id: email.id }); - expect(actor.getSnapshot().value).toBe('methodPickerPreparing'); - await tick(); - expect(prepare).toHaveBeenCalledOnce(); - expect(actor.getSnapshot().value).toBe('verifying'); - expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('email_code'); - expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); - - actor.send({ type: 'SHOW_METHODS' }); - actor.send({ type: 'BACK' }); - expect(actor.getSnapshot().value).toBe('verifying'); - expect(prepare).toHaveBeenCalledOnce(); - }); - - it('routes to second factor after a successful first-factor attempt', async () => { - const actor = startActor( - seatedDeps({ - attempt: vi.fn(async () => - firstFactorResult({ - status: 'needs_second_factor', - methods: [totp], - startingMethod: totp, - }), - ), - }), - ); - await tick(); - actor.send({ type: 'TYPE', value: 'secret' }); - actor.send({ type: 'SUBMIT' }); - await tick(); - expect(actor.getSnapshot().value).toBe('verifying'); - expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('totp'); - }); - - it('opens help from the method picker and returns to it', async () => { - const actor = startActor(); - await tick(); - actor.send({ type: 'SHOW_METHODS' }); - actor.send({ type: 'SHOW_HELP' }); - expect(actor.getSnapshot().value).toBe('help'); - actor.send({ type: 'BACK' }); - expect(actor.getSnapshot().value).toBe('methodPicker'); - }); - - it('does not leave submitting when reset until the attempt settles', async () => { - const attempt = deferred(); - const cancel = vi.fn(); - const actor = startActor(seatedDeps({ attempt: () => attempt.promise, cancel })); - await tick(); - actor.send({ type: 'TYPE', value: 'secret' }); - actor.send({ type: 'SUBMIT' }); - expect(actor.getSnapshot().value).toBe('submitting'); - - actor.send({ type: 'RESET' }); - expect(actor.getSnapshot().value).toBe('submitting'); - expect(cancel).not.toHaveBeenCalled(); - - attempt.reject(new Error('cancelled')); - await tick(); - expect(actor.getSnapshot().value).toBe('done'); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it('cancels a successful attempt that settled after reset', async () => { - const attempt = deferred(); - const finish = vi.fn(async () => {}); - const cancel = vi.fn(); - const actor = startActor(seatedDeps({ attempt: () => attempt.promise, finish, cancel })); - await tick(); - actor.send({ type: 'TYPE', value: 'secret' }); - actor.send({ type: 'SUBMIT' }); - actor.send({ type: 'RESET' }); - - attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - await tick(); - expect(actor.getSnapshot().value).toBe('done'); - expect(cancel).toHaveBeenCalledOnce(); - expect(finish).not.toHaveBeenCalled(); - }); - - it('finishes on success without replacing the active method', async () => { - const finish = deferred(); - const actor = startActor( - seatedDeps({ - attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - finish: () => finish.promise, - }), - ); - await tick(); - actor.send({ type: 'TYPE', value: 'secret' }); - actor.send({ type: 'SUBMIT' }); - await tick(); - expect(actor.getSnapshot().value).toBe('completing'); - expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('password'); - finish.resolve(); - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('done')); - }); - - it('returns to the current method with the error when finish fails', async () => { - const actor = startActor( - seatedDeps({ - attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - finish: vi.fn(async () => Promise.reject(new Error('Session could not be activated.'))), - }), - ); - await tick(); - actor.send({ type: 'TYPE', value: 'secret' }); - actor.send({ type: 'SUBMIT' }); - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifying')); - expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('password'); - expect(actor.getSnapshot().context.errorMessage).toBe('Session could not be activated.'); - expect(actor.getSnapshot().context.deps.cancel).not.toHaveBeenCalled(); - }); - - it('prepares the starting email method without leaving the factor', async () => { - const prepare = deferred(); - const actor = startActor( - seatedDeps({ - start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), - prepare: () => prepare.promise, - }), - ); - await tick(); - expect(actor.getSnapshot().value).toBe('preparing'); - expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); - - actor.send({ type: 'TYPE', value: '123456' }); - expect(actor.getSnapshot().context.inputValue).toBe('123456'); - - prepare.resolve(); - await tick(); - expect(actor.getSnapshot().value).toBe('verifying'); - expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); - }); - - it('queues an attempt submitted while the starting prepare is in flight', async () => { - const prepare = deferred(); - const finish = deferred(); - const attempt = vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - const actor = startActor( - seatedDeps({ - start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), - prepare: () => prepare.promise, - attempt, - finish: () => finish.promise, - }), - ); - await tick(); - actor.send({ type: 'TYPE', value: '123456' }); - actor.send({ type: 'SUBMIT' }); - expect(actor.getSnapshot().value).toBe('preparing'); - expect(attempt).not.toHaveBeenCalled(); - - prepare.resolve(); - await tick(); - expect(attempt).toHaveBeenCalledOnce(); - expect(actor.getSnapshot().value).toBe('completing'); - }); - - it('keeps the factor interactive and queues submit while resend prepares', async () => { - const now = vi.spyOn(Date, 'now').mockReturnValue(1_000); - const resend = deferred(); - let prepareCount = 0; - const prepare = vi.fn(() => { - prepareCount += 1; - return prepareCount === 1 ? Promise.resolve() : resend.promise; - }); - const attempt = vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - - try { - const actor = startActor( - seatedDeps({ - start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), - prepare, - attempt, - }), - ); - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifying')); - - actor.send({ type: 'SHOW_HELP' }); - actor.send({ type: 'BACK' }); - now.mockReturnValue(31_000); - actor.send({ type: 'RESEND' }); - - expect(actor.getSnapshot().value).toBe('preparing'); - expect(actor.getSnapshot().context.resendAvailableAt).toBe(61_000); - - actor.send({ type: 'TYPE', value: '123456' }); - actor.send({ type: 'SUBMIT' }); - expect(actor.getSnapshot().context.inputValue).toBe('123456'); - expect(attempt).not.toHaveBeenCalled(); - - resend.resolve(); - await vi.waitFor(() => expect(attempt).toHaveBeenCalledWith(email, '123456')); - } finally { - now.mockRestore(); - } - }); -}); - -describe('useReverificationController', () => { - it('is idle when reverification is not active', () => { - const { result } = renderHook(() => useReverificationController(readyModel({ isActive: false }))); - expect(result.current.status).toBe('idle'); - }); - - it('is loading while the model is still waiting on Clerk', () => { - const loading: ReverificationModel = { - status: 'loading', - isActive: true, - }; - const { result } = renderHook(() => useReverificationController(loading)); - expect(result.current.status).toBe('loading'); - }); - - it('is unavailable when start fails', async () => { - const cancel = vi.fn(); - const { result } = renderHook(() => - useReverificationController( - readyModel({ start: vi.fn(async () => Promise.reject(new Error('no session'))), cancel }), - ), - ); - await waitFor(() => expect(result.current.status).toBe('unavailable')); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it('is unavailable when start returns no methods', async () => { - const cancel = vi.fn(); - const { result } = renderHook(() => - useReverificationController( - readyModel({ - start: vi.fn(async () => firstFactorResult({ methods: [], startingMethod: null })), - cancel, - }), - ), - ); - await waitFor(() => expect(result.current.status).toBe('unavailable')); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it('still passes onShowMethods when only one method is available', async () => { - const { result } = renderHook(() => - useReverificationController( - readyModel({ - start: vi.fn(async () => firstFactorResult({ methods: [password], startingMethod: password })), - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - if (result.current.status !== 'ready') { - throw new Error('expected ready'); - } - expect(result.current.onShowMethods).toEqual(expect.any(Function)); - expect(result.current.methods).toEqual([]); - expect(result.current.step).toBe('password'); - }); - - it('keeps onResend during the resend cooldown', async () => { - const { result } = renderHook(() => - useReverificationController( - readyModel({ - start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - if (result.current.status !== 'ready') { - throw new Error('expected ready'); - } - expect(result.current.canResend).toBe(false); - expect(result.current.onResend).toEqual(expect.any(Function)); - expect(result.current.resendRemainingSeconds).toBeGreaterThan(0); - expect(result.current.resendRemainingSeconds).toBeLessThanOrEqual(30); - }); - - it('marks the current step pending while an attempt is in flight', async () => { - const attempt = deferred(); - const { result } = renderHook(() => useReverificationController(readyModel({ attempt: () => attempt.promise }))); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(true); - } - }); - - act(() => { - attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - }); - }); - - it('resets once while an attempt is in flight', async () => { - const attempt = deferred(); - const finish = vi.fn(() => Promise.resolve()); - const cancel = vi.fn(); - const activeModel = readyModel({ attempt: () => attempt.promise, finish, cancel }); - const { result, rerender } = renderHook( - ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { initialProps: { model: activeModel } }, - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(true); - } - }); - - rerender({ model: { ...activeModel, isActive: false } }); - expect(result.current.status).toBe('idle'); - - act(() => { - attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - }); - await waitFor(() => expect(cancel).toHaveBeenCalledOnce()); - expect(finish).not.toHaveBeenCalled(); - }); - - it('stays on the current step pending while finish runs', async () => { - const finish = deferred(); - const { result } = renderHook(() => - useReverificationController( - readyModel({ - attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - finish: () => finish.promise, - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('password'); - expect(result.current.isPending).toBe(true); - } - }); - - act(() => { - finish.resolve(); - }); - await waitFor(() => expect(result.current.status).toBe('loading')); - }); - - it('stays on the current step with the error when finish fails', async () => { - const { result } = renderHook(() => - useReverificationController( - readyModel({ - attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - finish: vi.fn(async () => Promise.reject(new Error('Session could not be activated.'))), - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('password'); - expect(result.current.isPending).toBe(false); - expect(result.current.errorMessage).toBe('Session could not be activated.'); - } - }); - }); - - it('keeps the current step when the model flickers to loading', async () => { - const start = vi.fn(async () => firstFactorResult()); - const { result, rerender } = renderHook( - ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { initialProps: { model: readyModel({ start }) } }, - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - expect(start).toHaveBeenCalledOnce(); - - rerender({ model: { status: 'loading', isActive: true } }); - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('password'); - } - expect(start).toHaveBeenCalledOnce(); - }); - - it('keeps finish seated when the model flickers to loading', async () => { - const finish = deferred(); - const finishFn = vi.fn(() => finish.promise); - const { result, rerender } = renderHook( - ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { - initialProps: { - model: readyModel({ - attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - finish: finishFn, - }), - }, - }, - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(true); - } - }); - - rerender({ model: { status: 'loading', isActive: true } }); - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('password'); - expect(result.current.isPending).toBe(true); - } - - act(() => { - finish.resolve(); - }); - await waitFor(() => expect(result.current.status).toBe('loading')); - expect(finishFn).toHaveBeenCalledOnce(); - }); - - it('starts again only when the handshake ends and reopens', async () => { - const start = vi.fn(async () => firstFactorResult()); - const { result, rerender } = renderHook( - ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { initialProps: { model: readyModel({ start }) } }, - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - rerender({ model: readyModel({ start, isActive: false }) }); - expect(result.current.status).toBe('idle'); - - rerender({ model: readyModel({ start, isActive: true }) }); - await waitFor(() => expect(result.current.status).toBe('ready')); - expect(start).toHaveBeenCalledTimes(2); - }); - - it('shows the factor idle while the starting prepare is in flight', async () => { - const prepare = deferred(); - const { result } = renderHook(() => - useReverificationController( - readyModel({ - start: vi.fn(async () => firstFactorResult({ methods: [email, password], startingMethod: email })), - prepare: () => prepare.promise, - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - if (result.current.status !== 'ready') { - throw new Error('expected ready'); - } - expect(result.current.step).toBe('otp'); - expect(result.current.isPending).toBe(false); - expect(result.current.canResend).toBe(false); - expect(result.current.pendingMethodId).toBeUndefined(); - - const onValueChange = result.current.onValueChange; - act(() => { - onValueChange('123456'); - }); - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.value).toBe('123456'); - } - - act(() => { - prepare.resolve(); - }); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.canResend).toBe(false); - expect(result.current.isPending).toBe(false); - } - }); - }); - - it('does not attempt until the starting prepare settles after submit', async () => { - const prepare = deferred(); - const attempt = vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - const { result } = renderHook(() => - useReverificationController( - readyModel({ - start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), - prepare: () => prepare.promise, - attempt, - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('123456'); - result.current.onSubmit(); - } - }); - expect(attempt).not.toHaveBeenCalled(); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(false); - } - - act(() => { - prepare.resolve(); - }); - await waitFor(() => expect(attempt).toHaveBeenCalledOnce()); - }); - - it('keeps the picker on the sending row until prepare settles', async () => { - const prepare = deferred(); - const { result } = renderHook(() => - useReverificationController( - readyModel({ - prepare: () => prepare.promise, - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onShowMethods(); - } - }); - if (result.current.status !== 'ready') { - throw new Error('expected ready'); - } - expect(result.current.step).toBe('method-picker'); - - const onSelectMethod = result.current.onSelectMethod; - act(() => { - onSelectMethod(email.id); - }); - if (result.current.status !== 'ready') { - throw new Error('expected ready'); - } - expect(result.current.step).toBe('method-picker'); - expect(result.current.pendingMethodId).toBe(email.id); - expect(result.current.isPending).toBe(false); - expect(result.current.methods.some(method => method.id === email.id)).toBe(true); - expect(result.current.onBack).toEqual(expect.any(Function)); - - act(() => { - prepare.resolve(); - }); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('otp'); - expect(result.current.pendingMethodId).toBeUndefined(); - expect(result.current.canResend).toBe(false); - } - }); - }); - - it('shows the factor with the error when a picker prepare fails', async () => { - const prepare = deferred(); - const { result } = renderHook(() => - useReverificationController( - readyModel({ - prepare: () => prepare.promise, - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onShowMethods(); - result.current.onSelectMethod(email.id); - } - }); - - act(() => { - prepare.reject(new Error('Could not send the code.')); - }); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('otp'); - expect(result.current.errorMessage).toBe('Could not send the code.'); - expect(result.current.pendingMethodId).toBeUndefined(); - expect(result.current.canResend).toBe(true); - } - }); - }); -}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx new file mode 100644 index 00000000000..bb0f200a164 --- /dev/null +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -0,0 +1,1468 @@ +import { reverificationError } from '@clerk/shared/authorization-errors'; +import { ClerkWebAuthnError } from '@clerk/shared/error'; +import type { + PublicKeyCredentialWithAuthenticatorAssertionResponse, + ReverificationConfig, + SignInFirstFactorJSON, + SignInSecondFactorJSON, +} from '@clerk/shared/types'; +import { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { useRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; +import type { FakeVerificationSeed } from '../../../__tests__/feature/fake-fapi/verification'; +import { fapiClient, fapiEmailAddress, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { Card } from '../../../components/card'; +import type { MosaicLocalization } from '../../../localization'; +import { MosaicProvider } from '../../../MosaicProvider'; +import { Reverification, useReverificationFlow } from '../reverification'; + +const alice = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })], +}); + +const aliceSession = fapiSession({ id: 'sess_1', user: alice }); + +const VERIFY = '/v1/client/sessions/sess_1/verify'; +const PREPARE_FIRST = `${VERIFY}/prepare_first_factor`; +const ATTEMPT_FIRST = `${VERIFY}/attempt_first_factor`; +const TOUCH = '/v1/client/sessions/sess_1/touch'; + +const PASSWORD = 'hunter2'; +const CODE = '123456'; +const WRONG_CODE = '000000'; +const BACKUP_CODE = 'backup-1'; + +const SECRETS = { password: PASSWORD, email_code: CODE, phone_code: CODE, totp: CODE, backup_code: BACKUP_CODE }; + +const passwordFactor: SignInFirstFactorJSON = { strategy: 'password' }; +const emailFactor: SignInFirstFactorJSON = { + strategy: 'email_code', + email_address_id: 'idn_alice', + safe_identifier: 'alice@example.com', +}; +const workEmailFactor: SignInFirstFactorJSON = { + strategy: 'email_code', + email_address_id: 'idn_work', + safe_identifier: 'alice@work.example', +}; +const phoneFactor: SignInFirstFactorJSON = { + strategy: 'phone_code', + phone_number_id: 'idn_phone', + safe_identifier: '+1 555 0100', +}; +const passkeyFactor: SignInFirstFactorJSON = { strategy: 'passkey' }; +const enterpriseSsoFactor = { + strategy: 'enterprise_sso', + enterprise_connection_id: 'ec_1', + enterprise_connection_name: 'Acme SSO', +} as unknown as SignInFirstFactorJSON; + +const totpFactor: SignInSecondFactorJSON = { strategy: 'totp' }; +const backupCodeFactor: SignInSecondFactorJSON = { strategy: 'backup_code' }; +const secondPhoneFactor: SignInSecondFactorJSON = { + strategy: 'phone_code', + phone_number_id: 'idn_phone', + safe_identifier: '+1 555 0100', +}; + +const TITLE = 'Verification required'; +const NO_FACTORS_TITLE = 'Cannot verify your account'; +const GENERIC_ERROR_TITLE = 'Something went wrong'; +const STEP = { + password: 'Enter your current password to continue', + emailCode: 'Enter the code sent to your email to continue', + phoneCode: 'Enter the code sent to your phone to continue', + totp: 'Enter the code generated by your authenticator app to continue', + backupCode: 'Enter the backup code you received when setting up two-step authentication', + passkey: /Using your passkey confirms your identity/, +}; + +function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed { + return { client: fapiClient([aliceSession]), ...overrides }; +} + +function verificationSeed(verification: FakeVerificationSeed, overrides: FakeFapiSeed = {}): FakeFapiSeed { + return signedIn({ verification: { secrets: SECRETS, ...verification }, ...overrides }); +} + +function preferring(strategy: 'password' | 'otp') { + return fapiEnvironment({ display_config: { preferred_sign_in_strategy: strategy } }); +} + +function guardedAction(config?: ReverificationConfig) { + return vi.fn().mockResolvedValueOnce(reverificationError(config)).mockResolvedValue({ done: true }); +} + +function errorCode(error: unknown) { + if (error instanceof Error) { + return 'code' in error && typeof error.code === 'string' ? error.code : error.message; + } + return 'unknown'; +} + +function Host({ action }: { action: () => Promise }) { + const [run, reverification] = useReverificationFlow(action); + const [outcome, setOutcome] = useState(''); + const [running, setRunning] = useState(false); + const inFlight = useRef(false); + + return ( + <> + + {outcome} + {running ? ( + + + {reverification.onCancel ? ( + + ) : null} + + ) : null} + + ); +} + +function HoldingHost({ action }: { action: () => Promise }) { + const [run, reverification] = useReverificationFlow(action); + const [outcome, setOutcome] = useState(''); + + return ( + <> + + + {outcome} + {String(reverification.visible)} + + + {reverification.onCancel ? ( + + ) : null} + + + ); +} + +function renderReverification( + action = guardedAction(), + seed: FakeFapiSeed = signedIn(), + localization?: MosaicLocalization, +) { + const fapi = serveFapi(seed); + const host = ; + return renderWithClerk( + localization ? {host} : host, + ).then(view => ({ ...view, fapi, action })); +} + +type User = ReturnType; + +async function startVerification() { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Run action' })); + return user; +} + +const outcome = () => screen.getByLabelText('Outcome').textContent; +const dismiss = (user: User) => user.click(screen.getByRole('button', { name: 'Dismiss' })); +const passwordField = () => screen.getByLabelText('Password'); +const slots = () => + within(screen.getByRole('group', { name: 'Verification code' })).getAllByRole('textbox'); +const slotValues = () => slots().map(slot => slot.value); +const verifying = () => screen.getByRole('progressbar', { name: 'Verifying' }); +const useAnotherMethod = (user: User) => user.click(screen.getByRole('button', { name: 'Use another method' })); +const method = (name: RegExp | string) => screen.getByRole('button', { name }); + +function pretendToBeARealBrowser() { + Object.defineProperty(navigator, 'webdriver', { value: false, configurable: true }); + return () => { + Reflect.deleteProperty(navigator, 'webdriver'); + }; +} + +function pretendWebAuthnIsUnsupported() { + const restoreBrowser = pretendToBeARealBrowser(); + const original = Object.getOwnPropertyDescriptor(window, 'PublicKeyCredential'); + Object.defineProperty(window, 'PublicKeyCredential', { value: undefined, configurable: true, writable: true }); + return () => { + restoreBrowser(); + if (original) { + Object.defineProperty(window, 'PublicKeyCredential', original); + } + }; +} + +function fakeAssertion() { + const buffer = () => new Uint8Array([1, 2, 3]).buffer; + return { + type: 'public-key', + id: 'cred_1', + rawId: buffer(), + authenticatorAttachment: 'platform', + response: { clientDataJSON: buffer(), authenticatorData: buffer(), signature: buffer(), userHandle: null }, + } as unknown as PublicKeyCredentialWithAuthenticatorAssertionResponse; +} + +function captureNavigations() { + const navigation = (window as unknown as { navigation: EventTarget }).navigation; + const urls: string[] = []; + const onNavigate = (event: Event) => { + urls.push((event as Event & { destination: { url: string } }).destination.url); + event.preventDefault(); + }; + navigation.addEventListener('navigate', onNavigate); + return { urls, stop: () => navigation.removeEventListener('navigate', onNavigate) }; +} + +const settle = () => new Promise(resolve => setTimeout(resolve, 100)); + +async function pasteCode(user: User, code: string) { + await user.click(slots()[0]); + await user.paste(code); +} + +async function untilResolved() { + await waitFor(() => expect(outcome()).toBe('resolved')); +} + +async function untilClosed() { + await waitFor(() => expect(screen.queryByText(TITLE)).toBeNull()); +} + +describe('Reverification', () => { + describe('starting', () => { + it('passes through when no reverification is needed', async () => { + const action = vi.fn().mockResolvedValue({ done: true }); + await renderReverification(action); + expect(screen.queryByText(TITLE)).toBeNull(); + + await startVerification(); + + await untilResolved(); + expect(action).toHaveBeenCalledTimes(1); + expect(screen.queryByText(TITLE)).toBeNull(); + }); + + it('opens on the first factor when the hint asks for the first factor level', async () => { + const { action } = await renderReverification( + guardedAction({ level: 'first_factor', afterMinutes: 10 }), + verificationSeed({ firstFactors: [passwordFactor], secondFactors: [totpFactor] }), + ); + + const user = await startVerification(); + + expect(await screen.findByText(STEP.password)).toBeVisible(); + await user.type(passwordField(), `${PASSWORD}{Enter}`); + await untilResolved(); + expect(action).toHaveBeenCalledTimes(2); + }); + + it('opens on the second factor when the hint asks for the second factor level', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ firstFactors: [passwordFactor], secondFactors: [totpFactor] }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.totp)).toBeVisible(); + expect(screen.queryByLabelText('Password')).toBeNull(); + }); + + it('starts verification at the second factor level when the hint carries no level', async () => { + const levelessHint = { clerk_error: { type: 'forbidden', reason: 'reverification-error' } }; + const action = vi.fn().mockResolvedValueOnce(levelessHint).mockResolvedValue({ done: true }); + await renderReverification( + action, + verificationSeed({ firstFactors: [passwordFactor], secondFactors: [totpFactor] }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.totp)).toBeVisible(); + }); + + it('lets the user dismiss the pending card while verification is starting', async () => { + await renderReverification(guardedAction(), verificationSeed({})); + const start = holdRequests('post', VERIFY); + + const user = await startVerification(); + + await waitFor(() => expect(start.requests).toHaveLength(1)); + expect(screen.getByText(TITLE)).toBeVisible(); + expect(screen.queryByLabelText('Password')).toBeNull(); + + await dismiss(user); + + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + start.release(); + }); + + // This can't use renderWithClerk since that awaits Clerk.load() + it.todo('shows the pending card while Clerk is still loading', () => { + // A pending card is shown while the session or environment is missing + // The first step opens once Clerk is ready + }); + + it('keeps showing a generic error card until it is dismissed when verification cannot be started', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + worker.use( + http.post(fapiUrl(VERIFY), () => + HttpResponse.json({ errors: [{ code: 'some_new_error', message: 'Nope' }] }, { status: 400 }), + ), + ); + + const user = await startVerification(); + + // The error card stays on screen without the server's message + expect(await screen.findByText(GENERIC_ERROR_TITLE)).toBeVisible(); + expect(screen.getByText('Please try again.')).toBeVisible(); + expect(screen.queryByText(NO_FACTORS_TITLE)).toBeNull(); + expect(screen.queryByText('Nope')).toBeNull(); + await settle(); + expect(screen.getByText(GENERIC_ERROR_TITLE)).toBeVisible(); + expect(outcome()).toBe(''); + + // Closing it rejects the action as cancelled + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + expect(screen.queryByText(GENERIC_ERROR_TITLE)).toBeNull(); + }); + + it('keeps showing the no factors card until it is dismissed when none of the factors are supported', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [enterpriseSsoFactor] })); + + const user = await startVerification(); + + expect(await screen.findByText(NO_FACTORS_TITLE)).toBeVisible(); + expect(screen.getByText(/No suitable authentication factor is configured/)).toBeVisible(); + expect(screen.queryByText(GENERIC_ERROR_TITLE)).toBeNull(); + await settle(); + expect(screen.getByText(NO_FACTORS_TITLE)).toBeVisible(); + expect(outcome()).toBe(''); + + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + expect(screen.queryByText(NO_FACTORS_TITLE)).toBeNull(); + }); + }); + + describe('choosing the starting method', () => { + it('starts with a passkey when the browser supports WebAuthn and the user has one', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] }, { environment: preferring('password') }), + ); + const restore = pretendToBeARealBrowser(); + + try { + await startVerification(); + + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + expect(screen.queryByLabelText('Password')).toBeNull(); + } finally { + restore(); + } + }); + + it('starts with the password when the instance prefers password sign in', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [emailFactor, passwordFactor] }, { environment: preferring('password') }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.password)).toBeVisible(); + }); + + it('starts with a code method when the instance prefers code sign in', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, emailFactor] }, { environment: preferring('otp') }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + }); + + it('starts the second factor on the authenticator app when the user has one', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [backupCodeFactor, secondPhoneFactor, totpFactor] }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.totp)).toBeVisible(); + }); + + it('starts the second factor on a phone code when there is no authenticator app', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [backupCodeFactor, secondPhoneFactor] }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); + }); + + it('starts the second factor on the first remaining method when there is neither', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [backupCodeFactor] }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.backupCode)).toBeVisible(); + }); + + // This is a future behavior we will need to account for, here's the PR that builds it for the existing reverification: https://github.com/clerk/javascript/pull/9127 + it.todo('offer a passkey listed as a second factor', () => { + // The passkey is not offered on the second factor step + }); + }); + + describe('password', () => { + // Currently wrong: the error shown is the server's English text, not a localized message. See the errors group. + it('recovers from a wrong password and completes the challenge', async () => { + const { action } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor] }), + ); + expect(screen.queryByText(TITLE)).toBeNull(); + + // The password step opens when the action needs reverification + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + + // A wrong password shows the server message and stays on the step + // Pressing Enter submits the password + await user.type(passwordField(), 'wrong{Enter}'); + expect(await screen.findByText(/Password is incorrect/)).toBeInTheDocument(); + + // The typed value is kept after the error + expect(passwordField()).toHaveValue('wrong'); + + // Typing again clears the error + await user.clear(passwordField()); + await user.type(passwordField(), PASSWORD); + expect(screen.queryByText(/Password is incorrect/)).toBeNull(); + + // The form is disabled and shows the verifying label while the attempt is in flight + const attempt = holdRequests('post', ATTEMPT_FIRST); + const touch = holdRequests('post', TOUCH); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + expect(passwordField()).toBeDisabled(); + expect(verifying()).toBeInTheDocument(); + attempt.release(); + + // The last step stays visible and pending while the session is being activated + await waitFor(() => expect(touch.requests).toHaveLength(1)); + expect(passwordField()).toBeDisabled(); + expect(verifying()).toBeInTheDocument(); + expect(action).toHaveBeenCalledTimes(1); + touch.release(); + + // The action is retried once the session is activated and resolves with its result + await untilResolved(); + expect(action).toHaveBeenCalledTimes(2); + + // The card closes once the retried action has settled + await untilClosed(); + }); + }); + + describe('email code', () => { + // Currently wrong: the error shown is the server's English text, not a localized message. See the errors group. + it('sends a code and completes the challenge with the correct code', async () => { + const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); + const prepare = holdRequests('post', PREPARE_FIRST); + + // A code is sent once when the step opens + const user = await startVerification(); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + + // The field is usable while the code is being sent + await user.click(slots()[0]); + await user.keyboard(WRONG_CODE.slice(0, 3)); + expect(slotValues()).toEqual(['0', '0', '0', '', '', '']); + prepare.release(); + await waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('email_code')); + expect(prepare.requests).toHaveLength(1); + + // A wrong code submits automatically on the last digit and shows the server error + // The field is disabled and shows the verifying label while the attempt is in flight + const attempt = holdRequests('post', ATTEMPT_FIRST); + await user.keyboard(WRONG_CODE.slice(3)); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + expect(slots().every(slot => slot.disabled)).toBe(true); + expect(verifying()).toBeInTheDocument(); + attempt.release(); + expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); + await waitFor(() => expect(slots().every(slot => !slot.disabled)).toBe(true)); + + // The correct code completes the challenge + await user.click(slots()[5]); + await user.keyboard('{Backspace>6/}'); + expect(slotValues()).toEqual(['', '', '', '', '', '']); + await pasteCode(user, CODE); + await untilResolved(); + }); + + it('resubmits the typed code with the Continue button', async () => { + const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); + const user = await startVerification(); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + await waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('email_code')); + await pasteCode(user, WRONG_CODE); + expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); + await waitFor(() => expect(slots().every(slot => !slot.disabled)).toBe(true)); + + const attempt = holdRequests('post', ATTEMPT_FIRST); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + expect(verifying()).toBeInTheDocument(); + attempt.release(); + expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); + }); + + it('holds a submit made while a code is being sent', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); + const prepare = holdRequests('post', PREPARE_FIRST); + const attempt = holdRequests('post', ATTEMPT_FIRST); + const user = await startVerification(); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + + // A submit made while the first code is being sent is held + await pasteCode(user, CODE); + expect(attempt.requests).toHaveLength(0); + + // It is sent once the code has been sent + prepare.release(); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + attempt.release(); + await untilResolved(); + }); + + it('discards a held submit when sending the code fails', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); + const prepare = holdRequests('post', PREPARE_FIRST); + const attempt = holdRequests('post', ATTEMPT_FIRST); + const user = await startVerification(); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + await pasteCode(user, CODE); + + prepare.fail(); + + // The error is shown on the step + expect(await screen.findByText('form_param_invalid')).toBeInTheDocument(); + expect(screen.getByText(STEP.emailCode)).toBeVisible(); + + // The held submit is not sent + expect(attempt.requests).toHaveLength(0); + attempt.release(); + + // The user can resend immediately + expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(); + }); + + it('resends a code once the cooldown has passed', async () => { + const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); + vi.useFakeTimers({ toFake: ['Date'] }); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + await waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('email_code')); + + // Resend is disabled and shows the countdown while the cooldown runs + const resend = await screen.findByRole('button', { name: /Resend \(\d+\)/ }); + expect(resend).toBeDisabled(); + + // Resend is enabled again after the cooldown + vi.setSystemTime(Date.now() + 30_000); + await waitFor(() => expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(), { timeout: 3000 }); + expect(screen.getByRole('button', { name: /Resend/ }).textContent).not.toMatch(/\(\d+\)/); + + // Resending sends a new code and clears the typed value + await user.click(slots()[0]); + await user.keyboard('123'); + expect(slotValues()).toEqual(['1', '2', '3', '', '', '']); + const prepare = holdRequests('post', PREPARE_FIRST); + await user.click(screen.getByRole('button', { name: /Resend/ })); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + expect(slotValues()).toEqual(['', '', '', '', '', '']); + expect(screen.getByRole('button', { name: /Resend \(\d+\)/ })).toBeDisabled(); + + // A submit made while a resend is in flight is held and sent once the resend has been sent + const attempt = holdRequests('post', ATTEMPT_FIRST); + await pasteCode(user, CODE); + await settle(); + expect(attempt.requests).toHaveLength(0); + prepare.release(); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + attempt.release(); + await untilResolved(); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('phone code', () => { + it('sends an SMS code for the first factor and completes the challenge', async () => { + const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [phoneFactor] })); + + const user = await startVerification(); + + // The SMS code is sent through the first factor endpoints when the step opens + expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); + await waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('phone_code')); + + // The correct code completes the challenge + await pasteCode(user, CODE); + await untilResolved(); + }); + }); + + describe('moving from the first to the second factor', () => { + it('completes a multi factor challenge', async () => { + const { fapi } = await renderReverification( + guardedAction({ level: 'multi_factor', afterMinutes: 10 }), + verificationSeed({ firstFactors: [passwordFactor], secondFactors: [secondPhoneFactor, backupCodeFactor] }), + ); + + // The first factor step opens first + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + await user.type(passwordField(), `${PASSWORD}{Enter}`); + + // The second factor step opens after the first factor is verified + expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); + await waitFor(() => expect(screen.queryByLabelText('Password')).toBeNull()); + + // The second factor starts with an empty field and no error + expect(slotValues()).toEqual(['', '', '', '', '', '']); + expect(screen.queryByText('Incorrect code')).toBeNull(); + + // The SMS code is sent through the second factor endpoints when the step opens + await waitFor(() => expect(fapi.verification.secondFactorVerification?.strategy).toBe('phone_code')); + + // The picker lists second factors only + await useAnotherMethod(user); + expect(await screen.findByRole('button', { name: 'Use a backup code' })).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Continue with your password' })).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Back' })); + + // The correct code completes the challenge + expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); + await pasteCode(user, CODE); + await untilResolved(); + }); + }); + + describe('second factor alternatives', () => { + // Currently wrong: the error shown is the server's English text, not a localized message. See the errors group. + it('switches from the authenticator app to a backup code', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [totpFactor, backupCodeFactor] }), + ); + + // The authenticator app step opens first + const user = await startVerification(); + expect(await screen.findByText(STEP.totp)).toBeVisible(); + + // The picker offers the backup code + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Use a backup code' })); + expect(await screen.findByText(STEP.backupCode)).toBeVisible(); + + // An invalid backup code shows the server error + await user.type(screen.getByLabelText('Backup code'), 'invalid{Enter}'); + expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); + + // A valid backup code completes the challenge + await user.clear(screen.getByLabelText('Backup code')); + await user.type(screen.getByLabelText('Backup code'), `${BACKUP_CODE}{Enter}`); + await untilResolved(); + }); + }); + + describe('passkey', () => { + // Currently wrong: the banner shows the browser or clerk-js error message as is, which is not localized. + it('completes the challenge with the passkey', async () => { + const { clerk } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] }), + ); + let acceptCredential: () => void = () => {}; + const getCredential = vi + .fn() + .mockResolvedValueOnce({ + publicKeyCredential: null, + error: new ClerkWebAuthnError('The operation was refused', { code: 'passkey_retrieval_failed' }), + }) + .mockImplementationOnce( + () => + new Promise(resolve => { + acceptCredential = () => resolve({ publicKeyCredential: fakeAssertion(), error: null }); + }), + ); + clerk.__internal_getPublicCredentials = getCredential; + const restore = pretendToBeARealBrowser(); + + try { + // The passkey step opens + const user = await startVerification(); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + + // A refused credential shows an error banner and stays on the step + await user.click(screen.getByRole('button', { name: 'Continue' })); + expect(await screen.findByRole('alert')).toHaveTextContent('The operation was refused'); + expect(screen.getByText(STEP.passkey)).toBeVisible(); + + // The button is disabled and shows the verifying label while the credential is being verified + await user.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(verifying()).toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Use another method' })).toBeDisabled(); + + // A second attempt with an accepted credential completes the challenge + await waitFor(() => expect(getCredential).toHaveBeenCalledTimes(2)); + acceptCredential(); + await untilResolved(); + } finally { + restore(); + } + }); + + it('offers the passkey from the picker when another method is showing', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] })); + const restore = pretendToBeARealBrowser(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Continue with your password' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + + // The passkey is listed once the user is on another method + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Use your passkey' })); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + } finally { + restore(); + } + }); + + it('does not offer a passkey when the browser does not support WebAuthn', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, passkeyFactor, emailFactor] }), + ); + const restore = pretendWebAuthnIsUnsupported(); + + try { + // The passkey is not the starting method + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + + // The passkey is not listed in the picker + await useAnotherMethod(user); + expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Use your passkey' })).toBeNull(); + } finally { + restore(); + } + }); + }); + + describe('switching methods', () => { + // Unsure about behavior: Back keeps the typed value while picking a method clears it. It is not clear whether keeping it on Back is intended. + it('walks through the picker and help steps', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, emailFactor, workEmailFactor, phoneFactor] }), + ); + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + + // The picker lists every method except the one currently shown + // Each email address and phone number is its own method + await useAnotherMethod(user); + expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); + expect(method('Email code to alice@work.example')).toBeVisible(); + expect(method('Send SMS code to +1 555 0100')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Continue with your password' })).toBeNull(); + + // Help opens from the picker and offers to email support + await user.click(screen.getByRole('button', { name: 'Get help' })); + expect(await screen.findByText(/If you have trouble verifying your account/)).toBeVisible(); + expect(screen.getByRole('button', { name: 'Email support' })).toBeVisible(); + + // Back from help returns to the picker + await user.click(screen.getByRole('button', { name: 'Back' })); + expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); + + // Back from the picker returns to the method the user came from + await user.click(screen.getByRole('button', { name: 'Back' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + + // Focus moves to the first field of the step the user switches to + await waitFor(() => expect(passwordField()).toHaveFocus()); + }); + + it('does not offer another method when the user has only one', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + + await startVerification(); + + expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Use another method' })).toBeNull(); + }); + + it('does not list factors Mosaic cannot verify', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, enterpriseSsoFactor, emailFactor] }), + ); + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + + await useAnotherMethod(user); + + expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); + expect(screen.queryByText(/SSO/i)).toBeNull(); + expect(screen.queryByText(/enterprise/i)).toBeNull(); + }); + + it('completes the challenge with a method picked from the picker', async () => { + const { fapi } = await renderReverification( + guardedAction(), + verificationSeed( + { firstFactors: [emailFactor, passwordFactor, phoneFactor] }, + { environment: preferring('otp') }, + ), + ); + const firstPrepare = holdRequests('post', PREPARE_FIRST); + const user = await startVerification(); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + await waitFor(() => expect(firstPrepare.requests).toHaveLength(1)); + firstPrepare.release(); + + // Picking the password shows the password step without sending anything + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Continue with your password' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(firstPrepare.requests).toHaveLength(1); + + // Picking an email code shows a pending row and disables the other actions while the code is sent + const secondPrepare = holdRequests('post', PREPARE_FIRST); + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Email code to alice@example.com' })); + await waitFor(() => expect(secondPrepare.requests).toHaveLength(1)); + expect(method('Send SMS code to +1 555 0100')).toBeDisabled(); + expect(method('Back')).toBeDisabled(); + secondPrepare.release(); + + // The code step opens with an empty field and no error once the code is sent + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + expect(slotValues()).toEqual(['', '', '', '', '', '']); + expect(screen.queryByText('Incorrect code')).toBeNull(); + + // Picking a phone code sends the SMS code through the first factor endpoints + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Send SMS code to +1 555 0100' })); + expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); + expect(fapi.verification.firstFactorVerification?.strategy).toBe('phone_code'); + + // The correct code completes the challenge + await pasteCode(user, CODE); + await untilResolved(); + }); + + it('lands on the picked method with an error when the code cannot be sent', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] })); + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + const prepare = holdRequests('post', PREPARE_FIRST); + + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Email code to alice@example.com' })); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + prepare.fail(); + + // The code step opens for the picked method + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + + // The error is shown on the step + expect(await screen.findByText('form_param_invalid')).toBeInTheDocument(); + }); + + it('emails the support address from the environment', async () => { + await renderReverification( + guardedAction(), + verificationSeed( + { firstFactors: [passwordFactor, emailFactor] }, + { environment: fapiEnvironment({ display_config: { support_email: 'help@acme.test' } }) }, + ), + ); + const navigations = captureNavigations(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Get help' })); + + // Email support opens a mail to the configured address + await user.click(await screen.findByRole('button', { name: 'Email support' })); + await waitFor(() => expect(navigations.urls).toEqual(['mailto:help@acme.test'])); + } finally { + navigations.stop(); + } + }); + + it('emails the default support address when the environment has none', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] })); + const navigations = captureNavigations(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Get help' })); + + // Email support opens a mail to support@ the Frontend API host + await user.click(await screen.findByRole('button', { name: 'Email support' })); + await waitFor(() => expect(navigations.urls).toHaveLength(1)); + expect(navigations.urls[0]).toMatch(/^mailto:support@/); + } finally { + navigations.stop(); + } + }); + }); + + describe('finishing', () => { + it('shows a generic error card when the session cannot be activated', async () => { + const { action } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor] }), + ); + const touch = holdRequests('post', TOUCH); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + + // The last step stays visible and pending while the session is being activated + await waitFor(() => expect(touch.requests).toHaveLength(1)); + expect(passwordField()).toBeDisabled(); + expect(verifying()).toBeInTheDocument(); + touch.fail(); + + // The step is replaced by a generic error card without the server's message + expect(await screen.findByText(GENERIC_ERROR_TITLE)).toBeVisible(); + expect(screen.getByText('Please try again.')).toBeVisible(); + expect(screen.queryByText('form_param_invalid')).toBeNull(); + expect(screen.queryByText(STEP.password)).toBeNull(); + + // The action is not retried + expect(action).toHaveBeenCalledTimes(1); + expect(outcome()).toBe(''); + + // Dismissing it rejects the action as cancelled + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + expect(screen.queryByText(GENERIC_ERROR_TITLE)).toBeNull(); + }); + + it('cannot be interrupted while the action is being retried', async () => { + let rejectRetry: (error: Error) => void = () => {}; + const retry = new Promise((_, reject) => { + rejectRetry = reject; + }); + const action = vi + .fn() + .mockResolvedValueOnce(reverificationError()) + .mockImplementationOnce(() => retry) + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValue({ done: true }); + await renderReverification(action, verificationSeed({ firstFactors: [passwordFactor] })); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + + // The last step stays visible and pending while the action is being retried + await waitFor(() => expect(action).toHaveBeenCalledTimes(2)); + expect(passwordField()).toBeDisabled(); + expect(verifying()).toBeInTheDocument(); + + // Dismissing is not available + expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull(); + + // A second call rejects with request_already_in_progress + await user.click(screen.getByRole('button', { name: 'Run action' })); + await waitFor(() => expect(outcome()).toBe('rejected: request_already_in_progress')); + expect(passwordField()).toBeDisabled(); + + // An error thrown by the retried action rejects the action and closes the card + rejectRetry(new Error('retry failed')); + await waitFor(() => expect(outcome()).toBe('rejected: retry failed')); + await untilClosed(); + + // A later call opens a new challenge from the first step with empty fields + await user.click(screen.getByRole('button', { name: 'Run action' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(passwordField()).toHaveValue(''); + expect(passwordField()).toBeEnabled(); + }); + }); + + describe('after the action has ended', () => { + const renderHolding = (action: () => Promise) => { + serveFapi(verificationSeed({ firstFactors: [passwordFactor] })); + return renderWithClerk(); + }; + + it('keeps the last card on screen, inert, after a dismissal until it is reset', async () => { + await renderHolding(guardedAction()); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), 'half-typed'); + + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + + expect(screen.getByText(STEP.password)).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull(); + expect(passwordField()).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Reset' })); + expect(screen.queryByText(STEP.password)).toBeNull(); + }); + + it('keeps the last card on screen after the action completes until the next run', async () => { + await renderHolding(guardedAction()); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + await untilResolved(); + + expect(screen.getByText(STEP.password)).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Run action' })); + await waitFor(() => expect(screen.queryByText(STEP.password)).toBeNull()); + }); + + it('is visible once there is a card of its own to show, until it is reset', async () => { + await renderHolding(guardedAction()); + const start = holdRequests('post', VERIFY); + const visible = () => screen.getByLabelText('Visible').textContent; + expect(visible()).toBe('false'); + + const user = await startVerification(); + await waitFor(() => expect(start.requests).toHaveLength(1)); + expect(screen.getByText(TITLE)).toBeVisible(); + expect(visible()).toBe('false'); + + start.release(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(visible()).toBe('true'); + + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + expect(visible()).toBe('true'); + + await user.click(screen.getByRole('button', { name: 'Reset' })); + expect(visible()).toBe('false'); + }); + + it('does not keep anything on screen when no reverification was needed', async () => { + await renderHolding(vi.fn().mockResolvedValue({ done: true })); + await startVerification(); + + await untilResolved(); + expect(screen.queryByText(STEP.password)).toBeNull(); + }); + }); + + describe('dismissing', () => { + it('dismisses the challenge and starts fresh the next time', async () => { + const action = vi + .fn() + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValue({ done: true }); + await renderReverification(action, verificationSeed({ firstFactors: [passwordFactor] })); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), 'half-typed'); + + // Dismissing rejects the action with reverification_cancelled + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + + // The card closes + await untilClosed(); + + // Running the action again opens from the first step with empty fields + await user.click(screen.getByRole('button', { name: 'Run action' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(passwordField()).toHaveValue(''); + }); + + it('can be dismissed from the method picker', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] })); + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + await useAnotherMethod(user); + expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); + + await dismiss(user); + + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + }); + + it('can be dismissed from the help step', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] })); + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Get help' })); + expect(await screen.findByRole('button', { name: 'Email support' })).toBeVisible(); + + await dismiss(user); + + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + }); + + it('can be dismissed while a code is being sent', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); + const prepare = holdRequests('post', PREPARE_FIRST); + const user = await startVerification(); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + + await dismiss(user); + + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + prepare.release(); + }); + + it('ignores a late failure from an attempt that was in flight when dismissed', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + const attempt = holdRequests('post', ATTEMPT_FIRST); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + + // The card closes and the action is rejected as cancelled + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + + // The late failure does not reopen the card or show an error + attempt.fail(); + await settle(); + expect(screen.queryByText(TITLE)).toBeNull(); + expect(screen.queryByText('form_param_invalid')).toBeNull(); + expect(outcome()).toBe('rejected: reverification_cancelled'); + }); + + it('ignores a late success from an attempt that was in flight when dismissed', async () => { + const { fapi, action } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor] }), + ); + const attempt = holdRequests('post', ATTEMPT_FIRST); + const touch = holdRequests('post', TOUCH); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + + // The card closes and the action is rejected as cancelled + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + + // The session is not activated + attempt.release(); + await waitFor(() => expect(fapi.verification.status).toBe('complete')); + await settle(); + expect(touch.requests).toHaveLength(0); + touch.release(); + + // The action is not retried + expect(action).toHaveBeenCalledTimes(1); + expect(screen.queryByText(TITLE)).toBeNull(); + }); + + it('starts a fresh challenge when the action is run again while a dismissed attempt is in flight', async () => { + const action = vi + .fn() + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValue({ done: true }); + await renderReverification(action, verificationSeed({ firstFactors: [passwordFactor] })); + const attempt = holdRequests('post', ATTEMPT_FIRST); + const touch = holdRequests('post', TOUCH); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + + // The new challenge opens from the first step with empty fields + await user.click(screen.getByRole('button', { name: 'Run action' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(passwordField()).toHaveValue(''); + expect(passwordField()).toBeEnabled(); + + // The late response from the dismissed attempt has no effect + attempt.release(); + await settle(); + expect(passwordField()).toHaveValue(''); + expect(passwordField()).toBeEnabled(); + expect(screen.getByText(STEP.password)).toBeVisible(); + expect(touch.requests).toHaveLength(0); + touch.release(); + expect(action).toHaveBeenCalledTimes(2); + }); + }); + + describe('action lifecycle', () => { + it('rejects a second call while a challenge is open', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), 'typed'); + + await user.click(screen.getByRole('button', { name: 'Run action' })); + + // The second call rejects with request_already_in_progress + await waitFor(() => expect(outcome()).toBe('rejected: request_already_in_progress')); + + // The open challenge is unaffected + expect(screen.getByText(STEP.password)).toBeVisible(); + expect(passwordField()).toHaveValue('typed'); + }); + + it('cancels the challenge when the user signs out', async () => { + const { clerk } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor] }), + ); + await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + + await act(() => clerk.signOut()); + + // The card closes + await untilClosed(); + + // The action is rejected as cancelled + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + }); + + it('passes through an error that is unrelated to reverification', async () => { + const action = vi.fn().mockRejectedValue(new Error('unrelated failure')); + await renderReverification(action); + + await startVerification(); + + // The action rejects with the original error + await waitFor(() => expect(outcome()).toBe('rejected: unrelated failure')); + + // No challenge card opens + expect(screen.queryByText(TITLE)).toBeNull(); + }); + }); + + describe('errors', () => { + // Currently wrong: the controller keeps only the server's longMessage or message (errorDetail drops the error code), so nothing downstream can localize it. Known codes such as form_password_incorrect, form_code_incorrect and verification_not_sent should map to localized messages the way the old UI does. + // Blocked: Mosaic has no message keys for error codes yet, so there is nothing a test can override. + it.todo('shows a localized message for a wrong password', () => { + // The message for form_password_incorrect follows the configured locale + }); + + it.todo('shows a localized message for a wrong code', () => { + // The message for form_code_incorrect follows the configured locale + }); + + it('shows the long message from the server for a Clerk error code without a localized message', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + worker.use( + http.post(fapiUrl(ATTEMPT_FIRST), () => + HttpResponse.json( + { errors: [{ code: 'some_new_error', message: 'Short message', long_message: 'Long message' }] }, + { status: 422 }, + ), + ), + ); + const user = await startVerification(); + + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + + expect(await screen.findByText('Long message')).toBeInTheDocument(); + expect(screen.queryByText('Short message')).toBeNull(); + }); + + it('shows the short message from the server when there is no long message', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + worker.use( + http.post(fapiUrl(ATTEMPT_FIRST), () => + HttpResponse.json({ errors: [{ code: 'some_new_error', message: 'Short message' }] }, { status: 422 }), + ), + ); + const user = await startVerification(); + + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + + expect(await screen.findByText('Short message')).toBeInTheDocument(); + }); + + it('shows a localized generic message when the failure is not a Clerk error', async () => { + const { clerk } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor] }), + { + messages: { 'reverification.unstable__errors__generic': 'Something custom went wrong' }, + }, + ); + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + vi.spyOn(clerk.session!, 'attemptFirstFactorVerification').mockRejectedValue('not an error'); + + await user.type(passwordField(), `${PASSWORD}{Enter}`); + + expect(await screen.findByText('Something custom went wrong')).toBeInTheDocument(); + }); + + it('shows the generic message instead of the request URL when there is a network failure', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + worker.use(http.post(fapiUrl(ATTEMPT_FIRST), () => HttpResponse.error(), { once: true })); + const restore = pretendToBeARealBrowser(); + + try { + const user = await startVerification(); + + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + + await waitFor(() => expect(passwordField()).toBeInvalid()); + expect(screen.getByText('Something went wrong. Please try again.')).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent(/https?:\/\//); + } finally { + restore(); + } + }); + + it('keeps the step open and lets the user try again after a network failure', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + worker.use(http.post(fapiUrl(ATTEMPT_FIRST), () => HttpResponse.error(), { once: true })); + const restore = pretendToBeARealBrowser(); + + try { + const user = await startVerification(); + + // The step stays open with an error + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + await waitFor(() => expect(passwordField()).toBeInvalid()); + expect(screen.getByText(STEP.password)).toBeVisible(); + expect(passwordField()).toBeEnabled(); + expect(passwordField()).toHaveValue(PASSWORD); + + // The user can try again + await user.type(passwordField(), '{Enter}'); + await untilResolved(); + } finally { + restore(); + } + }); + }); +}); + +/* + Since this file already lists a lot of the behaviors worth knowing about for reverification, here + are a few others that might be worth knowing about, but don't deserve tests here: + + Owned by the server (tested in clerk_go/tests/fapi/session_reverification_test.go): + - A second_factor or multi_factor request falls back to first factor when the user has no second + factor (TestSessionReverification_FallbackLevelFirstFactor) + - The password factor is omitted when password reverification is disabled, and a second factor + request then falls back to first factor (PasswordDisabled, PasswordDisabled_SecondFactorFallback) + - Second factor only strategies are rejected as first factors (SecondFactorsRejectedAsFirstFactors) + - Phone numbers reserved for second factor are rejected as first factors, and a phone number must + be reserved for second factor to be used as one (FirstFactor_RejectsReservedForSecondFactorPhone, + SecondFactor_PhoneCode) + - A passkey can satisfy the second factor, which completes a multi factor request without a second + step, unless the instance opts out (MultiFactor_PasskeySatisfiesSecondFactor and _Disabled) + - A TOTP code that was already consumed is rejected (TOTP_RejectsConsumedCode) + - A pwned password is still accepted for reverification (FirstFactor_PwnedPassword) + - Wrong passwords and codes are 422 (form_password_incorrect, form_code_incorrect). Acting in the + wrong state is 400 (invalid_action_for_session_reverification), as is attempting a code that was + never sent (verification_not_sent) and acting before starting (session_reverification_missing) +*/ diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx deleted file mode 100644 index 7403bc26b16..00000000000 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { ClerkAPIResponseError } from '@clerk/shared/error'; -import type * as SharedReact from '@clerk/shared/react'; -import type { PreferredSignInStrategy, SessionVerificationResource } from '@clerk/shared/types'; -import { renderHook } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { - type ReverificationModel, - type ReverificationReadyModel, - useReverificationModel, -} from '../reverification.model'; - -function ready(model: ReverificationModel): ReverificationReadyModel { - expect(model.status).toBe('ready'); - if (model.status !== 'ready') { - throw new Error('expected ready'); - } - return model; -} - -let session: - | { - id: string; - startVerification: ReturnType; - prepareFirstFactorVerification: ReturnType; - prepareSecondFactorVerification: ReturnType; - attemptFirstFactorVerification: ReturnType; - attemptSecondFactorVerification: ReturnType; - verifyWithPasskey: ReturnType; - } - | null - | undefined; -let environmentHydrated: boolean; -let preferredSignInStrategy: PreferredSignInStrategy; -let supportEmail: string | undefined; -let webAuthnSupported: boolean; -let setActive: ReturnType; - -function environment() { - return environmentHydrated ? { displayConfig: { preferredSignInStrategy } } : undefined; -} - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useSession: () => ({ session }), - useClerk: () => ({ setActive }), - }; -}); - -vi.mock('../../../hooks/useMosaicEnvironment', () => ({ - useMosaicEnvironment: () => environment(), -})); - -vi.mock('../../../hooks/useMosaicSupportEmail', () => ({ - useMosaicSupportEmail: () => supportEmail, -})); - -vi.mock('@clerk/shared/webauthn', () => ({ - isWebAuthnSupported: () => webAuthnSupported, -})); - -function resource(overrides: Partial = {}): SessionVerificationResource { - return { - status: 'needs_first_factor', - level: 'first_factor', - session: { id: 'sess_1' }, - supportedFirstFactors: [{ strategy: 'password' }], - supportedSecondFactors: null, - ...overrides, - } as SessionVerificationResource; -} - -function activeProps() { - return { - isActive: true as const, - complete: vi.fn(), - cancel: vi.fn(), - level: 'first_factor' as const, - }; -} - -describe('useReverificationModel', () => { - beforeEach(() => { - session = { - id: 'sess_1', - startVerification: vi.fn(), - prepareFirstFactorVerification: vi.fn(), - prepareSecondFactorVerification: vi.fn(), - attemptFirstFactorVerification: vi.fn(), - attemptSecondFactorVerification: vi.fn(), - verifyWithPasskey: vi.fn(), - }; - environmentHydrated = true; - preferredSignInStrategy = 'password'; - supportEmail = 'support@example.com'; - webAuthnSupported = true; - setActive = vi.fn().mockResolvedValue(undefined); - }); - - it('is loading until the session and environment are both present', () => { - session = null; - const { result } = renderHook(() => useReverificationModel(activeProps())); - expect(result.current.status).toBe('loading'); - expect(result.current.isActive).toBe(true); - }); - - it('is loading until supportEmail is resolved', () => { - supportEmail = undefined; - const { result } = renderHook(() => useReverificationModel(activeProps())); - expect(result.current.status).toBe('loading'); - expect(result.current.isActive).toBe(true); - }); - - it('is active when props are active', () => { - const { result } = renderHook(() => useReverificationModel(activeProps())); - expect(result.current.isActive).toBe(true); - expect(ready(result.current).supportEmail).toBe('support@example.com'); - }); - - it('is inactive when props are idle', () => { - const { result } = renderHook(() => useReverificationModel({ isActive: false })); - expect(result.current.isActive).toBe(false); - }); - - it('defaults to second-factor verification when no level is provided', async () => { - session?.startVerification.mockResolvedValue(resource()); - const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: undefined })); - - await ready(result.current).start(); - - expect(session?.startVerification).toHaveBeenCalledWith({ level: 'second_factor' }); - }); - - it('maps first-factor strategies and drops enterprise_sso and passkey without WebAuthn', async () => { - webAuthnSupported = false; - session?.startVerification.mockResolvedValue( - resource({ - supportedFirstFactors: [ - { strategy: 'password' }, - { strategy: 'passkey' }, - { strategy: 'email_code', emailAddressId: 'idn_1', safeIdentifier: 'a***@ex.com' }, - { - strategy: 'enterprise_sso', - emailAddressId: 'idn_2', - enterpriseConnectionId: 'ec_1', - safeIdentifier: 'sso', - }, - ], - }), - ); - - const { result } = renderHook(() => useReverificationModel(activeProps())); - const started = await ready(result.current).start(); - - expect(session?.startVerification).toHaveBeenCalledWith({ level: 'first_factor' }); - expect(started.methods.map(method => method.strategy)).toEqual(['password', 'email_code']); - expect(started.startingMethod?.strategy).toBe('password'); - expect(started.methods.find(method => method.strategy === 'email_code')).toEqual({ - id: 'email_code:idn_1', - stage: 'first', - strategy: 'email_code', - identifier: 'a***@ex.com', - emailAddressId: 'idn_1', - }); - }); - - it('prefers passkey when WebAuthn is available', async () => { - session?.startVerification.mockResolvedValue( - resource({ - supportedFirstFactors: [{ strategy: 'password' }, { strategy: 'passkey' }], - }), - ); - - const { result } = renderHook(() => useReverificationModel(activeProps())); - const started = await ready(result.current).start(); - expect(started.startingMethod?.strategy).toBe('passkey'); - }); - - it('starts second factor on totp then phone then the first remaining method', async () => { - session?.startVerification.mockResolvedValue( - resource({ - status: 'needs_second_factor', - supportedFirstFactors: null, - supportedSecondFactors: [ - { strategy: 'backup_code' }, - { strategy: 'phone_code', phoneNumberId: 'pn_1', safeIdentifier: '+1••••1' }, - { strategy: 'totp' }, - ], - }), - ); - - const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: 'second_factor' })); - const started = await ready(result.current).start(); - expect(started.status).toBe('needs_second_factor'); - expect(started.startingMethod).toEqual({ id: 'totp', stage: 'second', strategy: 'totp' }); - expect(started.methods.find(method => method.strategy === 'phone_code')).toEqual({ - id: 'phone_code:pn_1', - stage: 'second', - strategy: 'phone_code', - identifier: '+1••••1', - phoneNumberId: 'pn_1', - }); - }); - - it('prepares and attempts with the Clerk param shape for the active method', async () => { - session?.startVerification.mockResolvedValue(resource()); - session?.prepareFirstFactorVerification.mockResolvedValue(resource()); - session?.attemptFirstFactorVerification.mockResolvedValue(resource({ status: 'complete' })); - - const { result } = renderHook(() => useReverificationModel(activeProps())); - await ready(result.current).start(); - await ready(result.current).prepare({ - id: 'email_code:idn_1', - stage: 'first', - strategy: 'email_code', - identifier: 'a***@ex.com', - emailAddressId: 'idn_1', - }); - expect(session?.prepareFirstFactorVerification).toHaveBeenCalledWith({ - strategy: 'email_code', - emailAddressId: 'idn_1', - }); - - await ready(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'secret'); - expect(session?.attemptFirstFactorVerification).toHaveBeenCalledWith({ - strategy: 'password', - password: 'secret', - }); - - await ready(result.current).prepare({ - id: 'phone_code:pn_1', - stage: 'first', - strategy: 'phone_code', - phoneNumberId: 'pn_1', - identifier: '+1••••1', - }); - expect(session?.prepareFirstFactorVerification).toHaveBeenCalledWith({ - strategy: 'phone_code', - phoneNumberId: 'pn_1', - }); - }); - - it('prepares second-factor phone codes and attempts totp against the second-factor API', async () => { - session?.startVerification.mockResolvedValue( - resource({ - status: 'needs_second_factor', - supportedFirstFactors: null, - supportedSecondFactors: [{ strategy: 'totp' }, { strategy: 'phone_code', phoneNumberId: 'pn_1' }], - }), - ); - session?.prepareSecondFactorVerification.mockResolvedValue(resource({ status: 'needs_second_factor' })); - session?.attemptSecondFactorVerification.mockResolvedValue(resource({ status: 'complete' })); - - const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: 'second_factor' })); - await ready(result.current).start(); - await ready(result.current).prepare({ - id: 'phone_code:pn_1', - stage: 'second', - strategy: 'phone_code', - phoneNumberId: 'pn_1', - identifier: '+1••••1', - }); - expect(session?.prepareSecondFactorVerification).toHaveBeenCalledWith({ - strategy: 'phone_code', - phoneNumberId: 'pn_1', - }); - - await ready(result.current).attempt({ id: 'totp', stage: 'second', strategy: 'totp' }, '123456'); - expect(session?.attemptSecondFactorVerification).toHaveBeenCalledWith({ strategy: 'totp', code: '123456' }); - }); - - it('verifies a passkey', async () => { - session?.verifyWithPasskey.mockResolvedValue(resource({ status: 'complete' })); - const { result } = renderHook(() => useReverificationModel(activeProps())); - - await ready(result.current).attempt({ id: 'passkey', stage: 'first', strategy: 'passkey' }, ''); - expect(session?.verifyWithPasskey).toHaveBeenCalledOnce(); - }); - - it('rewrites Clerk API errors to plain Error messages', async () => { - session?.attemptFirstFactorVerification.mockRejectedValue( - new ClerkAPIResponseError('nope', { - data: [ - { - code: 'form_password_incorrect', - message: 'Incorrect password', - long_message: 'That password is incorrect.', - }, - ], - status: 422, - }), - ); - - const { result } = renderHook(() => useReverificationModel(activeProps())); - await expect( - ready(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'bad'), - ).rejects.toThrow('That password is incorrect.'); - }); - - it('activates the verified session and calls complete', async () => { - session?.startVerification.mockResolvedValue(resource({ status: 'complete' })); - const props = activeProps(); - const { result } = renderHook(() => useReverificationModel(props)); - const started = await ready(result.current).start(); - expect(started).toEqual({ status: 'complete', methods: [], startingMethod: null }); - await ready(result.current).finish(); - expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' }); - expect(props.complete).toHaveBeenCalledOnce(); - }); - - it('does not call complete when setActive fails', async () => { - setActive.mockRejectedValue(new Error('Session could not be activated.')); - const props = activeProps(); - const { result } = renderHook(() => useReverificationModel(props)); - - await expect(ready(result.current).finish()).rejects.toThrow('Session could not be activated.'); - expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' }); - expect(props.complete).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx deleted file mode 100644 index 5611f71f638..00000000000 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { Reverification } from '../reverification'; -import type { ReverificationController } from '../reverification.controller'; -import type { ReverificationModel } from '../reverification.model'; -import type { ReverificationViewProps } from '../reverification.types'; - -const model: ReverificationModel = { - status: 'ready', - isActive: true, - supportEmail: '', - start: vi.fn(), - prepare: vi.fn(), - attempt: vi.fn(), - finish: vi.fn(), - cancel: vi.fn(), -}; - -let controller: ReverificationController = { status: 'idle' }; - -vi.mock('../reverification.model', () => ({ - useReverificationModel: () => model, -})); - -vi.mock('../reverification.controller', () => ({ - useReverificationController: () => controller, -})); - -vi.mock('../reverification.view', () => ({ - ReverificationView: ({ step }: { step: string }) => {step}, -})); - -const active = { - isActive: true as const, - complete: vi.fn(), - cancel: vi.fn(), - level: 'first_factor' as const, -}; - -function ready(overrides: Partial = {}): ReverificationController { - return { - status: 'ready', - step: 'password', - value: '', - onValueChange: vi.fn(), - isPending: false, - onSubmit: vi.fn(), - onShowMethods: vi.fn(), - onShowHelp: vi.fn(), - onBack: vi.fn(), - onEmailSupport: vi.fn(), - onResend: vi.fn(), - canResend: true, - methods: [], - onSelectMethod: vi.fn(), - ...overrides, - }; -} - -describe('Reverification', () => { - it('renders nothing when reverification is not active', () => { - controller = { status: 'idle' }; - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders nothing while the controller is loading or unavailable', () => { - controller = { status: 'loading' }; - const loading = render(); - expect(loading.container).toBeEmptyDOMElement(); - - controller = { status: 'unavailable' }; - const unavailable = render(); - expect(unavailable.container).toBeEmptyDOMElement(); - }); - - it('renders the view once the controller is ready', () => { - controller = ready({ step: 'otp', otpChannel: 'email' }); - render(); - expect(screen.getByTestId('view')).toHaveTextContent('otp'); - }); -}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx deleted file mode 100644 index d9439dedfc7..00000000000 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { act, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import type { ReverificationViewProps } from '../reverification.types'; -import { ReverificationView } from '../reverification.view'; - -function viewProps(overrides: Partial = {}): ReverificationViewProps { - return { - step: 'password', - value: '', - onValueChange: vi.fn(), - isPending: false, - onSubmit: vi.fn(), - onShowMethods: vi.fn(), - onShowHelp: vi.fn(), - onBack: vi.fn(), - onEmailSupport: vi.fn(), - onResend: vi.fn(), - canResend: true, - methods: [], - onSelectMethod: vi.fn(), - ...overrides, - }; -} - -function renderView(overrides: Partial = {}) { - return render( - - - , - ); -} - -describe('ReverificationView', () => { - it('keeps one Card and Flow surface while view props and panels change', () => { - const { container, rerender } = renderView(); - - const card = container.querySelector('.cl-card-root'); - const flow = container.querySelector('.cl-flow-root'); - const passwordStep = screen.getByLabelText('Password').closest('.cl-flow-step'); - - expect(card).not.toBeNull(); - expect(card).toContainElement(flow); - expect(flow).toHaveAttribute('data-value', 'password'); - - rerender( - - - , - ); - - expect(container.querySelector('.cl-card-root')).toBe(card); - expect(container.querySelector('.cl-flow-root')).toBe(flow); - expect(screen.getByLabelText('Password').closest('.cl-flow-step')).toBe(passwordStep); - - rerender( - - - , - ); - - expect(container.querySelector('.cl-card-root')).toBe(card); - expect(container.querySelector('.cl-flow-root')).toBe(flow); - expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); - const otpStep = screen.getByRole('group', { name: 'Verification code' }).closest('.cl-flow-step'); - expect(otpStep).toBeInTheDocument(); - expect(otpStep?.style.getPropertyValue('--cl-flow-transition-direction')).toBe('-1'); - }); - - it('moves focus to the entering step once it settles', async () => { - const { rerender } = renderView(); - - expect(screen.getByLabelText('Password')).not.toHaveFocus(); - - rerender( - - - , - ); - await act(async () => {}); - - expect(screen.getAllByRole('textbox')[0]).toHaveFocus(); - - rerender( - - - , - ); - await act(async () => {}); - - expect(screen.getByRole('button', { name: 'Continue with your password' })).toHaveFocus(); - }); - - it('keeps a disabled resend control during the cooldown', () => { - renderView({ - step: 'otp', - otpChannel: 'email', - canResend: false, - resendRemainingSeconds: 23, - }); - - expect(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (23)' })).toBeDisabled(); - }); - - it('does not render Card branding', () => { - renderView(); - - expect(screen.queryByText('Secured by')).not.toBeInTheDocument(); - }); - - it('renders a passkey attempt error in a negative Banner', () => { - renderView({ - step: 'passkey', - errorMessage: 'We couldn’t verify that passkey. Try again.', - }); - - const banner = screen.getByRole('alert'); - expect(banner).toHaveClass('cl-banner-root'); - expect(banner).toHaveAttribute('data-color', 'negative'); - expect(banner).toHaveTextContent('We couldn’t verify that passkey. Try again.'); - }); -}); diff --git a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx index 162709d54a7..82aea7c7b46 100644 --- a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -1,8 +1,11 @@ +import { ClerkRuntimeError, isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; import type * as SharedReact from '@clerk/shared/react'; import type { SessionVerificationLevel } from '@clerk/shared/types'; import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { deferred, tick } from '../../../machines/__tests__/test-utils'; +import type { ReverificationState } from '../reverification.types'; import { useReverificationWithState } from '../use-reverification-with-state'; type NeedsReverificationParameters = { @@ -11,9 +14,16 @@ type NeedsReverificationParameters = { level: SessionVerificationLevel | undefined; }; -let capturedOnNeeds: ((params: NeedsReverificationParameters) => void) | undefined; +type Hint = { reverificationLevel: SessionVerificationLevel | undefined }; + +const IN_PROGRESS = 'request_already_in_progress'; + let session: { id: string } | null | undefined = { id: 'sess_1' }; -const wrapped = vi.fn(); +let challengeCancel: ReturnType; + +function isHint(value: unknown): value is Hint { + return Boolean(value && typeof value === 'object' && 'reverificationLevel' in value); +} vi.mock('@clerk/shared/react', async importOriginal => { const actual = await importOriginal(); @@ -21,203 +31,492 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useSession: () => ({ session }), useReverification: ( - _fetcher: unknown, + fetcher: (...args: unknown[]) => Promise | undefined, options?: { onNeedsReverification?: (params: NeedsReverificationParameters) => void }, ) => { - capturedOnNeeds = options?.onNeedsReverification; - return wrapped; + return async (...args: unknown[]) => { + const result = await fetcher(...args); + if (!isHint(result)) { + return result; + } + await new Promise((resolve, reject) => { + options?.onNeedsReverification?.({ + level: result.reverificationLevel, + complete: () => resolve(), + cancel: () => { + challengeCancel(); + reject( + new ClerkRuntimeError('User cancelled attempted verification', { + code: 'reverification_cancelled', + }), + ); + }, + }); + }); + return fetcher(...args); + }; }, }; }); -const fetcher = async (id: string) => id; +function assertActive(state: ReverificationState): asserts state is Extract { + expect(state.phase).toBe('active'); + if (state.phase !== 'active') { + throw new Error('expected active reverification'); + } +} describe('useReverificationWithState', () => { beforeEach(() => { - capturedOnNeeds = undefined; session = { id: 'sess_1' }; - wrapped.mockReset(); + challengeCancel = vi.fn(); }); - it('returns the enhanced fetcher and is idle until reverification is needed', () => { + it('returns the enhanced fetcher and stays inactive until reverification is needed', async () => { + const fetcher = vi.fn((id: string) => Promise.resolve(id)); const { result } = renderHook(() => useReverificationWithState(fetcher)); - const [callback, state] = result.current; - expect(callback).toBe(wrapped); - expect(state.isActive).toBe(false); - expect(state.complete).toBeUndefined(); - expect(state.cancel).toBeUndefined(); - expect(state.level).toBeUndefined(); + expect(typeof result.current[0]).toBe('function'); + expect(result.current[1]).toEqual({ phase: 'inactive' }); + + let value: string | undefined; + await act(async () => { + value = await result.current[0]('user'); + }); + + expect(value).toBe('user'); + expect(fetcher).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledWith('user'); + expect(result.current[1]).toEqual({ phase: 'inactive' }); }); - it('surfaces complete, cancel, and level when useReverification needs reverification', () => { + it('moves inactive → active → retrying → settled when the retry succeeds', async () => { + const retry = deferred<{ ok: true }>(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockImplementationOnce(() => retry.promise); const { result } = renderHook(() => useReverificationWithState(fetcher)); - const complete = vi.fn(); - const cancel = vi.fn(); + let pending!: Promise; act(() => { - capturedOnNeeds?.({ complete, cancel, level: 'first_factor' }); + pending = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + assertActive(result.current[1]); + expect(result.current[1].level).toBe('first_factor'); + expect(fetcher).toHaveBeenCalledOnce(); + + act(() => { + assertActive(result.current[1]); + result.current[1].complete(); + }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'retrying' })); + expect(fetcher).toHaveBeenCalledTimes(2); + + retry.resolve({ ok: true }); + await act(async () => { + await expect(pending).resolves.toEqual({ ok: true }); + }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'settled' })); + expect(challengeCancel).not.toHaveBeenCalled(); + }); + + it('settles when the delayed retry fails', async () => { + const retry = deferred(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'multi_factor' } satisfies Hint) + .mockImplementationOnce(() => retry.promise); + const { result } = renderHook(() => useReverificationWithState(fetcher)); - const [, state] = result.current; - expect(state.isActive).toBe(true); - if (!state.isActive) { - throw new Error('expected active reverification state'); - } - expect(state.level).toBe('first_factor'); + let pending!: Promise; + act(() => { + pending = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); act(() => { - state.complete(); + assertActive(result.current[1]); + result.current[1].complete(); }); + await waitFor(() => expect(result.current[1].phase).toBe('retrying')); - expect(complete).toHaveBeenCalledOnce(); - expect(result.current[1].isActive).toBe(false); + retry.reject(new Error('Mock delete failed.')); + await act(async () => { + await expect(pending).rejects.toMatchObject({ message: 'Mock delete failed.' }); + }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'settled' })); + expect(challengeCancel).not.toHaveBeenCalled(); }); - it('returns to idle after cancel and calls the state cancel', () => { + it('settles immediately when the active challenge is cancelled', async () => { + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: undefined } satisfies Hint); const { result } = renderHook(() => useReverificationWithState(fetcher)); - const complete = vi.fn(); - const cancel = vi.fn(); + let pending!: Promise; act(() => { - capturedOnNeeds?.({ complete, cancel, level: undefined }); + pending = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); act(() => { - const [, state] = result.current; - if (state.isActive) { - state.cancel(); - } + assertActive(result.current[1]); + result.current[1].cancel(); }); + expect(result.current[1]).toEqual({ phase: 'settled' }); - expect(cancel).toHaveBeenCalledOnce(); - expect(complete).not.toHaveBeenCalled(); - expect(result.current[1].isActive).toBe(false); + await act(async () => { + await expect(pending).rejects.toMatchObject({ code: 'reverification_cancelled' }); + }); + expect(isReverificationCancelledError(await pending.catch(error => error))).toBe(true); + expect(result.current[1]).toEqual({ phase: 'settled' }); + expect(fetcher).toHaveBeenCalledOnce(); + expect(challengeCancel).toHaveBeenCalledOnce(); }); - it('cancels an overlapping challenge and preserves the active challenge', () => { + it('stays settled until the handler is called again', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockResolvedValue('ok'); const { result } = renderHook(() => useReverificationWithState(fetcher)); - const firstComplete = vi.fn(); - const firstCancel = vi.fn(); - const secondComplete = vi.fn(); - const secondCancel = vi.fn(); + let first!: Promise; + act(() => { + first = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); act(() => { - capturedOnNeeds?.({ complete: firstComplete, cancel: firstCancel, level: 'first_factor' }); - capturedOnNeeds?.({ complete: secondComplete, cancel: secondCancel, level: 'multi_factor' }); + assertActive(result.current[1]); + result.current[1].cancel(); }); + await act(async () => { + await first.catch(() => undefined); + }); + expect(result.current[1]).toEqual({ phase: 'settled' }); - expect(secondCancel).toHaveBeenCalledOnce(); - expect(result.current[1]).toMatchObject({ isActive: true, level: 'first_factor' }); + let second!: Promise; + act(() => { + second = result.current[0](); + }); + expect(result.current[1]).toEqual({ phase: 'inactive' }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].cancel(); + }); + await act(async () => { + await second.catch(() => undefined); + }); + }); + + it('releases a settled run with reset', async () => { + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); + const { result } = renderHook(() => useReverificationWithState(fetcher)); + let first!: Promise; act(() => { - const [, state] = result.current; - if (state.isActive) { - state.complete(); - } + first = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].cancel(); + }); + + act(() => result.current[2]()); + expect(result.current[1]).toEqual({ phase: 'settled' }); - expect(firstComplete).toHaveBeenCalledOnce(); - expect(firstCancel).not.toHaveBeenCalled(); - expect(secondComplete).not.toHaveBeenCalled(); + await act(async () => { + await first.catch(() => undefined); + }); + act(() => result.current[2]()); + expect(result.current[1]).toEqual({ phase: 'inactive' }); }); - it('allows another challenge after the active challenge settles', () => { + it('rejects a second call during the initial request without calling the fetcher again', async () => { + const gate = deferred(); + const fetcher = vi.fn(() => gate.promise); const { result } = renderHook(() => useReverificationWithState(fetcher)); - const firstComplete = vi.fn(); - const secondCancel = vi.fn(); + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: firstComplete, cancel: vi.fn(), level: 'first_factor' }); + first = result.current[0]('first'); }); + await waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); + + await expect(result.current[0]('second')).rejects.toMatchObject({ code: IN_PROGRESS }); + expect(fetcher).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledWith('first'); + + gate.resolve('done'); + await act(async () => { + await expect(first).resolves.toBe('done'); + }); + }); + + it('rejects a second call during verification and retry without calling the fetcher', async () => { + const retry = deferred(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockImplementationOnce(() => retry.promise); + const { result } = renderHook(() => useReverificationWithState(fetcher)); + + let first!: Promise; act(() => { - const [, state] = result.current; - if (state.isActive) { - state.complete(); - } + first = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + expect(fetcher).toHaveBeenCalledOnce(); + + const duringVerification = await result.current[0]().then( + () => { + throw new Error('expected rejection'); + }, + error => error, + ); + expect(isReverificationCancelledError(duringVerification)).toBe(false); + expect(isClerkRuntimeError(duringVerification) && duringVerification.code).toBe(IN_PROGRESS); + expect(fetcher).toHaveBeenCalledOnce(); + act(() => { - capturedOnNeeds?.({ complete: vi.fn(), cancel: secondCancel, level: 'multi_factor' }); + assertActive(result.current[1]); + result.current[1].complete(); }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'retrying' })); + expect(fetcher).toHaveBeenCalledTimes(2); + + await expect(result.current[0]()).rejects.toMatchObject({ code: IN_PROGRESS }); + expect(fetcher).toHaveBeenCalledTimes(2); - expect(firstComplete).toHaveBeenCalledOnce(); - expect(secondCancel).not.toHaveBeenCalled(); - expect(result.current[1]).toMatchObject({ isActive: true, level: 'multi_factor' }); + retry.resolve('ok'); + await act(async () => { + await expect(first).resolves.toBe('ok'); + }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'settled' })); }); - it('cancels when the session changes after reverification opens', async () => { + it('starts another challenge after the first invocation settles', async () => { + const retry = deferred(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockImplementationOnce(() => retry.promise) + .mockResolvedValueOnce({ reverificationLevel: 'multi_factor' } satisfies Hint); + const { result } = renderHook(() => useReverificationWithState(fetcher)); + + let first!: Promise; + act(() => { + first = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].complete(); + }); + await waitFor(() => expect(result.current[1].phase).toBe('retrying')); + + retry.resolve('ok'); + await act(async () => { + await expect(first).resolves.toBe('ok'); + }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'settled' })); + + act(() => { + first = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + expect(result.current[1]).toMatchObject({ phase: 'active', level: 'multi_factor' }); + act(() => { + assertActive(result.current[1]); + result.current[1].cancel(); + }); + await act(async () => { + await first.catch(() => undefined); + }); + }); + + it('cancels when the session changes while the challenge is active', async () => { + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); - const cancel = vi.fn(); + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + first = result.current[0](); }); - expect(result.current[1].isActive).toBe(true); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + const settled = expect(first).rejects.toMatchObject({ code: 'reverification_cancelled' }); session = { id: 'sess_2' }; rerender(); - await waitFor(() => expect(cancel).toHaveBeenCalledOnce()); - expect(result.current[1].isActive).toBe(false); + await settled; + expect(result.current[1]).toEqual({ phase: 'settled' }); + expect(challengeCancel).toHaveBeenCalledOnce(); }); it('does not cancel when the session is briefly unloaded', async () => { - const { rerender } = renderHook(() => useReverificationWithState(fetcher)); - const cancel = vi.fn(); + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); + const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + first = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); const previous = session; session = undefined; rerender(); session = previous; rerender(); - expect(cancel).not.toHaveBeenCalled(); + expect(challengeCancel).not.toHaveBeenCalled(); + expect(result.current[1].phase).toBe('active'); + + act(() => { + assertActive(result.current[1]); + result.current[1].cancel(); + }); + await act(async () => { + await first.catch(() => undefined); + }); }); - it('cancels when the session is signed out', async () => { + it('cancels when the session is signed out during the challenge', async () => { + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); - const cancel = vi.fn(); + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + first = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + + const settled = expect(first).rejects.toMatchObject({ code: 'reverification_cancelled' }); + session = null; + rerender(); + await settled; + expect(result.current[1]).toEqual({ phase: 'settled' }); + }); + + it('does not cancel a retry when the session changes', async () => { + const retry = deferred(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockImplementationOnce(() => retry.promise); + const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); + + let first!: Promise; + act(() => { + first = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].complete(); + }); + await waitFor(() => expect(result.current[1].phase).toBe('retrying')); + + session = { id: 'sess_2' }; + rerender(); + await tick(); + expect(challengeCancel).not.toHaveBeenCalled(); + expect(result.current[1]).toEqual({ phase: 'retrying' }); session = null; rerender(); - await waitFor(() => expect(cancel).toHaveBeenCalledOnce()); - expect(result.current[1].isActive).toBe(false); + await tick(); + expect(challengeCancel).not.toHaveBeenCalled(); + expect(result.current[1]).toEqual({ phase: 'retrying' }); + + retry.resolve('ok'); + await act(async () => { + await expect(first).resolves.toBe('ok'); + }); }); - it('cancels when the owner unmounts mid-flow', () => { - const { unmount } = renderHook(() => useReverificationWithState(fetcher)); - const cancel = vi.fn(); + it('cancels when the owner unmounts during the challenge', async () => { + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); + const { result, unmount } = renderHook(() => useReverificationWithState(fetcher)); + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + first = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + const settled = expect(first).rejects.toMatchObject({ code: 'reverification_cancelled' }); unmount(); - expect(cancel).toHaveBeenCalledOnce(); + await settled; + expect(challengeCancel).toHaveBeenCalledOnce(); + }); + + it('cancels the challenge when the owner unmounts before reverification is needed', async () => { + const response = deferred(); + const fetcher = vi.fn().mockImplementationOnce(() => response.promise); + const { result, unmount } = renderHook(() => useReverificationWithState(fetcher)); + + let pending!: Promise; + act(() => { + pending = result.current[0](); + }); + unmount(); + + const settled = expect(pending).rejects.toMatchObject({ code: 'reverification_cancelled' }); + response.resolve({ reverificationLevel: 'first_factor' }); + await settled; + expect(challengeCancel).toHaveBeenCalledOnce(); }); - it('does not cancel again on unmount after the flow already settled', () => { + it('does not cancel when the owner unmounts during retry', async () => { + const retry = deferred(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockImplementationOnce(() => retry.promise); const { result, unmount } = renderHook(() => useReverificationWithState(fetcher)); - const cancel = vi.fn(); + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + first = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); act(() => { - const [, state] = result.current; - if (state.isActive) { - state.cancel(); - } + assertActive(result.current[1]); + result.current[1].complete(); + }); + await waitFor(() => expect(result.current[1].phase).toBe('retrying')); + + unmount(); + await tick(); + expect(challengeCancel).not.toHaveBeenCalled(); + + retry.resolve('ok'); + await expect(first).resolves.toBe('ok'); + }); + + it('does not cancel again on unmount after the flow already settled', async () => { + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); + const { result, unmount } = renderHook(() => useReverificationWithState(fetcher)); + + let first!: Promise; + act(() => { + first = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].cancel(); + }); + await act(async () => { + await first.catch(() => undefined); }); - expect(cancel).toHaveBeenCalledOnce(); + expect(challengeCancel).toHaveBeenCalledOnce(); unmount(); - expect(cancel).toHaveBeenCalledOnce(); + expect(challengeCancel).toHaveBeenCalledOnce(); }); }); diff --git a/packages/mosaic/src/features/reverification/index.ts b/packages/mosaic/src/features/reverification/index.ts index 4ac85e89457..1c56d70fda6 100644 --- a/packages/mosaic/src/features/reverification/index.ts +++ b/packages/mosaic/src/features/reverification/index.ts @@ -1,15 +1,14 @@ -export { Reverification } from './reverification'; +export { Reverification, useReverificationFlow } from './reverification'; +export type { ReverificationController } from './reverification.controller'; +export type { UseReverificationFlowResult } from './reverification'; export type { ReverificationMethod, ReverificationOtpChannel, - ReverificationProps, + ReverificationState, ReverificationStage, ReverificationStep, ReverificationStrategy, ReverificationViewProps, } from './reverification.types'; -export type { - UseReverificationWithStateOptions, - UseReverificationWithStateResult, -} from './use-reverification-with-state'; +export type { UseReverificationWithStateResult } from './use-reverification-with-state'; export { useReverificationWithState } from './use-reverification-with-state'; diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index 98beda9cf65..030e7388561 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -1,40 +1,63 @@ import { useEffect, useState } from 'react'; import type { FlowDirection } from '../../components/flow'; +import { useMessages } from '../../localization'; import { setup } from '../../machine/setup'; import type { DoneInvokeEvent, StateConfig } from '../../machine/types'; import { useMachine } from '../../machine/useMachine'; -import type { ReverificationModel, ReverificationReadyModel } from './reverification.model'; -import type { ReverificationMethod, ReverificationResult, ReverificationViewProps } from './reverification.types'; -import { needsPrepare, otpChannelFor } from './reverification.utils'; - -export type ReverificationController = - | { status: 'idle' } - | { status: 'loading' } - | { status: 'unavailable' } - | ({ status: 'ready' } & ReverificationViewProps); +import type { ReverificationActiveModel, ReverificationModel } from './reverification.model'; +import type { + ReverificationErrorReason, + ReverificationMethod, + ReverificationResult, + ReverificationStep, + ReverificationViewProps, +} from './reverification.types'; +import { errorDetail, needsPrepare, otpChannelFor } from './reverification.utils'; + +type ReverificationControllerState = + | { status: 'idle'; onCancel?: undefined } + | { status: 'loading'; onCancel?: () => void } + | { status: 'error'; reason: ReverificationErrorReason; onCancel?: () => void } + | ({ status: 'ready'; onCancel?: () => void } & ReverificationViewProps) + | ({ status: 'retrying'; onCancel?: undefined } & ReverificationViewProps); + +export type ReverificationController = ReverificationControllerState & { + /** + * Whether reverification has a card of its own to show: a factor or an error. The pending card + * shown before a factor exists is not counted, so a host can keep its own pending state on + * screen until this turns true. Stays true after the run ends until the next run or `reset`. + */ + visible: boolean; + reset: () => void; +}; type OverlayFrom = 'factor' | 'method-picker'; -export type ReverificationDeps = Pick; +export type ReverificationDeps = Pick; + +interface FactorError { + detail: string | undefined; +} interface ReverificationContext { inputValue: string; - errorMessage: string | undefined; + error: FactorError | undefined; direction: FlowDirection; activeMethod: ReverificationMethod | null; methods: readonly ReverificationMethod[]; resendAvailableAt: number | undefined; - abortRequested: boolean; submitRequested: boolean; frozenActiveMethodId: string | undefined; overlayFrom: OverlayFrom; supportEmail: string; + heldFrom: string; deps: ReverificationDeps; } type ReverificationEvent = | { type: 'START' } + | { type: 'SETTLE' } | { type: 'RESET' } | { type: 'TYPE'; value: string } | { type: 'SUBMIT' } @@ -55,7 +78,6 @@ const unseatedDeps: ReverificationDeps = { prepare: notSeated, attempt: notSeated, finish: notSeated, - cancel: () => {}, }; export const RESEND_COOLDOWN_MS = 30_000; @@ -68,15 +90,22 @@ function unlockResend(): Pick { return { resendAvailableAt: undefined }; } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'Something went wrong. Please try again.'; +function settleFrom(state: string) { + return { + target: 'settled' as const, + actions: assign(() => ({ heldFrom: state })), + }; +} + +function factorError(error: unknown): FactorError { + return { detail: errorDetail(error) }; } function selectMethod(ctx: ReverificationContext, id: string): Partial { return { activeMethod: ctx.methods.find(method => method.id === id) ?? ctx.activeMethod, inputValue: '', - errorMessage: undefined, + error: undefined, direction: 1, submitRequested: false, }; @@ -99,7 +128,7 @@ const applyResult = assign>((_, event) => methods: event.output.methods, activeMethod: event.output.startingMethod, inputValue: '', - errorMessage: undefined, + error: undefined, ...unlockResend(), direction: 1, })); @@ -107,8 +136,8 @@ const afterResult = [ { guard: (_: ReverificationContext, event: DoneInvokeEvent) => event.output.status === 'complete', - target: 'completing' as const, - // We don't apply the result here as we want to keep the old one visible as we are completing + target: 'finishing' as const, + // We don't apply the result here as we want to keep the old one visible as we are finishing }, { guard: (_: ReverificationContext, event: DoneInvokeEvent) => @@ -127,20 +156,9 @@ const afterResult = [ { target: 'verifying' as const, actions: applyResult }, ]; -const abortAfterInvoke = { - target: 'done' as const, - guard: (ctx: ReverificationContext) => ctx.abortRequested, - actions: [ - (ctx: ReverificationContext) => { - ctx.deps.cancel(); - }, - assign(() => ({ abortRequested: false })), - ], -}; - const factorEvents = { TYPE: { - actions: assign((_, event) => ({ inputValue: event.value, errorMessage: undefined })), + actions: assign((_, event) => ({ inputValue: event.value, error: undefined })), }, SHOW_METHODS: { target: 'methodPicker', @@ -168,24 +186,23 @@ export const reverificationMachine = createMachine({ initial: 'inactive', context: { inputValue: '', - errorMessage: undefined, + error: undefined, direction: 1, activeMethod: null, methods: [], resendAvailableAt: undefined, - abortRequested: false, submitRequested: false, frozenActiveMethodId: undefined, overlayFrom: 'factor', supportEmail: '', + heldFrom: '', deps: unseatedDeps, }, states: { inactive: { entry: assign(() => ({ inputValue: '', - errorMessage: undefined, - abortRequested: false, + error: undefined, submitRequested: false, ...unlockResend(), })), @@ -193,10 +210,10 @@ export const reverificationMachine = createMachine({ }, starting: { - on: { RESET: 'inactive' }, + on: { RESET: 'inactive', SETTLE: settleFrom('starting') }, invoke: fromPromise(ctx => ctx.deps.start(), { onDone: afterResult, - onError: 'unavailable', + onError: 'failed', }), }, @@ -209,6 +226,7 @@ export const reverificationMachine = createMachine({ preparing: { on: { ...factorEvents, + SETTLE: settleFrom('preparing'), SUBMIT: { actions: assign(() => ({ submitRequested: true })), }, @@ -225,7 +243,7 @@ export const reverificationMachine = createMachine({ onError: { target: 'verifying', actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), + error: factorError(event.error), submitRequested: false, ...unlockResend(), })), @@ -241,12 +259,12 @@ export const reverificationMachine = createMachine({ */ methodPickerPreparing: { entry: assign(() => lockResend()), - on: { RESET: 'inactive' }, + on: { RESET: 'inactive', SETTLE: settleFrom('methodPickerPreparing') }, invoke: fromPromise(prepareActive, { onDone: 'verifying', onError: { target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error), ...unlockResend() })), + actions: assign((_, event) => ({ error: factorError(event.error), ...unlockResend() })), }, }), }, @@ -255,6 +273,7 @@ export const reverificationMachine = createMachine({ always: [{ guard: ctx => ctx.activeMethod === null, target: 'unavailable' }], on: { ...factorEvents, + SETTLE: settleFrom('verifying'), SUBMIT: 'submitting', RESEND: { target: 'preparing', @@ -264,7 +283,7 @@ export const reverificationMachine = createMachine({ actions: assign(() => ({ ...lockResend(), inputValue: '', - errorMessage: undefined, + error: undefined, submitRequested: false, })), }, @@ -272,26 +291,19 @@ export const reverificationMachine = createMachine({ }, submitting: { - on: { - RESET: { - guard: ctx => !ctx.abortRequested, - actions: assign(() => ({ abortRequested: true })), - }, - }, + on: { RESET: 'inactive', SETTLE: settleFrom('submitting') }, invoke: fromPromise(submit, { - onDone: [abortAfterInvoke, ...afterResult], - onError: [ - abortAfterInvoke, - { - target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), - }, - ], + onDone: afterResult, + onError: { + target: 'verifying', + actions: assign((_, event) => ({ error: factorError(event.error) })), + }, }), }, methodPicker: { on: { + SETTLE: settleFrom('methodPicker'), SELECT_METHOD: [ { target: 'methodPickerPreparing', @@ -314,6 +326,7 @@ export const reverificationMachine = createMachine({ help: { on: { + SETTLE: settleFrom('help'), BACK: [ { target: 'methodPicker', @@ -327,52 +340,71 @@ export const reverificationMachine = createMachine({ }, unavailable: { - entry: (ctx: ReverificationContext) => { - ctx.deps.cancel(); - }, - on: { RESET: 'inactive' }, + on: { RESET: 'inactive', SETTLE: settleFrom('unavailable') }, }, - completing: { - on: { RESET: 'inactive' }, + failed: { + on: { RESET: 'inactive', SETTLE: settleFrom('failed') }, + }, + + finishing: { + on: { RESET: 'inactive', SETTLE: settleFrom('finishing') }, invoke: fromPromise(ctx => ctx.deps.finish(), { - onDone: 'done', - onError: { - target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), - }, + onDone: 'retrying', + onError: 'failed', }), }, - done: { + // This means we are retrying the action after successful reverification + // This state is usually left by the model state going to 'settled' once the retry has completed + retrying: { + on: { RESET: 'inactive', SETTLE: settleFrom('retrying') }, + }, + + /* + The action has ended and the caller has not moved on yet. The machine keeps the last + frame (heldFrom) so the controller can keep rendering it, inert, until the model + leaves 'settled' and the machine is reset. + */ + settled: { on: { RESET: 'inactive' }, }, }, }); -const pendingStates = new Set(['submitting', 'completing']); +const pendingStates = new Set(['submitting', 'finishing', 'retrying']); -function viewStep(value: string, method: ReverificationMethod | null): ReverificationViewProps['step'] | undefined { +function factorStep(method: ReverificationMethod): ReverificationStep { + if (method.strategy === 'password') { + return 'password'; + } + if (method.strategy === 'passkey') { + return 'passkey'; + } + if (method.strategy === 'backup_code') { + return 'backup-code'; + } + return 'otp'; +} + +function viewStep(value: string, method: ReverificationMethod | null): ReverificationStep | undefined { if (value === 'methodPicker' || value === 'methodPickerPreparing') { return 'method-picker'; } if (value === 'help') { return 'help'; } - if (value === 'verifying' || value === 'submitting' || value === 'preparing' || value === 'completing') { + if ( + value === 'verifying' || + value === 'submitting' || + value === 'preparing' || + value === 'finishing' || + value === 'retrying' + ) { if (!method) { return undefined; } - if (method.strategy === 'password') { - return 'password'; - } - if (method.strategy === 'passkey') { - return 'passkey'; - } - if (method.strategy === 'backup_code') { - return 'backup-code'; - } - return 'otp'; + return factorStep(method); } return undefined; } @@ -380,24 +412,39 @@ function viewStep(value: string, method: ReverificationMethod | null): Reverific /** * Machine - State internal to the controller, not all steps are exposed to the UI * Return 'ReverificationController' - The view state - * - status: idle | loading | unavailable | ready - * - When ready - * - step: The visible reverification step + * - status: 'idle' renders nothing + * - status: 'loading' | 'error' carry no factor props + * - status: 'ready' | 'retrying' carry the factor view, including step + * + * There are two pending presentations. + * - status: 'loading' is the pending card rendered in place, before a factor exists + * - status: 'ready' && isPending is the inline pending state of the current factor * - * Note that there are two loading states. - * - status: 'loading' - Full card spinner - * - status: 'ready' && isPending: true - Current action is pending, inline loading state + * 'retrying' is the point of no return: verification succeeded and the original action is being + * retried, so it keeps the last factor pending and has no `onCancel`. The factor machine runs + * while the model is active and resets when the model returns to inactive. + * + * When the run ends after reverification was shown (completed, failed or cancelled), the model is + * 'settled' and the controller keeps returning the last frame, pending and without `onCancel`, + * until the next run starts or `reset` is called. Hosts decide when to stop rendering it. */ -export function useReverificationController(model: ReverificationModel): ReverificationController { - const ready = model.status === 'ready' ? model : null; +export function useReverificationController(model: ReverificationModel, reset: () => void): ReverificationController { + const state = useReverificationState(model); + const visible = state.status === 'ready' || state.status === 'retrying' || state.status === 'error'; + return { ...state, visible, reset }; +} + +function useReverificationState(model: ReverificationModel): ReverificationControllerState { + const m = useMessages('reverification'); + const active = model.status === 'active' ? model : null; const [snapshot, send] = useMachine( reverificationMachine, - ready + active ? { context: { - supportEmail: ready.supportEmail, - deps: ready, + supportEmail: active.supportEmail, + deps: active, }, } : undefined, @@ -416,49 +463,66 @@ export function useReverificationController(model: ReverificationModel): Reverif return () => window.clearInterval(id); }, [countingDown, resendAvailableAt]); - const needsStart = model.isActive && Boolean(ready) && snapshot.value === 'inactive'; - const needsReset = !model.isActive && snapshot.value !== 'inactive'; + const cancelModel = + (model.status === 'loading' || model.status === 'active') && snapshot.value !== 'retrying' + ? model.cancel + : undefined; + const onCancel = cancelModel + ? () => { + send({ type: 'SETTLE' }); + cancelModel(); + } + : undefined; + const settled = model.status === 'settled'; + const needsStart = active !== null && snapshot.value === 'inactive'; + const needsSettle = settled && snapshot.value !== 'inactive' && snapshot.value !== 'settled'; + const needsReset = + (model.status === 'inactive' && snapshot.value !== 'inactive') || (!settled && snapshot.value === 'settled'); useEffect(() => { if (needsStart) { send({ type: 'START' }); + } else if (needsSettle) { + send({ type: 'SETTLE' }); } else if (needsReset) { send({ type: 'RESET' }); } - }, [needsStart, needsReset, send]); + }, [needsStart, needsSettle, needsReset, send]); - if (!model.isActive) { + if (model.status === 'inactive') { return { status: 'idle' }; } - if (snapshot.value === 'inactive' || snapshot.value === 'starting' || snapshot.value === 'done') { - return { status: 'loading' }; + const { context } = snapshot; + const activeMethod = context.activeMethod; + const state = settled && snapshot.value === 'settled' ? context.heldFrom : snapshot.value; + + if (state === 'unavailable') { + return { status: 'error', reason: 'noFactors', onCancel }; } - if (snapshot.value === 'unavailable') { - return { status: 'unavailable' }; + if (state === 'failed') { + return { status: 'error', reason: 'generic', onCancel }; } - const { context } = snapshot; - const step = viewStep(snapshot.value, context.activeMethod); + const step = viewStep(state, activeMethod); + if (!step) { - return { status: 'unavailable' }; + return { status: 'loading', onCancel }; } - const activeMethod = context.activeMethod; // If we are currently on the alternative methods screen and preparing a factor, activeMethod will // have transitioned to the factor we are now preparing, so the one we want to hide is the old one - const excludeId = snapshot.value === 'methodPickerPreparing' ? context.frozenActiveMethodId : activeMethod?.id; + const excludeId = state === 'methodPickerPreparing' ? context.frozenActiveMethodId : activeMethod?.id; const methods = context.methods.filter(method => method.id !== excludeId); - const pendingMethodId = snapshot.value === 'methodPickerPreparing' ? activeMethod?.id : undefined; + const pendingMethodId = state === 'methodPickerPreparing' ? activeMethod?.id : undefined; - return { - status: 'ready', + const view: ReverificationViewProps = { step, direction: context.direction, value: context.inputValue, - onValueChange: value => send({ type: 'TYPE', value }), - errorMessage: context.errorMessage, - isPending: pendingStates.has(snapshot.value), + onValueChange: (value: string) => send({ type: 'TYPE', value }), + errorMessage: context.error ? (context.error.detail ?? m.unstable__errors__generic) : undefined, + isPending: settled || pendingStates.has(state), onSubmit: () => send({ type: 'SUBMIT' }), onShowMethods: () => send({ type: 'SHOW_METHODS' }), onShowHelp: () => send({ type: 'SHOW_HELP' }), @@ -470,7 +534,7 @@ export function useReverificationController(model: ReverificationModel): Reverif }, methods, pendingMethodId, - onSelectMethod: id => send({ type: 'SELECT_METHOD', id }), + onSelectMethod: (id: string) => send({ type: 'SELECT_METHOD', id }), otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, onResend: () => send({ type: 'RESEND' }), canResend, @@ -485,4 +549,6 @@ export function useReverificationController(model: ReverificationModel): Reverif ? Math.min(RESEND_COOLDOWN_MS / 1000, Math.max(0, Math.ceil((resendAvailableAt - now) / 1000))) : undefined, }; + + return state === 'retrying' ? { status: 'retrying', ...view } : { status: 'ready', onCancel, ...view }; } diff --git a/packages/mosaic/src/features/reverification/reverification.messages.ts b/packages/mosaic/src/features/reverification/reverification.messages.ts index 2b26f27a698..04e333632f0 100644 --- a/packages/mosaic/src/features/reverification/reverification.messages.ts +++ b/packages/mosaic/src/features/reverification/reverification.messages.ts @@ -21,6 +21,9 @@ export const reverificationMessages = { description: 'Enter the backup code you received when setting up two-step authentication', title: 'Enter a backup code', }, + loading: { + title: 'Verification required', + }, emailCode: { description: 'Enter the code sent to your email to continue', formTitle: 'Verification code', @@ -50,6 +53,17 @@ export const reverificationMessages = { resendButton: 'Didn’t receive a code? Resend', title: 'Verification required', }, + error: { + closeButton: 'Close', + generic: { + description: 'Please try again.', + title: 'Something went wrong', + }, + noFactors: { + description: 'Cannot proceed with verification. No suitable authentication factor is configured.', + title: 'Cannot verify your account', + }, + }, totpMfa: { description: 'Enter the code generated by your authenticator app to continue', formTitle: 'Verification code', diff --git a/packages/mosaic/src/features/reverification/reverification.model.tsx b/packages/mosaic/src/features/reverification/reverification.model.tsx index 76b76a73bd0..30d929aa297 100644 --- a/packages/mosaic/src/features/reverification/reverification.model.tsx +++ b/packages/mosaic/src/features/reverification/reverification.model.tsx @@ -1,4 +1,3 @@ -import { isClerkAPIResponseError } from '@clerk/shared/error'; import { useClerk, useSession } from '@clerk/shared/react'; import type { PreferredSignInStrategy, @@ -13,15 +12,14 @@ import { useMosaicSupportEmail } from '../../hooks/useMosaicSupportEmail'; import type { ReverificationMethod, ReverificationPreparableMethod, - ReverificationProps, ReverificationResult, ReverificationStage, + ReverificationState, } from './reverification.types'; import { pickStartingMethod } from './reverification.utils'; -export type ReverificationReadyModel = { - status: 'ready'; - isActive: boolean; +export type ReverificationActiveModel = { + status: 'active'; supportEmail: string; start: () => Promise; prepare: (method: ReverificationPreparableMethod) => Promise; @@ -30,15 +28,12 @@ export type ReverificationReadyModel = { cancel: () => void; }; -export type ReverificationModel = { status: 'loading'; isActive: boolean } | ReverificationReadyModel; - -function toError(error: unknown): Error { - if (isClerkAPIResponseError(error)) { - const first = error.errors[0]; - return new Error(first?.longMessage || first?.message || error.message); - } - return error instanceof Error ? error : new Error('Something went wrong. Please try again.'); -} +export type ReverificationModel = + | { status: 'inactive' } + | { status: 'loading'; cancel: () => void } + | ReverificationActiveModel + | { status: 'retrying' } + | { status: 'settled' }; function toMethod( factor: SessionVerificationFirstFactor | SessionVerificationSecondFactor, @@ -114,15 +109,28 @@ function toResult( }; } -export function useReverificationModel(props: ReverificationProps): ReverificationModel { +export function useReverificationModel(reverificationState: ReverificationState): ReverificationModel { const { session } = useSession(); const clerk = useClerk(); const environment = useMosaicEnvironment(); const supportEmail = useMosaicSupportEmail(); - const { isActive, cancel, complete, level } = props; + + if (reverificationState.phase === 'inactive') { + return { status: 'inactive' }; + } + + if (reverificationState.phase === 'retrying') { + return { status: 'retrying' }; + } + + if (reverificationState.phase === 'settled') { + return { status: 'settled' }; + } + + const { level, cancel, complete } = reverificationState; if (!session || !environment || supportEmail === undefined) { - return { status: 'loading', isActive }; + return { status: 'loading', cancel }; } const webAuthnSupported = isWebAuthnSupported(); @@ -132,85 +140,60 @@ export function useReverificationModel(props: ReverificationProps): Reverificati toResult(resource, preferredSignInStrategy, webAuthnSupported); return { - status: 'ready', - isActive, + status: 'active', supportEmail, - start: async () => { - try { - return handleResponse(await session.startVerification({ level: level ?? 'second_factor' })); - } catch (error) { - throw toError(error); - } - }, - cancel: () => { - cancel?.(); - }, + start: async () => handleResponse(await session.startVerification({ level: level ?? 'second_factor' })), + cancel, prepare: async method => { - try { - switch (method.strategy) { - case 'email_code': - await session.prepareFirstFactorVerification({ - strategy: 'email_code', - emailAddressId: method.emailAddressId, - }); - return; - case 'phone_code': - if (method.stage === 'second') { - await session.prepareSecondFactorVerification({ - strategy: 'phone_code', - phoneNumberId: method.phoneNumberId, - }); - return; - } - await session.prepareFirstFactorVerification({ + switch (method.strategy) { + case 'email_code': + await session.prepareFirstFactorVerification({ + strategy: 'email_code', + emailAddressId: method.emailAddressId, + }); + return; + case 'phone_code': + if (method.stage === 'second') { + await session.prepareSecondFactorVerification({ strategy: 'phone_code', phoneNumberId: method.phoneNumberId, }); return; - } - } catch (error) { - throw toError(error); + } + await session.prepareFirstFactorVerification({ + strategy: 'phone_code', + phoneNumberId: method.phoneNumberId, + }); + return; } }, attempt: async (method, value) => { - try { - switch (method.strategy) { - case 'password': - return handleResponse( - await session.attemptFirstFactorVerification({ strategy: 'password', password: value }), - ); - case 'email_code': + switch (method.strategy) { + case 'password': + return handleResponse( + await session.attemptFirstFactorVerification({ strategy: 'password', password: value }), + ); + case 'email_code': + return handleResponse(await session.attemptFirstFactorVerification({ strategy: 'email_code', code: value })); + case 'phone_code': + if (method.stage === 'second') { return handleResponse( - await session.attemptFirstFactorVerification({ strategy: 'email_code', code: value }), + await session.attemptSecondFactorVerification({ strategy: 'phone_code', code: value }), ); - case 'phone_code': - if (method.stage === 'second') { - return handleResponse( - await session.attemptSecondFactorVerification({ strategy: 'phone_code', code: value }), - ); - } - return handleResponse( - await session.attemptFirstFactorVerification({ strategy: 'phone_code', code: value }), - ); - case 'totp': - case 'backup_code': - return handleResponse( - await session.attemptSecondFactorVerification({ strategy: method.strategy, code: value }), - ); - case 'passkey': - return handleResponse(await session.verifyWithPasskey()); - } - } catch (error) { - throw toError(error); + } + return handleResponse(await session.attemptFirstFactorVerification({ strategy: 'phone_code', code: value })); + case 'totp': + case 'backup_code': + return handleResponse( + await session.attemptSecondFactorVerification({ strategy: method.strategy, code: value }), + ); + case 'passkey': + return handleResponse(await session.verifyWithPasskey()); } }, finish: async () => { - try { - await clerk.setActive({ session: session.id }); - complete?.(); - } catch (error) { - throw toError(error); - } + await clerk.setActive({ session: session.id }); + complete(); }, }; } diff --git a/packages/mosaic/src/features/reverification/reverification.styles.ts b/packages/mosaic/src/features/reverification/reverification.styles.ts new file mode 100644 index 00000000000..506678bc8bb --- /dev/null +++ b/packages/mosaic/src/features/reverification/reverification.styles.ts @@ -0,0 +1,23 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../../tokens.stylex'; + +const stackedFooter = '@container card (max-width: 20rem)' as const; +const half = `calc(50% - ${space['1']})`; + +export const styles = stylex.create({ + pending: { + paddingBlock: space['12'], + alignItems: 'center', + display: 'flex', + justifyContent: 'center', + width: '100%', + }, + pendingAction: { + flexBasis: { default: half, [stackedFooter]: 'auto' }, + flexGrow: { default: 0, [stackedFooter]: 1 }, + flexShrink: 0, + marginInlineStart: { default: 'auto', [stackedFooter]: 0 }, + width: { default: half, [stackedFooter]: '100%' }, + }, +}); diff --git a/packages/mosaic/src/features/reverification/reverification.tsx b/packages/mosaic/src/features/reverification/reverification.tsx index 5b277c9df62..5ca3edbee7f 100644 --- a/packages/mosaic/src/features/reverification/reverification.tsx +++ b/packages/mosaic/src/features/reverification/reverification.tsx @@ -1,21 +1,46 @@ -import { useReverificationController } from './reverification.controller'; +import { type ReverificationController, useReverificationController } from './reverification.controller'; import { useReverificationModel } from './reverification.model'; -import type { ReverificationProps } from './reverification.types'; -import { ReverificationView } from './reverification.view'; +import { ReverificationError, ReverificationPending, ReverificationView } from './reverification.view'; +import { + type ReverificationFetcher, + useReverificationWithState, + type UseReverificationWithStateResult, +} from './use-reverification-with-state'; -export function Reverification(props: ReverificationProps) { - const model = useReverificationModel(props); - const controller = useReverificationController(model); +export type UseReverificationFlowResult = readonly [ + UseReverificationWithStateResult[0], + ReverificationController, +]; - if (!props.isActive) { +export function useReverificationFlow( + fetcher: F, +): UseReverificationFlowResult { + const [wrappedFetcher, reverificationState, reset] = useReverificationWithState(fetcher); + const model = useReverificationModel(reverificationState); + const controller = useReverificationController(model, reset); + + return [wrappedFetcher, controller]; +} + +export type ReverificationProps = ReverificationController & { + onClose?: () => void; +}; + +export function Reverification({ onClose, ...controller }: ReverificationProps) { + if (controller.status === 'idle') { return null; } - if (controller.status !== 'ready') { - // TODO: Implement unavailable and loading states, could also live in the .view. - return null; + if (controller.status === 'loading') { + return ; + } else if (controller.status === 'error') { + return ( + + ); } - const { status: _status, ...viewProps } = controller; - return ; + return ; } diff --git a/packages/mosaic/src/features/reverification/reverification.types.ts b/packages/mosaic/src/features/reverification/reverification.types.ts index c31890a4fbe..e55cda4b1af 100644 --- a/packages/mosaic/src/features/reverification/reverification.types.ts +++ b/packages/mosaic/src/features/reverification/reverification.types.ts @@ -8,6 +8,8 @@ export type ReverificationStage = 'first' | 'second'; export type ReverificationOtpChannel = 'email' | 'phone' | 'totp'; +export type ReverificationErrorReason = 'noFactors' | 'generic'; + export type ReverificationStep = 'password' | 'passkey' | 'otp' | 'backup-code' | 'method-picker' | 'help'; export type ReverificationMethod = @@ -18,8 +20,7 @@ export type ReverificationMethod = export type ReverificationPreparableMethod = Extract; -export type ReverificationViewProps = { - step: ReverificationStep; +type ReverificationViewFields = { direction?: FlowDirection; value: string; onValueChange: (value: string) => void; @@ -39,9 +40,15 @@ export type ReverificationViewProps = { resendRemainingSeconds?: number; }; -export type ReverificationProps = - | { isActive: false; complete?: undefined; cancel?: undefined; level?: undefined } - | { isActive: true; complete: () => void; cancel: () => void; level: SessionVerificationLevel | undefined }; +export type ReverificationViewProps = ReverificationViewFields & { + step: ReverificationStep; +}; + +export type ReverificationState = + | { phase: 'inactive' } + | { phase: 'active'; complete: () => void; cancel: () => void; level: SessionVerificationLevel | undefined } + | { phase: 'retrying' } + | { phase: 'settled' }; export type ReverificationResult = { status: 'needs_first_factor' | 'needs_second_factor' | 'complete'; diff --git a/packages/mosaic/src/features/reverification/reverification.utils.ts b/packages/mosaic/src/features/reverification/reverification.utils.ts index 42f07b27c3e..ac089479bec 100644 --- a/packages/mosaic/src/features/reverification/reverification.utils.ts +++ b/packages/mosaic/src/features/reverification/reverification.utils.ts @@ -1,3 +1,4 @@ +import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; import type { PreferredSignInStrategy } from '@clerk/shared/types'; import type { @@ -7,6 +8,17 @@ import type { ReverificationStrategy, } from './reverification.types'; +export function errorDetail(error: unknown): string | undefined { + if (isClerkAPIResponseError(error)) { + const first = error.errors[0]; + return first?.longMessage || first?.message || error.message; + } + if (isClerkRuntimeError(error) && error.code !== 'network_error') { + return error.message; + } + return undefined; +} + export function otpChannelFor(strategy: ReverificationStrategy): ReverificationOtpChannel | undefined { if (strategy === 'email_code') { return 'email'; diff --git a/packages/mosaic/src/features/reverification/reverification.view.tsx b/packages/mosaic/src/features/reverification/reverification.view.tsx index f160482f1c2..08c7f457d7b 100644 --- a/packages/mosaic/src/features/reverification/reverification.view.tsx +++ b/packages/mosaic/src/features/reverification/reverification.view.tsx @@ -1,9 +1,17 @@ +import { Button } from '../../components/button'; import { Card } from '../../components/card'; import { Flow } from '../../components/flow'; +import { Spinner } from '../../components/spinner'; import type { IconName } from '../../icons/registry'; import type { MosaicMessages } from '../../localization'; import { fill, useMessages } from '../../localization'; -import type { ReverificationMethod, ReverificationOtpChannel, ReverificationViewProps } from './reverification.types'; +import { styles } from './reverification.styles'; +import type { + ReverificationErrorReason, + ReverificationMethod, + ReverificationOtpChannel, + ReverificationViewProps, +} from './reverification.types'; import { ReverificationBackupCode } from './steps/reverification-backup-code'; import { ReverificationHelp } from './steps/reverification-help'; import { ReverificationMethodPicker } from './steps/reverification-method-picker'; @@ -40,6 +48,62 @@ function methodLabel(method: ReverificationMethod, m: Messages): string { } } +// Lives here but is rendered by the wrapper parent +export function ReverificationError({ reason, onClose }: { reason: ReverificationErrorReason; onClose?: () => void }) { + const m = useMessages('reverification'); + const copy = m.error[reason]; + + return ( + <> + + {copy.title} + {copy.description} + + {onClose ? ( + + + + ) : null} + + ); +} + +// Lives here but is rendered by the wrapper parent +export function ReverificationPending() { + const m = useMessages('reverification'); + + return ( + <> + + {m.loading.title} + + + + + + + + + ); +} + function otpCopy(channel: ReverificationOtpChannel | undefined, m: Messages) { if (channel === 'email') { return m.emailCode; @@ -50,28 +114,27 @@ function otpCopy(channel: ReverificationOtpChannel | undefined, m: Messages) { return m.totpMfa; } -export function ReverificationView(props: ReverificationViewProps): JSX.Element { +export function ReverificationView({ + step, + direction, + value, + onValueChange, + errorMessage, + isPending, + onSubmit, + onShowMethods, + onShowHelp, + onBack, + onEmailSupport, + methods, + pendingMethodId, + onSelectMethod, + otpChannel, + onResend, + canResend, + resendRemainingSeconds, +}: ReverificationViewProps): JSX.Element { const m = useMessages('reverification'); - const { - step, - direction, - value, - onValueChange, - errorMessage, - isPending, - onSubmit, - onShowMethods, - onShowHelp, - onBack, - onEmailSupport, - methods, - pendingMethodId, - onSelectMethod, - otpChannel, - onResend, - canResend, - resendRemainingSeconds, - } = props; const otp = otpCopy(otpChannel, m); const actions = { @@ -91,121 +154,119 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element : undefined; return ( - - - {() => ( - <> - - - - - - - - - - { - onValueChange(code); - onSubmit(); - }} - onSubmit={onSubmit} - onCancel={hasAlternatives ? onShowMethods : undefined} - /> - - - - - - - - ({ - id: method.id, - label: methodLabel(method, m), - icon: methodIcon[method.strategy], - }))} - pendingMethodId={pendingMethodId} - onSelect={onSelectMethod} - onHelp={onShowHelp} - onBack={onBack} - /> - - - - - - - )} - - + + {() => ( + <> + + + + + + + + + + { + onValueChange(code); + onSubmit(); + }} + onSubmit={onSubmit} + onCancel={hasAlternatives ? onShowMethods : undefined} + /> + + + + + + + + ({ + id: method.id, + label: methodLabel(method, m), + icon: methodIcon[method.strategy], + }))} + pendingMethodId={pendingMethodId} + onSelect={onSelectMethod} + onHelp={onShowHelp} + onBack={onBack} + /> + + + + + + + )} + ); } diff --git a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts index 927cd262e46..ced1f18e220 100644 --- a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts +++ b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts @@ -1,69 +1,171 @@ -import { useReverification, useSession } from '@clerk/shared/react'; -import { useEffect, useRef, useState } from 'react'; +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { useReverification, useSafeLayoutEffect, useSession } from '@clerk/shared/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; -import type { ReverificationProps } from './reverification.types'; +import type { ReverificationState } from './reverification.types'; -type Fetcher = (...args: any[]) => Promise | undefined; +export type ReverificationFetcher = (...args: any[]) => Promise | undefined; -type UseReverificationOptions = NonNullable[1]>; - -export type UseReverificationWithStateOptions = Omit; - -export type UseReverificationWithStateResult = readonly [ +export type UseReverificationWithStateResult = readonly [ ReturnType>, - ReverificationProps, + ReverificationState, + () => void, ]; +type RuntimeOperation = + | { status: 'idle' } + | { status: 'requesting'; promise: Promise } + | { + status: 'active'; + promise: Promise; + sessionId: string | null; + complete: () => void; + cancel: () => void; + } + | { status: 'retrying'; promise: Promise } + | { status: 'cancelling'; promise: Promise }; + +type Runtime = { + operation: RuntimeOperation; + sessionId: string | null; +}; + +function releaseSettled(state: ReverificationState): ReverificationState { + return state.phase === 'settled' ? { phase: 'inactive' } : state; +} + +const REQUEST_ALREADY_IN_PROGRESS_CODE = 'request_already_in_progress'; + +function requestAlreadyInProgressError(): ClerkRuntimeError { + return new ClerkRuntimeError('A request is already in progress.', { + code: REQUEST_ALREADY_IN_PROGRESS_CODE, + }); +} + /** - * Same fetcher wrap as `useReverification`, with the need-reverification callback - * returned as `ReverificationProps` instead of `onNeedsReverification`. + * Wraps useReverification without the default UI. Returns [handler, state]: call handler + * to run the action, and render your own UI from state.phase ('inactive' | 'active' | + * 'retrying' | 'settled'). While 'active', state.complete and state.cancel end the challenge. + * + * A run that was interrupted by a reverification ends in 'settled', not 'inactive', so the + * UI that was showing can be held until the caller moves on. 'settled' ends when the handler + * is called again, or when the third return value, reset, is called. A run that was never + * interrupted stays 'inactive' throughout. + * + * The handler runs one call at a time. A call made while another is pending rejects with a + * ClerkRuntimeError with code 'request_already_in_progress'. */ -export function useReverificationWithState( +// The reason we need to enforce single-flight is that we need a direct link between +// a single invocation of the handler and a specific reverification. useReverification +// does not have well-defined behavior for concurrent calls, so we add the single-flight +// constraint for extra safeguards here. +// If we ever want to make this hook public API, we might want to reconsider the single-flight +// behavior by first fixing the useReverification hook. +export function useReverificationWithState( fetcher: F, - options?: UseReverificationWithStateOptions, ): UseReverificationWithStateResult { const { session } = useSession(); - const [reverificationState, setReverificationState] = useState({ isActive: false }); - const openedSessionId = useRef(null); - const activeCancelRef = useRef<(() => void) | undefined>(undefined); + // The return is observable and needs to be driven by React state + const [reverificationState, setReverificationState] = useState({ phase: 'inactive' }); + // State updates are not immediate and since parallel requests can resolve before observing + // those state changes, the internal state is driven by a ref + const runtimeRef = useRef({ + operation: { status: 'idle' }, + sessionId: session?.id ?? null, + }); + useSafeLayoutEffect(() => { + runtimeRef.current.sessionId = session?.id ?? null; + }); + + const completeChallenge = useCallback(() => { + const operation = runtimeRef.current.operation; + if (operation.status !== 'active') { + return; + } + runtimeRef.current.operation = { status: 'retrying', promise: operation.promise }; + setReverificationState({ phase: 'retrying' }); + operation.complete(); + }, []); + + const cancelChallenge = useCallback(() => { + const operation = runtimeRef.current.operation; + if (operation.status !== 'active') { + return; + } + runtimeRef.current.operation = { status: 'cancelling', promise: operation.promise }; + setReverificationState({ phase: 'settled' }); + operation.cancel(); + }, []); const wrapped = useReverification(fetcher, { - ...options, onNeedsReverification: ({ complete, cancel, level }) => { - // If another Reverification is already in progress for this specific - // wrapped action, cancel this new one immediately and keep the old one. - // This is an extra safeguard for something that likely never happens. - if (activeCancelRef.current) { + const operation = runtimeRef.current.operation; + if (operation.status !== 'requesting') { + // This can happen e.g. when the component unmounts mid-flight cancel(); return; } - openedSessionId.current = session?.id ?? null; - activeCancelRef.current = cancel; - - const settle = (callback: () => void) => { - if (activeCancelRef.current !== cancel) { - return; - } - activeCancelRef.current = undefined; - setReverificationState({ isActive: false }); - callback(); + runtimeRef.current.operation = { + status: 'active', + promise: operation.promise, + sessionId: runtimeRef.current.sessionId, + complete, + cancel, }; setReverificationState({ - isActive: true, + phase: 'active', level, - complete: () => settle(complete), - cancel: () => settle(cancel), + complete: completeChallenge, + cancel: cancelChallenge, }); }, }); - // Cancel if the session changes mid-flight - const { isActive, cancel } = reverificationState; + const singleFlight = useCallback( + (...args: Parameters) => { + // Only a single handler call is allowed to be in progress at the same time + if (runtimeRef.current.operation.status !== 'idle') { + return Promise.reject(requestAlreadyInProgressError()); + } + + setReverificationState(releaseSettled); + + const invocation = Promise.resolve().then(() => wrapped(...args)); + runtimeRef.current.operation = { status: 'requesting', promise: invocation }; + void invocation + .finally(() => { + const operation = runtimeRef.current.operation; + if (operation.status === 'idle' || operation.promise !== invocation) { + return; + } + runtimeRef.current.operation = { status: 'idle' }; + setReverificationState({ phase: operation.status === 'requesting' ? 'inactive' : 'settled' }); + }) + // The original error is meant to be handled outside, but .finally() creates + // a new promise that errors the same way, so we swallow that duplicate error silently + .catch(() => undefined); + + return invocation; + }, + [wrapped], + ) as ReturnType>; + + const reset = useCallback(() => { + if (runtimeRef.current.operation.status !== 'idle') { + return; + } + setReverificationState(releaseSettled); + }, []); + + const phase = reverificationState.phase; useEffect(() => { - if (!isActive) { - openedSessionId.current = null; + if (phase !== 'active') { + return; + } + const operation = runtimeRef.current.operation; + if (operation.status !== 'active') { return; } // Do not reset on the transitive state @@ -71,25 +173,30 @@ export function useReverificationWithState( return; } if (session === null) { - cancel?.(); + cancelChallenge(); return; } - if (openedSessionId.current === null) { - openedSessionId.current = session.id; + // If operation started before sessionId was known, we record it here + if (operation.sessionId === null) { + runtimeRef.current.operation = { ...operation, sessionId: session.id }; return; } - if (session.id !== openedSessionId.current) { - cancel?.(); + if (session.id !== operation.sessionId) { + cancelChallenge(); } - }, [isActive, cancel, session]); + }, [phase, session, cancelChallenge]); + // Cancel on unmount - Does not cancel ongoing retry after reverification has finished useEffect(() => { + const runtime = runtimeRef.current; return () => { - const cancel = activeCancelRef.current; - activeCancelRef.current = undefined; - cancel?.(); + const operation = runtime.operation; + runtime.operation = { status: 'idle' }; + if (operation.status === 'active') { + operation.cancel(); + } }; }, []); - return [wrapped, reverificationState]; + return [singleFlight, reverificationState, reset]; } diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.controller.test.ts deleted file mode 100644 index ac1c8ac584d..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.controller.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { createActor } from '../../../machine/createActor'; -import { - userProfileDeleteSectionMachine, - useUserProfileDeleteSectionController, -} from '../user-profile-delete-section/user-profile-delete-section.controller'; - -function start(deleteAccount: () => Promise) { - const actor = createActor(userProfileDeleteSectionMachine, { context: { deleteAccount } }).start(); - actor.send({ type: 'OPEN' }); - return actor; -} - -describe('userProfileDeleteSectionMachine', () => { - it('finishes in deleted when the account goes', async () => { - const actor = start(() => Promise.resolve()); - actor.send({ type: 'CONFIRM' }); - expect(actor.getSnapshot().value).toBe('deleting'); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('deleted')); - expect(actor.getSnapshot().status).toBe('done'); - }); - - it('returns to confirming with the reason when the delete fails', async () => { - const actor = start(() => Promise.reject(new Error('Your subscription is still active.'))); - actor.send({ type: 'CONFIRM' }); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('confirming')); - expect(actor.getSnapshot().context.errorMessage).toBe('Your subscription is still active.'); - }); - - it('falls back to generic copy when the rejection is not an Error', async () => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test - const actor = start(() => Promise.reject('nope')); - actor.send({ type: 'CONFIRM' }); - - await vi.waitFor(() => - expect(actor.getSnapshot().context.errorMessage).toBe('Something went wrong. Please try again.'), - ); - }); - - it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => Promise.reject(new Error('nope'))); - actor.send({ type: 'CONFIRM' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.errorMessage).toBe('nope')); - - actor.send({ type: 'CANCEL' }); - - expect(actor.getSnapshot().value).toBe('idle'); - expect(actor.getSnapshot().context.errorMessage).toBeUndefined(); - }); -}); - -describe('useUserProfileDeleteSectionController', () => { - it('holds the dialog open across confirming and deleting', async () => { - const { result } = renderHook(() => useUserProfileDeleteSectionController({ onDelete: () => Promise.resolve() })); - expect(result.current.isOpen).toBe(false); - - act(() => result.current.onOpenChange(true)); - expect(result.current.isOpen).toBe(true); - expect(result.current.isDeleting).toBe(false); - - act(() => result.current.onConfirm()); - expect(result.current.isOpen).toBe(true); - expect(result.current.isDeleting).toBe(true); - - await waitFor(() => expect(result.current.isOpen).toBe(false)); - }); - - it('cancels on close', () => { - const { result } = renderHook(() => useUserProfileDeleteSectionController({ onDelete: () => Promise.resolve() })); - act(() => result.current.onOpenChange(true)); - - act(() => result.current.onOpenChange(false)); - - expect(result.current.isOpen).toBe(false); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx new file mode 100644 index 00000000000..b55fda0f5cc --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx @@ -0,0 +1,165 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { createDeferredPromise } from '@clerk/shared/utils'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileDeleteSection } from '../user-profile-delete-section/user-profile-delete-section'; + +let isLoaded: boolean; +let deleteSelfEnabled: boolean; +let signedInSessions: { user?: { id: string } }[]; +let deleteUser: ReturnType; +let setActive: ReturnType; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ + isLoaded, + isSignedIn: isLoaded, + user: isLoaded ? { id: 'user_1', deleteSelfEnabled, delete: deleteUser } : undefined, + }), + useSession: () => ({ session: { id: 'sess_1' } }), + useClerk: () => ({ + setActive, + client: { signedInSessions }, + buildAfterSignOutUrl: () => '/signed-out', + buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left', + __internal_getOption: () => undefined, + }), + useReverification: (fetcher: () => Promise) => fetcher, + }; +}); + +function renderSection(fallback?: React.ReactNode) { + return render( + + + , + ); +} + +async function openDialog(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'Delete account' })); + return screen.getByRole('dialog'); +} + +describe('UserProfileDeleteSection', () => { + beforeEach(() => { + isLoaded = true; + deleteSelfEnabled = true; + signedInSessions = []; + deleteUser = vi.fn(() => Promise.resolve()); + setActive = vi.fn(() => Promise.resolve()); + }); + + it('renders the fallback until the user has loaded', () => { + isLoaded = false; + renderSection(

Loading account

); + + expect(screen.getByText('Loading account')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Danger zone' })).not.toBeInTheDocument(); + }); + + it('renders nothing when the instance does not allow deleting the account', () => { + deleteSelfEnabled = false; + const { container } = renderSection(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the danger zone and opens the dialog from the action', async () => { + const user = userEvent.setup(); + renderSection(); + + expect(screen.getByRole('heading', { name: 'Danger zone' })).toBeInTheDocument(); + expect(screen.getByText('Delete account', { selector: '.cl-section-label' })).toBeInTheDocument(); + expect(screen.getByText('Permanently delete this account and all its data. This cannot be undone.')).toHaveClass( + 'cl-section-description', + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + const dialog = await openDialog(user); + expect(within(dialog).getByText('Type “Delete account” below to continue')).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Delete account' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('deletes only after the phrase is typed, then signs out', async () => { + const user = userEvent.setup(); + renderSection(); + const dialog = await openDialog(user); + const confirm = within(dialog).getByRole('button', { name: 'Delete account' }); + + await user.type(within(dialog).getByRole('textbox'), 'Delete accoun'); + expect(confirm).toHaveAttribute('aria-disabled', 'true'); + await user.click(confirm); + expect(deleteUser).not.toHaveBeenCalled(); + + await user.type(within(dialog).getByRole('textbox'), 't'); + await user.click(confirm); + + await waitFor(() => expect(deleteUser).toHaveBeenCalledOnce()); + expect(setActive).toHaveBeenCalledWith({ session: null, redirectUrl: '/signed-out' }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('uses the single-session sign-out url when another account is still signed in', async () => { + signedInSessions = [{ user: { id: 'user_1' } }, { user: { id: 'user_2' } }]; + const user = userEvent.setup(); + renderSection(); + const dialog = await openDialog(user); + + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); + + await waitFor(() => expect(setActive).toHaveBeenCalledWith({ session: null, redirectUrl: '/one-session-left' })); + }); + + it('keeps the dialog up with a message when the delete fails', async () => { + deleteUser = vi.fn(() => Promise.reject(new Error('Your subscription is still active.'))); + const user = userEvent.setup(); + renderSection(); + const dialog = await openDialog(user); + + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); + + expect(await screen.findByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(setActive).not.toHaveBeenCalled(); + }); + + it('holds the dialog open while the delete is in flight', async () => { + const pending = createDeferredPromise(); + deleteUser = vi.fn(() => pending.promise); + const user = userEvent.setup(); + renderSection(); + const dialog = await openDialog(user); + + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); + + expect(within(dialog).getByRole('button', { name: 'Delete account' })).toHaveAttribute('aria-busy', 'true'); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + pending.resolve(); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('clears the phrase when the dialog is cancelled', async () => { + const user = userEvent.setup(); + renderSection(); + const dialog = await openDialog(user); + + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + const reopened = await openDialog(user); + expect(within(reopened).getByRole('textbox')).toHaveValue(''); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx deleted file mode 100644 index 9f70ce5dea6..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view'; - -function renderView(onDelete: () => Promise = vi.fn(() => Promise.resolve())) { - return render( - - - , - ); -} - -const openDialog = async (user: ReturnType) => { - await user.click(screen.getByRole('button', { name: 'Delete account' })); - return screen.getByRole('dialog'); -}; - -describe('UserProfileDeleteSectionView', () => { - it('renders the danger zone with the dialog closed', () => { - renderView(); - - expect(screen.getByRole('heading', { name: 'Danger zone' })).toBeInTheDocument(); - expect( - screen.getByText('Permanently delete this account and all its data. This cannot be undone.'), - ).toBeInTheDocument(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - it('deletes only after the phrase is typed back, then closes', async () => { - const onDelete = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); - renderView(onDelete); - - const dialog = await openDialog(user); - const confirm = within(dialog).getByRole('button', { name: 'Delete account' }); - - expect(within(dialog).getByText('Type “Delete account” below to continue')).toBeInTheDocument(); - expect(confirm).toHaveAttribute('aria-disabled', 'true'); - - await user.type(within(dialog).getByRole('textbox'), 'Delete account'); - await user.click(confirm); - - expect(onDelete).toHaveBeenCalledOnce(); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - - it('keeps the dialog up and explains a failed delete', async () => { - const user = userEvent.setup(); - renderView(() => Promise.reject(new Error('Your subscription is still active.'))); - - const dialog = await openDialog(user); - await user.type(within(dialog).getByRole('textbox'), 'Delete account'); - await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); - - expect(await screen.findByText('Your subscription is still active.')).toBeInTheDocument(); - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - it('clears the phrase when the dialog is cancelled', async () => { - const user = userEvent.setup(); - renderView(); - - const dialog = await openDialog(user); - await user.type(within(dialog).getByRole('textbox'), 'Delete account'); - await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - await openDialog(user); - expect(screen.getByRole('textbox')).toHaveValue(''); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 94f16ce7583..ef4ca5532de 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -3,10 +3,17 @@ import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view'; import type { UserProfileProfilePanelViewProps } from '../user-profile-profile-panel.view'; import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; +function DeleteAccount() { + const controller = useDestructiveController({ onDelete: () => Promise.resolve() }); + return ; +} + const props: UserProfileProfilePanelViewProps = { allowMultipleAccounts: true, name: 'Preston Booth', @@ -285,16 +292,12 @@ describe('UserProfileProfilePanelView', () => { expect(within(phoneSection).getByRole('button', { name: 'Add phone number' })).toBeInTheDocument(); }); - it('renders connected accounts and the danger zone when provided', async () => { - const onRemoveConnectedAccount = vi.fn(); - const onDeleteAccount = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); + it('renders connected accounts and the danger zone when provided', () => { renderView({ connectedAccounts: [ { id: 'google', provider: 'Google', identifier: 'test@google.com', iconUrl: 'https://example.com/google.svg' }, ], - onRemoveConnectedAccount, - onDeleteAccount, + deleteAccountSlot: , }); expect(screen.getByRole('heading', { level: 3, name: 'Connected accounts' })).toBeInTheDocument(); @@ -304,12 +307,6 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByText('Permanently delete this account and all its data. This cannot be undone.')).toHaveClass( 'cl-section-description', ); - await user.click(screen.getByRole('button', { name: 'Delete account' })); - const deleteDialog = screen.getByRole('dialog'); - await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account'); - await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' })); - - expect(onDeleteAccount).toHaveBeenCalledOnce(); }); it('renders connected provider and Web3 images inside icon frames', () => { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 052f8b22877..f7035959062 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -3,10 +3,17 @@ import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view'; import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; +function DeleteAccount() { + const controller = useDestructiveController({ onDelete: () => Promise.resolve() }); + return ; +} + const props: UserProfileSecurityPanelViewProps = { hasPassword: true, passkeys: [ @@ -58,7 +65,7 @@ function renderView(overrides: Partial = {}) describe('UserProfileSecurityPanelView', () => { it('composes authentication, active devices, and the danger zone', () => { - renderView({ onDeleteAccount: vi.fn(() => Promise.resolve()) }); + renderView({ deleteAccountSlot: }); expect(screen.getByRole('heading', { level: 2, name: 'Security' })).toBeInTheDocument(); const authentication = screen.getByRole('region', { name: 'Authentication' }); @@ -106,7 +113,6 @@ describe('UserProfileSecurityPanelView', () => { const onRemovePasskey = vi.fn(); const onSignOutDevice = vi.fn(); const onSignOutAllOtherDevices = vi.fn(); - const onDeleteAccount = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); renderView({ @@ -115,7 +121,6 @@ describe('UserProfileSecurityPanelView', () => { onRemovePasskey, onSignOutDevice, onSignOutAllOtherDevices, - onDeleteAccount, }); await user.click(screen.getByRole('button', { name: 'Add passkey' })); @@ -146,18 +151,11 @@ describe('UserProfileSecurityPanelView', () => { await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - // The danger zone confirms in a modal, so it goes last: nothing else is clickable while it is open. - await user.click(screen.getByRole('button', { name: 'Delete account' })); - const deleteDialog = screen.getByRole('dialog'); - await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account'); - await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' })); - expect(onAddPasskey).toHaveBeenCalledOnce(); expect(onRenamePasskey).toHaveBeenCalledWith('passkey_1', 'Work laptop'); expect(onRemovePasskey).toHaveBeenCalledWith('passkey_1'); expect(onSignOutDevice).toHaveBeenCalledWith('mobile'); expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce(); - expect(onDeleteAccount).toHaveBeenCalledOnce(); }); it('keeps supported empty authentication methods actionable', () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts deleted file mode 100644 index 1b96da8ca90..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { setup } from '../../../machine/setup'; -import { useMachine } from '../../../machine/useMachine'; - -export interface UserProfileDeleteSectionContext { - /** Deletes the account. Injected by the view from its `onDelete` prop. */ - deleteAccount: () => Promise; - /** Why the last attempt failed. */ - errorMessage: string | undefined; -} - -export type UserProfileDeleteSectionEvent = { type: 'OPEN' } | { type: 'CONFIRM' } | { type: 'CANCEL' }; - -const { createMachine, assign, fromPromise } = setup(); - -/** - * The delete-account flow. `deleting` is the state that decides the dialog's fate: the - * account is gone on success, so the machine finishes in `deleted` and never reopens, - * while a failure drops back to `confirming` with the reason to render. - * - * The typed confirmation phrase is not here. It is a half-formed string that only the - * `Destructive` block can use, so the block keeps it. - */ -export const userProfileDeleteSectionMachine = createMachine({ - id: 'deleteAccount', - initial: 'idle', - context: { - deleteAccount: async () => {}, - errorMessage: undefined, - }, - states: { - idle: { on: { OPEN: 'confirming' } }, - confirming: { - on: { - CONFIRM: 'deleting', - CANCEL: { target: 'idle', actions: assign(() => ({ errorMessage: undefined })) }, - }, - }, - deleting: { - invoke: fromPromise(context => context.deleteAccount(), { - onDone: 'deleted', - onError: { - target: 'confirming', - actions: assign((_, event) => ({ - errorMessage: - event.error instanceof Error ? event.error.message : 'Something went wrong. Please try again.', - })), - }, - }), - }, - deleted: { type: 'final' }, - }, -}); - -export interface UserProfileDeleteSectionControllerOptions { - /** - * Deletes the account. Resolve and the flow finishes; reject with an `Error` and it returns to - * the confirmation step with that message. - */ - onDelete: () => Promise; -} - -export interface UserProfileDeleteSectionController { - /** Whether the confirmation dialog is open */ - isOpen: boolean; - /** Opens or closes the confirmation dialog */ - onOpenChange: (open: boolean) => void; - /** Starts the delete */ - onConfirm: () => void; - /** Whether the delete is in progress */ - isDeleting: boolean; - /** Why the last attempt failed */ - errorMessage: string | undefined; -} - -/** - * Drives the delete-account flow and hands the view plain props. A machine backs it because the - * flow has an async step, an error path back to a previous step, and a terminal state that must - * never reopen. A simpler section is free to hold its state in `useState`; the view cannot tell. - */ -export function useUserProfileDeleteSectionController({ - onDelete, -}: UserProfileDeleteSectionControllerOptions): UserProfileDeleteSectionController { - const [snapshot, send] = useMachine(userProfileDeleteSectionMachine, { context: { deleteAccount: onDelete } }); - - return { - isOpen: snapshot.value === 'confirming' || snapshot.value === 'deleting', - onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), - onConfirm: () => send({ type: 'CONFIRM' }), - isDeleting: snapshot.value === 'deleting', - errorMessage: snapshot.context.errorMessage, - }; -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx new file mode 100644 index 00000000000..294daef41f4 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx @@ -0,0 +1,55 @@ +import { useClerk, useUser } from '@clerk/shared/react'; + +import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; +import { useReverificationFlow } from '../../reverification'; +import { UserProfileDeleteSectionView } from './user-profile-delete-section.view'; + +export type UserProfileDeleteSectionProps = { + fallback?: React.ReactNode; +}; + +export function UserProfileDeleteSection(props: UserProfileDeleteSectionProps) { + // -- Model -- + const clerk = useClerk(); + const { setActive, client } = clerk; + const { isLoaded, user } = useUser(); + + // Because this is wrapped in reverification, it's important that it doesn't catch + // reverification hints or cancellation errors + const deleteAccount = async () => { + // Should never happen, just an extra guard + if (!user?.delete || !user?.deleteSelfEnabled) { + return undefined; + } + + await user.delete(); + + const hasOtherSessions = client.signedInSessions.filter(s => s.user?.id !== user?.id).length > 0; + const redirectUrl = hasOtherSessions + ? clerk.buildAfterMultiSessionSingleSignOutUrl() + : clerk.buildAfterSignOutUrl(); + + return await setActive({ + session: null, + redirectUrl, + }); + }; + + // -- Controllers -- + const [deleteAccountWithReverification, reverificationState] = useReverificationFlow(deleteAccount); + const destructiveController = useDestructiveController({ + onDelete: deleteAccountWithReverification, + reverification: reverificationState, + }); + + // -- View -- + if (!isLoaded) { + return props.fallback ?? null; + } + + if (!user?.deleteSelfEnabled) { + return null; + } + + return ; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx index 0922220d9a8..d5ecee39f4a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx @@ -1,22 +1,11 @@ import { Destructive } from '../../../blocks/destructive'; +import type { DestructiveController } from '../../../blocks/destructive/destructive.controller'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { fill, useMessages } from '../../../localization'; -import { useUserProfileDeleteSectionController } from './user-profile-delete-section.controller'; -export interface UserProfileDeleteSectionViewProps { - /** - * Deletes the account. Resolve and the confirmation dialog closes; reject with an `Error` - * and it stays open with that message under the confirmation field. - */ - onDelete: () => Promise; -} - -export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) { +export function UserProfileDeleteSectionView(destructiveProps: DestructiveController) { const m = useMessages('userProfileDeleteSection'); - const { isOpen, onOpenChange, onConfirm, isDeleting, errorMessage } = useUserProfileDeleteSectionController({ - onDelete, - }); return ( @@ -33,8 +22,7 @@ export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSect diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index 05f8ccede3a..35924d464e2 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import { useRef } from 'react'; import { Panel } from '../../components/panel'; @@ -14,7 +14,6 @@ import type { UserProfileConnectionProvider, } from './user-profile-connected-accounts-section.view'; import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view'; -import { UserProfileDeleteSectionView } from './user-profile-delete-section/user-profile-delete-section.view'; import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view'; @@ -36,8 +35,8 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect onConnectWeb3Wallet?: (id: string) => void; onSetPrimaryWeb3Wallet?: (id: string) => void; onRemoveWeb3Wallet?: (id: string) => void | Promise; - /** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */ - onDeleteAccount?: () => Promise; + /** Danger zone. Omit to hide it. */ + deleteAccountSlot?: ReactNode; } export function UserProfileProfilePanelView({ @@ -80,7 +79,7 @@ export function UserProfileProfilePanelView({ onConnectWeb3Wallet, onSetPrimaryWeb3Wallet, onRemoveWeb3Wallet, - onDeleteAccount, + deleteAccountSlot, }: UserProfileProfilePanelViewProps): ReactElement { const pageTitle = useRef(null); return ( @@ -139,7 +138,7 @@ export function UserProfileProfilePanelView({ onRemove={onRemoveWeb3Wallet} onSetPrimary={onSetPrimaryWeb3Wallet} /> - {onDeleteAccount ? : null} + {deleteAccountSlot} ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 95dafc3cdfa..9f1fe8b237d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -8,7 +8,6 @@ import type { UserProfileDevice, } from './user-profile-active-devices-section.view'; import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view'; -import { UserProfileDeleteSectionView } from './user-profile-delete-section/user-profile-delete-section.view'; import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view'; import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; @@ -50,8 +49,8 @@ export interface UserProfileSecurityPanelViewProps onRegenerateBackupCodes?: () => void; onRemoveMfaMethod?: (id: string) => void | Promise; onSetDefaultMfaMethod?: (id: string) => void | Promise; - /** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */ - onDeleteAccount?: () => Promise; + /** Danger zone. Omit to hide it. */ + deleteAccountSlot?: ReactNode; } export function UserProfileSecurityPanelView({ @@ -75,7 +74,7 @@ export function UserProfileSecurityPanelView({ onSetDefaultMfaMethod, onSignOutDevice, onSignOutAllOtherDevices, - onDeleteAccount, + deleteAccountSlot, }: UserProfileSecurityPanelViewProps): ReactElement { const showPassword = hasPassword || Boolean(onSubmitPassword) || Boolean(managedBy); const showPasskeys = passkeys !== undefined && passkeysVisible; @@ -124,7 +123,7 @@ export function UserProfileSecurityPanelView({ onSignOutDevice={onSignOutDevice} /> ) : null} - {onDeleteAccount ? : null} + {deleteAccountSlot} ); diff --git a/packages/mosaic/vitest.setup.browser.mts b/packages/mosaic/vitest.setup.browser.mts index 0e23f419066..fb56c2f3de6 100644 --- a/packages/mosaic/vitest.setup.browser.mts +++ b/packages/mosaic/vitest.setup.browser.mts @@ -7,7 +7,33 @@ import { startWorker, takeUnhandledRequests, takeUnsettledHolds, worker } from ' expect.extend(matchers); +const NativeBroadcastChannel = window.BroadcastChannel; +const channelNamespace = crypto.randomUUID(); +const openChannels = new Set(); + +class IsolatedBroadcastChannel extends NativeBroadcastChannel { + private isClosed = false; + + constructor(name: string) { + super(`${channelNamespace}:${name}`); + openChannels.add(this); + } + + override postMessage(message: unknown) { + if (!this.isClosed) { + super.postMessage(message); + } + } + + override close() { + this.isClosed = true; + openChannels.delete(this); + super.close(); + } +} + beforeAll(async () => { + window.BroadcastChannel = IsolatedBroadcastChannel; await startWorker(); }); @@ -18,6 +44,14 @@ beforeEach(() => { afterEach(() => { cleanup(); __resetClerkQueryClientForTest(); + for (const channel of [...openChannels]) { + channel.close(); + } + for (const cookie of document.cookie.split('; ').filter(Boolean)) { + document.cookie = `${cookie.split('=')[0]}=; max-age=0; path=/`; + } + localStorage.clear(); + sessionStorage.clear(); worker.resetHandlers(); const unhandled = takeUnhandledRequests(); const unsettled = takeUnsettledHolds(); @@ -26,5 +60,6 @@ afterEach(() => { }); afterAll(() => { + window.BroadcastChannel = NativeBroadcastChannel; worker.stop(); }); diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index 2e206b399de..1ce15a7dd11 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -18,7 +18,14 @@ import { SidebarRail, } from '@/components/ui/sidebar'; -const flows = [{ title: 'Reverification', href: '/live/reverification' }]; +const flows = [ + { title: 'Delete account', href: '/live/delete-account' }, + { title: 'Reverification', href: '/live/reverification' }, +]; + +function flowIsActive(pathname: string, href: string) { + return pathname === href || pathname.startsWith(`${href}/`); +} export function LiveSidebar(props: ComponentProps) { const pathname = usePathname(); @@ -64,7 +71,7 @@ export function LiveSidebar(props: ComponentProps) { } > {flow.title} diff --git a/packages/swingset/src/app/(clerk)/live/delete-account/page.tsx b/packages/swingset/src/app/(clerk)/live/delete-account/page.tsx new file mode 100644 index 00000000000..11fb20a9017 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/delete-account/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { UserProfileDeleteSection } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function DeleteAccountLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Delete account

+

+ Deletes the signed-in account. The section stays hidden when the instance does not allow it. +

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to delete this account. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx index 47f4be03068..e2118ff35e6 100644 --- a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -1,14 +1,21 @@ 'use client'; +import { Destructive } from '@clerk/mosaic/blocks/destructive'; +import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import { Button } from '@clerk/mosaic/components/button'; -import { Reverification, useReverificationWithState } from '@clerk/mosaic/features/reverification'; +import { Card } from '@clerk/mosaic/components/card'; +import { Dialog } from '@clerk/mosaic/components/dialog'; +import { Flow, type FlowDirection } from '@clerk/mosaic/components/flow'; +import { Reverification, useReverificationFlow } from '@clerk/mosaic/features/reverification'; import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; -import { SignOutButton, useUser } from '@clerk/nextjs'; -import { isReverificationCancelledError } from '@clerk/shared/error'; +import { useUser } from '@clerk/nextjs'; +import { isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; import Link from 'next/link'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; -async function mockDelete() { +const SUCCESS_DELAY_MS = 3000; + +async function mockDelete(delaySuccess: boolean) { const response = await fetch('/api/live/mock-delete', { method: 'POST' }); const body = await response.json().catch(() => ({})); @@ -18,6 +25,11 @@ async function mockDelete() { if (!response.ok) { throw new Error(typeof body.error === 'string' ? body.error : `Mock delete failed (${response.status})`); } + if (delaySuccess) { + await new Promise(resolve => { + setTimeout(resolve, SUCCESS_DELAY_MS); + }); + } return body; } @@ -25,31 +37,36 @@ async function resetMockDelete() { await fetch('/api/live/mock-delete', { method: 'DELETE' }); } -function DeleteAccountHarness() { - const { user } = useUser(); +function CardHarness() { const [status, setStatus] = useState<'idle' | 'success' | 'cancelled' | 'error'>('idle'); const [message, setMessage] = useState(null); - const [deleteAccount, reverification] = useReverificationWithState(mockDelete); - - if (!user) { - return null; - } + const [requestPending, setRequestPending] = useState(false); + const [deleteAccount, reverification] = useReverificationFlow(() => mockDelete(false)); + const busy = requestPending; return (
-

Signed in as {user.primaryEmailAddress?.emailAddress ?? user.id}

- - -
- + {requestPending ? ( + + + + ) : null} {message ?

{message}

: null}
); } +type Presentation = 'retain' | 'replace'; +type OuterStep = 'confirm' | 'verify' | 'finalizing'; + +function DialogHarness() { + const [deleteAccount, reverification] = useReverificationFlow(() => mockDelete(true)); + const [open, setOpen] = useState(false); + const [presentation, setPresentation] = useState('retain'); + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [requestPending, setRequestPending] = useState(false); + const [direction, setDirection] = useState(1); + const [step, setStep] = useState('confirm'); + const runRef = useRef(false); + + const nextStep: OuterStep | null = !requestPending + ? null + : reverification.status === 'retrying' && presentation === 'replace' + ? 'finalizing' + : reverification.status === 'ready' || reverification.status === 'error' + ? 'verify' + : null; + + if (nextStep && nextStep !== step) { + setStep(nextStep); + setDirection(1); + } + + const continueDelete = () => { + if (runRef.current) { + return; + } + runRef.current = true; + setRequestPending(true); + setErrorMessage(null); + setSuccessMessage(null); + void (async () => { + try { + await resetMockDelete(); + await deleteAccount(); + setSuccessMessage('Mock delete completed. The account was not deleted.'); + setOpen(false); + } catch (error) { + if (isClerkRuntimeError(error) && error.code === 'request_already_in_progress') { + return; + } + await resetMockDelete(); + if (isReverificationCancelledError(error)) { + setErrorMessage(null); + setOpen(false); + return; + } + setErrorMessage(error instanceof Error ? error.message : 'Mock delete failed.'); + setStep('confirm'); + setDirection(-1); + setOpen(true); + } finally { + runRef.current = false; + setRequestPending(false); + } + })(); + }; + + return ( +
+
+ +
+ {successMessage ?

{successMessage}

: null} + { + if (next) { + return; + } + if (requestPending && step === 'confirm') { + return; + } + reverification.onCancel?.(); + setOpen(false); + if (reverification.status !== 'retrying') { + setErrorMessage(null); + void resetMockDelete(); + } + }} + > + + + + {() => ( + <> + + + Delete account? + + This mock asks for verification, then waits before a fake success. The account is not deleted. + + + +
+ While the mock delete finishes + + +
+ {errorMessage ?

{errorMessage}

: null} +
+ + + +
+ + + + + + Finalizing + Completing the mock delete. The account is not deleted. + + + + )} +
+
+
+
+
+ ); +} + +function DestructiveHarness() { + const [successMessage, setSuccessMessage] = useState(null); + const [deleteAccount, reverification] = useReverificationFlow(() => mockDelete(true)); + const destructive = useDestructiveController({ + onDelete: async () => { + setSuccessMessage(null); + await resetMockDelete(); + try { + await deleteAccount(); + setSuccessMessage('Mock delete completed. The account was not deleted.'); + } catch (error) { + await resetMockDelete(); + throw error; + } + }, + reverification, + }); + + return ( +
+
+ +
+ {successMessage ?

{successMessage}

: null} + +
+ ); +} + export default function ReverificationLivePage() { const { isLoaded, isSignedIn } = useUser(); + const [demoKey, setDemoKey] = useState(0); return ( -
-
+
+

Reverification

-

- Delete account hits a mock route that returns a reverification hint, then a fake success. The account is not - deleted. -

-
- {!isLoaded ?

Loading…

: null} - {isLoaded && !isSignedIn ? ( -

- +

+ Delete account hits a mock route that returns a reverification hint, then a fake success. The account is + not deleted. Reset clears the mock state. +

+ +
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to use the live harness. +

+ ) : null} +
+
+
+

Card

+

+ The default is to render Reverification inside its own card. +

+
+ {isLoaded && isSignedIn ? : null} +
+
+
+

Dialog / Flow

+

+ Reverification renders inside the dialog's card. The action retry can stay on the verification step, + or move to its own finalizing step. +

+
+ {isLoaded && isSignedIn ? : null} +
+
+
+

Destructive

+

+ Destructive keeps its dialog and card while Reverification replaces the confirmation step. +

+
+ {isLoaded && isSignedIn ? : null} +
); diff --git a/packages/swingset/src/app/api/live/mock-delete/route.ts b/packages/swingset/src/app/api/live/mock-delete/route.ts index fb0b9d7fd8c..293dbaa5bf2 100644 --- a/packages/swingset/src/app/api/live/mock-delete/route.ts +++ b/packages/swingset/src/app/api/live/mock-delete/route.ts @@ -6,7 +6,7 @@ const COOKIE = 'swingset-mock-delete'; export async function POST() { const store = await cookies(); - // First call: ask for step-up. After the card completes, the same fetcher is retried. + // First call asks for step-up. The same fetcher is retried after verification. if (store.get(COOKIE)?.value !== '1') { store.set(COOKIE, '1', { path: '/', maxAge: 120, sameSite: 'lax' }); return reverificationErrorResponse('strict'); diff --git a/packages/swingset/src/stories/destructive.mdx b/packages/swingset/src/stories/destructive.mdx index e2590a7464b..4dd9998b8ac 100644 --- a/packages/swingset/src/stories/destructive.mdx +++ b/packages/swingset/src/stories/destructive.mdx @@ -19,45 +19,48 @@ import * as Stories from './destructive.stories'; A confirmation for a destructive action that warrants making the user type. Deleting an account, deleting a project, revoking an API key. For actions that do not warrant typing, use [Confirmation](/blocks/confirmation). -The block holds the typed phrase and compares it to `confirmationValue`. In the controlled form, everything that decides what the dialog does next belongs to the caller. `open` closes it, `isDeleting` marks it busy, `errorMessage` explains a failure. +The block holds the typed phrase and compares it to `confirmationValue`. `useDestructiveController` owns the dialog for a single action. Resolve `onDelete` and the dialog closes. Reject it and the dialog stays open. Pass the same props yourself to drive it from `useState` or a machine, or mount one block with a handle when many rows share it. ```tsx import { Destructive } from '@clerk/mosaic/blocks/destructive'; +import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import { Button } from '@clerk/mosaic/components/button'; -const [open, setOpen] = useState(false); -const [isDeleting, setIsDeleting] = useState(false); - -const handleDelete = async () => { - setIsDeleting(true); - await deleteAccount(); - setIsDeleting(false); - setOpen(false); -}; +const destructive = useDestructiveController({ onDelete: deleteAccount }); Delete account} title='Delete account?' description='Are you sure you want to delete your account? All of your data will be permanently deleted.' fieldLabel='Type “Delete account” below to continue' confirmationValue='Delete account' actionLabel='Delete account' - onDelete={() => void handleDelete()} - isDeleting={isDeleting} />; ``` ## Failure -A failed attempt leaves the dialog up. Pass the sentence the user should read as `errorMessage`, and clear it when the next attempt starts. The field is marked invalid for as long as a message is set. +A rejection leaves the dialog up. The field is marked invalid for as long as the message is set. +## Reverification + +Wrap the action with `useReverificationFlow` and pass the result to the controller. The controller decides which step is showing, cancels the verification when the dialog closes, and blocks closing while the action is being retried. Confirmation and verification stay in the same dialog and card. + +```tsx +import { useReverificationFlow } from '@clerk/mosaic/features/reverification'; + +const [runDelete, reverification] = useReverificationFlow(deleteAccount); +const destructive = useDestructiveController({ onDelete: runDelete, reverification }); + +; +``` + ## One block, many rows A table of projects has a Delete in every row. Create a handle, mount the block once after the table, and open it from any row with the project it is about. The copy props take a function of that payload, and `onDelete` receives it. The block owns `open`, the pending state, and the error: a resolved promise closes it, a rejected one keeps it open showing why. @@ -89,25 +92,71 @@ Opened from a menu item, focus returns to that menu's trigger when the dialog cl storyModule={Stories} /> +## Controlled + +The same props can be passed directly, from `useState` or from a machine. `open` closes the dialog, `isDeleting` marks it busy, `errorMessage` explains a failure. + +```tsx +import { Destructive } from '@clerk/mosaic/blocks/destructive'; +import { Button } from '@clerk/mosaic/components/button'; + +const [open, setOpen] = useState(false); +const [isDeleting, setIsDeleting] = useState(false); + +const handleDelete = async () => { + setIsDeleting(true); + await deleteAccount(); + setIsDeleting(false); + setOpen(false); +}; + +Delete account} + title='Delete account?' + description='Are you sure you want to delete your account? All of your data will be permanently deleted.' + fieldLabel='Type “Delete account” below to continue' + confirmationValue='Delete account' + actionLabel='Delete account' + onDelete={() => void handleDelete()} + isDeleting={isDeleting} +/>; +``` + +A machine maps onto those same props: + +```tsx + send({ type: open ? 'OPEN' : 'CANCEL' })} + onDelete={() => send({ type: 'CONFIRM' })} + isDeleting={snapshot.value === 'deleting'} + errorMessage={snapshot.context.errorMessage} + {...copy} +/> +``` + ## Props Controlled: -| Prop | Type | Default | Description | -| ------------------- | ------------------------- | ------------ | -------------------------------------------------------------------------------- | -| `open` | `boolean` | — (required) | Whether the confirmation is showing. Controlled, the way any dialog is. | -| `onOpenChange` | `(open: boolean) => void` | — (required) | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | -| `trigger` | `ReactNode` | — | The button that asks to open the dialog. | -| `title` | `string` | — (required) | Names what is about to happen. | -| `description` | `ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | -| `fieldLabel` | `string` | — (required) | Labels the confirmation field. | -| `confirmationValue` | `string` | — (required) | The phrase the user has to type back. Also the field's placeholder. | -| `actionLabel` | `string` | — (required) | The confirming button's label. | -| `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | -| `finalFocus` | `DialogFocusTarget` | the trigger | Where focus returns on close, for a removal that unmounts the trigger. | -| `onDelete` | `() => void` | — (required) | Asks the caller to run the action, once the typed phrase matches. | -| `isDeleting` | `boolean` | `false` | Disables the field, renders the action pending, and ignores further submissions. | -| `errorMessage` | `string` | — | Marks the field invalid and renders under it. | +| Prop | Type | Default | Description | +| ------------------- | -------------------------- | ------------ | -------------------------------------------------------------------------------- | +| `open` | `boolean` | — (required) | Whether the confirmation is showing. Controlled, the way any dialog is. | +| `onOpenChange` | `(open: boolean) => void` | — (required) | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | +| `trigger` | `ReactNode` | — | The button that asks to open the dialog. | +| `title` | `string` | — (required) | Names what is about to happen. | +| `description` | `ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | +| `fieldLabel` | `string` | — (required) | Labels the confirmation field. | +| `confirmationValue` | `string` | — (required) | The phrase the user has to type back. Also the field's placeholder. | +| `actionLabel` | `string` | — (required) | The confirming button's label. | +| `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | +| `finalFocus` | `DialogFocusTarget` | the trigger | Where focus returns on close, for a removal that unmounts the trigger. | +| `onDelete` | `() => void` | — (required) | Asks the caller to run the action, once the typed phrase matches. | +| `isDeleting` | `boolean` | `false` | Disables the field, renders the action pending, and ignores further submissions. | +| `errorMessage` | `string` | — | Marks the field invalid and renders under it. | +| `reverification` | `ReverificationController` | — | Renders reverification in the same dialog after the action requests it. | With a handle: @@ -122,18 +171,3 @@ With a handle: | `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | | `finalFocus` | `DialogFocusTarget` | the trigger | Where focus returns on close, for a removal that unmounts the trigger. | | `onDelete` | `(payload: Payload) => Promise \| void` | — (required) | Runs the action. Resolve to close; reject with an `Error` to keep it open showing why. | - -## Driving it from a machine - -A section that wires the block to a state machine maps the machine's state onto the same props: - -```tsx - send({ type: open ? 'OPEN' : 'CANCEL' })} - onDelete={() => send({ type: 'CONFIRM' })} - isDeleting={snapshot.value === 'deleting'} - errorMessage={snapshot.context.errorMessage} - {...copy} -/> -``` diff --git a/packages/swingset/src/stories/destructive.stories.tsx b/packages/swingset/src/stories/destructive.stories.tsx index 168a8221f82..df33aecc1ae 100644 --- a/packages/swingset/src/stories/destructive.stories.tsx +++ b/packages/swingset/src/stories/destructive.stories.tsx @@ -1,4 +1,5 @@ import { Destructive } from '@clerk/mosaic/blocks/destructive'; +import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import { Button } from '@clerk/mosaic/components/button'; import { Menu } from '@clerk/mosaic/components/menu'; import React from 'react'; @@ -20,86 +21,49 @@ export const meta: StoryMeta = { // state, so both stories wait before they settle. const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -const trigger = ( - -); - -/** - * The block holds the typed phrase and compares it to `confirmationValue`. Everything that - * decides what the dialog does next stays with the caller: `open` closes it, `isDeleting` - * marks it busy, `errorMessage` explains a failure. - */ -export function Default() { - const [open, setOpen] = React.useState(false); - const [isDeleting, setIsDeleting] = React.useState(false); - - const handleDelete = async () => { - setIsDeleting(true); - await settleAfter(2000); - setIsDeleting(false); - setOpen(false); - }; +const copy = { + trigger: ( + + ), + title: 'Delete account?', + description: 'Are you sure you want to delete your account? All of your data will be permanently deleted.', + fieldLabel: 'Type “Delete account” below to continue', + confirmationValue: 'Delete account', + actionLabel: 'Delete account', +}; +function Example({ onDelete }: { onDelete: () => Promise }) { + const controller = useDestructiveController({ onDelete }); return ( void handleDelete()} - isDeleting={isDeleting} + {...copy} + {...controller} /> ); } -/** - * A failed attempt leaves the dialog up. Pass the sentence the user should read as - * `errorMessage`, and clear it when the next attempt starts. - */ -export function WithError() { - const [open, setOpen] = React.useState(false); - const [isDeleting, setIsDeleting] = React.useState(false); - const [errorMessage, setErrorMessage] = React.useState(undefined); - - const handleDelete = async () => { - setErrorMessage(undefined); - setIsDeleting(true); - await settleAfter(2000); - setIsDeleting(false); - setErrorMessage('Your subscription is still active. Cancel it before you delete your account.'); - }; - - // The error belongs to the caller, so the caller drops it. Without this a reopened dialog - // still shows why the last attempt failed. - const handleOpenChange = (next: boolean) => { - setOpen(next); - if (!next) { - setErrorMessage(undefined); - } - }; +export function Default() { + return ( + { + await settleAfter(2000); + }} + /> + ); +} +export function WithError() { return ( - void handleDelete()} - isDeleting={isDeleting} - errorMessage={errorMessage} + { + await settleAfter(2000); + throw new Error('Your subscription is still active. Cancel it before you delete your account.'); + }} /> ); } diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.tsx similarity index 93% rename from packages/swingset/src/stories/fixtures/user-profile.ts rename to packages/swingset/src/stories/fixtures/user-profile.tsx index dc9375177fd..385c6b61857 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -1,8 +1,10 @@ +import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import type { UserProfileViewProps } from '@clerk/mosaic/features/user-profile/user-profile.view'; import type { UserProfilePaymentMethod, UserProfileSubscription, } from '@clerk/mosaic/features/user-profile/user-profile-billing-panel.view'; +import { UserProfileDeleteSectionView } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.view'; import type { UserProfileEmail, UserProfilePhone, @@ -25,6 +27,11 @@ import { useUserProfileMfaExample } from './user-profile-mfa-example'; import { usePasskeysFixture } from './user-profile-passkeys'; import { useWeb3WalletsFixture } from './user-profile-web3-wallets'; +export function UserProfileDeleteAccountPreview() { + const controller = useDestructiveController({ onDelete: () => Promise.resolve() }); + return ; +} + export interface UserProfileFixtureOptions { /** Replaces the default OTP flow, e.g. for a custom dialog example. */ onAddEmail?: () => void; @@ -126,7 +133,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions ...createUserProfileAddPhoneFixture({ onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), }), - onDeleteAccount: () => Promise.resolve(), + deleteAccountSlot: , onManageEmail: () => undefined, onManagePhone: () => undefined, onProfilePictureChange: showFile, @@ -148,7 +155,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions ...mfa.security, devices: activeDevices.devices, onAddPasskey: passkeys.onAdd, - onDeleteAccount: () => Promise.resolve(), + deleteAccountSlot: , onRemovePasskey: passkeys.onRemove, onSignOutAllOtherDevices: activeDevices.onSignOutAllOtherDevices, onSignOutDevice: activeDevices.onSignOutDevice, diff --git a/packages/swingset/src/stories/reverification.mdx b/packages/swingset/src/stories/reverification.mdx index 0fa8f8eec3c..b1a9c4bbac1 100644 --- a/packages/swingset/src/stories/reverification.mdx +++ b/packages/swingset/src/stories/reverification.mdx @@ -17,23 +17,87 @@ The example pauses briefly in each pending state. Enter `error` in password or b ## Usage -The feature wrapper reads Clerk state and drives `ReverificationView`. Isolated panel stories below are for visual development only. +The feature wrapper reads Clerk state and drives the reverification flow. It renders nothing while `status` is `idle`. While a challenge is still preparing, the parent card shows a verification header, a centered spinner, and a disabled continue button, then shows the factor without a step transition. Keep the same card around the component for a ready factor, a pending retry, or the error state. Isolated panel stories below are for visual development only. ```tsx -import { Reverification } from '@clerk/mosaic/features/reverification'; +import { Card } from '@clerk/mosaic/components/card'; +import { Reverification, useReverificationFlow } from '@clerk/mosaic/features/reverification'; +const [runAction, reverification] = useReverificationFlow(action); + + + +; +``` + +## Composing with a parent flow + +Reverification is usually one step inside a larger flow, like a confirmation dialog that deletes something. The parent owns the action and decides what is on screen. Reverification only reports whether it has something to show. + +### Reading `visible` + +`reverification.visible` is true when reverification has a card of its own: a factor, a pending retry, or an error. It is false when idle and while the pending card is showing before a factor is known. A parent that wants to handle pending itself, for example by keeping its own spinner up, can switch to the verification step on `visible` and never render the pending card. + +A parent should decide its layout from its own state and `visible`, not from `status`. `status` describes the card, and it changes while the action is still running. + +```tsx +const step = isRunning && reverification.visible ? 'verify' : 'confirm'; +``` + +### When the run ends + +The reverification card does not disappear when the action settles. If the user was shown a card during the run, the run ends in a settled state, whether it succeeded, failed, or was cancelled. The controller keeps returning the last card, inert: it is pending and has no `onCancel`. `visible` stays true unless the last thing shown was the pending card. + +This lets the parent finish its own work after the action returns, such as refetching or closing a dialog, without the screen falling back to an earlier step in between. The parent leaves the card by unmounting `` or changing its own step when it is done. + +A run that never asked for reverification never settles in this way. `status` stays `idle`, and `visible` stays false for the whole run. + +The settled card is released by whichever happens first. + +- The action is run again. +- `reverification.reset()` is called. + +Call `reset()` when a parent leaves the flow without running the action again and wants a clean state, so the next render does not show a stale card. + +### Cancelling + +`reverification.onCancel` is a request. It is undefined when the user cannot cancel, which is the case while the action is being retried and once the run has settled. Calling it rejects the action with a `reverification_cancelled` error and settles the card. + +The parent decides what cancelling means for its own flow. Pass `onClose` to `` to run that instead of `onCancel` when the user presses the Close button on the error card. The parent is then responsible for calling `onCancel` itself, as `Destructive` does when its dialog closes. This lets the parent close or leave the step at the moment of the click, instead of waiting for its own action to unwind. + +```tsx {}} - cancel={() => {}} - level='first_factor' -/>; + {...reverification} + onClose={() => closeDialog()} +/> ``` +### Outcomes + +The promise returned by the wrapped action is the outcome. It resolves when the action succeeds, rejects with `reverification_cancelled` when the user cancels, and rejects with the action's own error otherwise. Calling the action while another call is pending rejects with `request_already_in_progress`. + ## Panels These examples isolate the presentational views and their visual states. Product code should normally render the `Reverification` block shown above. +### Pending + +This state is used while reverification is still loading/starting and we don't know the factor yet, it can be avoided entirely by not rendering `` until `reverification.visible` is true. + + + +### Error + +Most errors are shown inline on the factor page, but these states are for catastrophic errors outside of that. + +#### No factors available + + + +#### Something went wrong + + + ### Password diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx index 83a8067f4c6..0581731cb89 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -2,7 +2,11 @@ import { Button } from '@clerk/mosaic/components/button'; import { Card } from '@clerk/mosaic/components/card'; import type { ReverificationMethod, ReverificationStep } from '@clerk/mosaic/features/reverification'; import { otpChannelFor } from '@clerk/mosaic/features/reverification/reverification.utils'; -import { ReverificationView } from '@clerk/mosaic/features/reverification/reverification.view'; +import { + ReverificationError, + ReverificationPending, + ReverificationView, +} from '@clerk/mosaic/features/reverification/reverification.view'; import { ReverificationBackupCode } from '@clerk/mosaic/features/reverification/steps/reverification-backup-code'; import { ReverificationHelp } from '@clerk/mosaic/features/reverification/steps/reverification-help'; import { ReverificationMethodPicker } from '@clerk/mosaic/features/reverification/steps/reverification-method-picker'; @@ -201,32 +205,34 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element return ( <> - navigate('method-picker', 1)} - onResend={onResend} - canResend={canResend} - onShowHelp={() => navigate('help', 1)} - onBack={() => { - if (step === 'help') { - navigate('method-picker', -1); - return; - } - if (step === 'method-picker') { - navigate(stepFor(methodId), -1); - } - }} - onEmailSupport={() => setSupportRequested(true)} - methods={allMethods.filter(method => method.id !== methodId)} - onSelectMethod={id => void selectMethod(id)} - otpChannel={activeMethod ? otpChannelFor(activeMethod.strategy) : undefined} - /> + + navigate('method-picker', 1)} + onResend={onResend} + canResend={canResend} + onShowHelp={() => navigate('help', 1)} + onBack={() => { + if (step === 'help') { + navigate('method-picker', -1); + return; + } + if (step === 'method-picker') { + navigate(stepFor(methodId), -1); + } + }} + onEmailSupport={() => setSupportRequested(true)} + methods={allMethods.filter(method => method.id !== methodId)} + onSelectMethod={id => void selectMethod(id)} + otpChannel={activeMethod ? otpChannelFor(activeMethod.strategy) : undefined} + /> + {supportRequested ?

Email support requested.

: null} ); @@ -261,6 +267,36 @@ export function Default(): JSX.Element { ); } +export function Pending(): JSX.Element { + return ( + + + + ); +} + +export function ErrorNoFactors(): JSX.Element { + return ( + + undefined} + /> + + ); +} + +export function ErrorGeneric(): JSX.Element { + return ( + + undefined} + /> + + ); +} + function PasswordPanel({ isPending = false, errorMessage, diff --git a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx index 9be6612ca04..c1ea6ffeb43 100644 --- a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx @@ -1,3 +1,4 @@ +import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import { UserProfileDeleteSectionView } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.view'; import { useState } from 'react'; @@ -11,13 +12,18 @@ export const meta: StoryMeta = { title: 'UserProfileDeleteSection', label: 'Danger zone', navigation: { category: 'Sections' }, - source: 'packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx', + source: 'packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx', }; // A real delete is a network round trip. Without one the button never renders its pending // state, so both stories wait before they settle. const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +function DeleteSectionHarness({ onDelete }: { onDelete: () => Promise }) { + const controller = useDestructiveController({ onDelete }); + return ; +} + export function Default() { const [runId, setRunId] = useState(0); @@ -29,7 +35,7 @@ export function Default() { }; return ( - @@ -38,7 +44,7 @@ export function Default() { export function WithError() { return ( - { await settleAfter(2000); throw new Error('Your subscription is still active. Cancel it before you delete your account.'); diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index c0fb9ce4bf4..aabb012c536 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -8,6 +8,7 @@ import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; +import { UserProfileDeleteAccountPreview } from './fixtures/user-profile'; import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useConnectedAccountsFixture } from './fixtures/user-profile-connected-accounts'; @@ -64,7 +65,7 @@ export function Default(_args: Record) { onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), })} onConnectAccount={connections.onConnect} - onDeleteAccount={() => Promise.resolve()} + deleteAccountSlot={} onManageEmail={() => undefined} onManagePhone={() => undefined} onProfilePictureChange={showFile} diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index 8e9f82f6742..a422f078f5d 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -269,10 +269,8 @@ export function UserButton(props: UserButtonProps = {}) { prop. There is no model: the Clerk call arrives from whoever renders it. ```tsx -export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) { - const { isOpen, onOpenChange, onConfirm, isDeleting, errorMessage } = useUserProfileDeleteSectionController({ - onDelete, - }); +export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { + const controller = useUserProfileEditUsernameController({ username, onSubmit }); // …render… } ```