From 243c8a1dee205e8d8ff2c2676cb23509d7f0a636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 1 Sep 2026 15:17:20 +0200 Subject: [PATCH 01/33] Add reverification hook, model and controller --- .changeset/reverification-feature-stack.md | 2 + .../reverification.controller.test.tsx | 360 +++++++++++++++ .../__tests__/reverification.model.test.tsx | 249 ++++++++++ .../__tests__/reverification.test.tsx | 60 +++ .../use-reverification-with-state.test.tsx | 139 ++++++ .../mosaic/features/reverification/index.ts | 11 + .../reverification.controller.ts | 430 ++++++++++++++++++ .../reverification/reverification.model.tsx | 190 ++++++++ .../reverification/reverification.tsx | 18 + .../reverification/reverification.types.ts | 48 ++ .../reverification/reverification.utils.ts | 70 +++ .../use-reverification-with-state.ts | 73 +++ 12 files changed, 1650 insertions(+) create mode 100644 .changeset/reverification-feature-stack.md create mode 100644 packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx create mode 100644 packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx create mode 100644 packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx create mode 100644 packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx create mode 100644 packages/ui/src/mosaic/features/reverification/index.ts create mode 100644 packages/ui/src/mosaic/features/reverification/reverification.controller.ts create mode 100644 packages/ui/src/mosaic/features/reverification/reverification.model.tsx create mode 100644 packages/ui/src/mosaic/features/reverification/reverification.tsx create mode 100644 packages/ui/src/mosaic/features/reverification/reverification.types.ts create mode 100644 packages/ui/src/mosaic/features/reverification/reverification.utils.ts create mode 100644 packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts diff --git a/.changeset/reverification-feature-stack.md b/.changeset/reverification-feature-stack.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/reverification-feature-stack.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx new file mode 100644 index 00000000000..1d1be5cfe89 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -0,0 +1,360 @@ +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 { + reverificationMachine, + useReverificationController, + type ReverificationDeps, +} from '../reverification.controller'; +import type { ReverificationModel, ReverificationReadyModel } from '../reverification.model'; +import type { ReverificationMethod, ReverificationResult } from '../reverification.types'; + +const password: ReverificationMethod = { id: 'password', strategy: 'password' }; +const email: ReverificationMethod = { + id: 'email_code:idn_1', + strategy: 'email_code', + identifier: 'a***@ex.com', + emailAddressId: 'idn_1', +}; +const totp: ReverificationMethod = { id: 'totp', 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' })), + verifyPasskey: 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' })), + verifyPasskey: 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('preparing'); + await tick(); + expect(prepare).toHaveBeenCalledOnce(); + expect(actor.getSnapshot().value).toBe('verifying'); + expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('email_code'); + + 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 abort is requested 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: 'ABORT' }); + 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('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')); + }); +}); + +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 { result } = renderHook(() => + useReverificationController(readyModel({ start: vi.fn(async () => Promise.reject(new Error('no session'))) })), + ); + await waitFor(() => expect(result.current.status).toBe('unavailable')); + }); + + it('is unavailable when start returns no methods', async () => { + const { result } = renderHook(() => + useReverificationController( + readyModel({ + start: vi.fn(async () => firstFactorResult({ methods: [], startingMethod: null })), + }), + ), + ); + await waitFor(() => expect(result.current.status).toBe('unavailable')); + }); + + it('omits 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).toBeUndefined(); + expect(result.current.step).toBe('password'); + }); + + 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(() => { + if (result.current.status === 'ready') { + expect(result.current.isPending).toBe(true); + } + }); + + act(() => { + attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); + }); + }); + + 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('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); + }); +}); diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx new file mode 100644 index 00000000000..187af8f5ad1 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx @@ -0,0 +1,249 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { PreferredSignInStrategy, SessionVerificationResource } from '@clerk/shared/types'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useReverificationModel, type ReverificationModel, type ReverificationReadyModel } 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; +let webAuthnSupported: boolean; +let setActive: ReturnType; + +function environment() { + return environmentHydrated + ? { displayConfig: { preferredSignInStrategy, supportEmail } } + : 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('@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 active when props are active', () => { + const { result } = renderHook(() => useReverificationModel(activeProps())); + expect(result.current.isActive).toBe(true); + }); + + it('is inactive when props are idle', () => { + const { result } = renderHook(() => useReverificationModel({ isActive: false })); + expect(result.current.isActive).toBe(false); + }); + + 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', + 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?.strategy).toBe('totp'); + }); + + 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', + strategy: 'email_code', + identifier: 'a***@ex.com', + emailAddressId: 'idn_1', + }, + 'needs_first_factor', + ); + expect(session?.prepareFirstFactorVerification).toHaveBeenCalledWith({ + strategy: 'email_code', + emailAddressId: 'idn_1', + }); + + await ready(result.current).attempt({ id: 'password', strategy: 'password' }, 'secret', 'needs_first_factor'); + expect(session?.attemptFirstFactorVerification).toHaveBeenCalledWith({ + strategy: 'password', + password: 'secret', + }); + }); + + 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', strategy: 'phone_code', phoneNumberId: 'pn_1' }, + 'needs_second_factor', + ); + expect(session?.prepareSecondFactorVerification).toHaveBeenCalledWith({ + strategy: 'phone_code', + phoneNumberId: 'pn_1', + }); + + await ready(result.current).attempt({ id: 'totp', strategy: 'totp' }, '123456', 'needs_second_factor'); + expect(session?.attemptSecondFactorVerification).toHaveBeenCalledWith({ strategy: 'totp', code: '123456' }); + }); + + 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', strategy: 'password' }, 'bad', 'needs_first_factor'), + ).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)); + await ready(result.current).start(); + await ready(result.current).finish(); + expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' }); + expect(props.complete).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx new file mode 100644 index 00000000000..1d99ff6b2ea --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx @@ -0,0 +1,60 @@ +import { render } 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'; + +const model: ReverificationModel = { + status: 'ready', + isActive: true, + supportEmail: '', + start: vi.fn(), + prepare: vi.fn(), + attempt: vi.fn(), + verifyPasskey: 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, +})); + +describe('Reverification', () => { + it('renders nothing when reverification is not active', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing while the controller is loading, unavailable, or ready', () => { + controller = { status: 'loading' }; + const active = { + isActive: true as const, + complete: vi.fn(), + cancel: vi.fn(), + level: 'first_factor' as const, + }; + + const loading = render(); + expect(loading.container).toBeEmptyDOMElement(); + + controller = { status: 'unavailable' }; + const unavailable = render(); + expect(unavailable.container).toBeEmptyDOMElement(); + + controller = { status: 'ready' } as ReverificationController; + const ready = render(); + expect(ready.container).toBeEmptyDOMElement(); + + controller = { status: 'idle' }; + }); +}); diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx new file mode 100644 index 00000000000..45f6d14c861 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -0,0 +1,139 @@ +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 { useReverificationWithState } from '../use-reverification-with-state'; + +type NeedsReverificationParameters = { + complete: () => void; + cancel: () => void; + level: SessionVerificationLevel | undefined; +}; + +let capturedOnNeeds: ((params: NeedsReverificationParameters) => void) | undefined; +let session: { id: string } | null | undefined = { id: 'sess_1' }; +const wrapped = vi.fn(); + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useSession: () => ({ session }), + useReverification: (_fetcher: unknown, options?: { onNeedsReverification?: (params: NeedsReverificationParameters) => void }) => { + capturedOnNeeds = options?.onNeedsReverification; + return wrapped; + }, + }; +}); + +const fetcher = async (id: string) => id; + +describe('useReverificationWithState', () => { + beforeEach(() => { + capturedOnNeeds = undefined; + session = { id: 'sess_1' }; + wrapped.mockReset(); + }); + + it('returns the enhanced fetcher and is idle until reverification is needed', () => { + 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(); + }); + + it('surfaces complete, cancel, and level when useReverification needs reverification', () => { + const { result } = renderHook(() => useReverificationWithState(fetcher)); + const complete = vi.fn(); + const cancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete, cancel, level: 'first_factor' }); + }); + + 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'); + + act(() => { + state.complete(); + }); + + expect(complete).toHaveBeenCalledOnce(); + expect(result.current[1].isActive).toBe(false); + }); + + it('returns to idle after cancel and calls the state cancel', () => { + const { result } = renderHook(() => useReverificationWithState(fetcher)); + const complete = vi.fn(); + const cancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete, cancel, level: undefined }); + }); + + act(() => { + const [, state] = result.current; + if (state.isActive) { + state.cancel(); + } + }); + + expect(cancel).toHaveBeenCalledOnce(); + expect(complete).not.toHaveBeenCalled(); + expect(result.current[1].isActive).toBe(false); + }); + + it('cancels when the session changes after reverification opens', async () => { + const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); + const cancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + }); + expect(result.current[1].isActive).toBe(true); + + session = { id: 'sess_2' }; + rerender(); + await waitFor(() => expect(cancel).toHaveBeenCalledOnce()); + expect(result.current[1].isActive).toBe(false); + }); + + it('does not cancel when the session is briefly unloaded', async () => { + const { rerender } = renderHook(() => useReverificationWithState(fetcher)); + const cancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + }); + + const previous = session; + session = undefined; + rerender(); + session = previous; + rerender(); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('cancels when the session is signed out', async () => { + const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); + const cancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + }); + + session = null; + rerender(); + await waitFor(() => expect(cancel).toHaveBeenCalledOnce()); + expect(result.current[1].isActive).toBe(false); + }); +}); diff --git a/packages/ui/src/mosaic/features/reverification/index.ts b/packages/ui/src/mosaic/features/reverification/index.ts new file mode 100644 index 00000000000..fbb031b5636 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/index.ts @@ -0,0 +1,11 @@ +export { Reverification } from './reverification'; +export type { + ReverificationMethod, + ReverificationOtpChannel, + ReverificationProps, + ReverificationStep, + ReverificationStrategy, + ReverificationViewProps, +} from './reverification.types'; +export type { UseReverificationWithStateOptions, UseReverificationWithStateResult } from './use-reverification-with-state'; +export { useReverificationWithState } from './use-reverification-with-state'; diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts new file mode 100644 index 00000000000..af7c682e554 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -0,0 +1,430 @@ +import { useEffect } from 'react'; + +import type { FlowDirection } from '../../components/flow'; +import { setup } from '../../machine/setup'; +import type { DoneInvokeEvent } from '../../machine/types'; +import { useMachine } from '../../machine/useMachine'; +import type { ReverificationModel } 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); + +type OverlayFrom = 'factor' | 'method-picker'; + +export type ReverificationDeps = { + start: () => Promise; + prepare: (method: ReverificationMethod, verificationStatus: ReverificationResult['status']) => Promise; + attempt: ( + method: ReverificationMethod, + value: string, + verificationStatus: ReverificationResult['status'], + ) => Promise; + verifyPasskey: () => Promise; + finish: () => Promise; + cancel: () => void; +}; + +interface ReverificationContext { + inputValue: string; + errorMessage: string | undefined; + direction: FlowDirection; + activeMethod: ReverificationMethod | null; + methods: readonly ReverificationMethod[]; + canResend: boolean; + abortRequested: boolean; + overlayFrom: OverlayFrom; + supportEmail: string; + verificationStatus: ReverificationResult['status'] | null; + deps: ReverificationDeps; +} + +type ReverificationEvent = + | { type: 'START' } + | { type: 'RESET' } + | { type: 'TYPE'; value: string } + | { type: 'SUBMIT' } + | { type: 'SHOW_METHODS' } + | { type: 'SHOW_HELP' } + | { type: 'SELECT_METHOD'; id: string } + | { type: 'BACK' } + | { type: 'RESEND' } + | { type: 'ABORT' }; + +const { createMachine, assign, fromPromise } = setup(); + +const unseatedDeps: ReverificationDeps = { + start: () => Promise.reject(new Error('start is not seated')), + prepare: () => Promise.resolve(), + attempt: () => Promise.reject(new Error('attempt is not seated')), + verifyPasskey: () => Promise.reject(new Error('verifyPasskey is not seated')), + finish: () => Promise.resolve(), + cancel: () => {}, +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Something went wrong. Please try again.'; +} + +function selectMethod(ctx: ReverificationContext, id: string): Partial { + return { + activeMethod: ctx.methods.find(method => method.id === id) ?? ctx.activeMethod, + inputValue: '', + errorMessage: undefined, + direction: 1, + }; +} + +function prepareActive(ctx: ReverificationContext) { + return ctx.deps.prepare( + ctx.activeMethod as ReverificationMethod, + ctx.verificationStatus ?? 'needs_first_factor', + ); +} + +function submit(ctx: ReverificationContext): Promise { + const method = ctx.activeMethod as ReverificationMethod; + return method.strategy === 'passkey' + ? ctx.deps.verifyPasskey() + : ctx.deps.attempt(method, ctx.inputValue, ctx.verificationStatus ?? 'needs_first_factor'); +} + +const applyResult = assign>((_, event) => ({ + methods: event.output.methods, + activeMethod: event.output.startingMethod, + verificationStatus: event.output.status, + inputValue: '', + errorMessage: undefined, + canResend: true, +})); +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 + }, + { + guard: (_: ReverificationContext, event: DoneInvokeEvent) => + event.output.startingMethod === null, + target: 'unavailable' as const, + actions: applyResult, + }, + { + guard: (_: ReverificationContext, event: DoneInvokeEvent) => { + const strategy = event.output.startingMethod?.strategy; + return Boolean(strategy && needsPrepare(strategy)); + }, + target: 'preparing' as const, + actions: applyResult, + }, + { target: 'verifying' as const, actions: applyResult }, +]; + +const abortNow = { + target: 'done' as const, + actions: (ctx: ReverificationContext) => { + ctx.deps.cancel(); + }, +}; + +const abortAfterInvoke = { + target: 'done' as const, + guard: (ctx: ReverificationContext) => ctx.abortRequested, + actions: [ + (ctx: ReverificationContext) => { + ctx.deps.cancel(); + }, + assign(() => ({ abortRequested: false })), + ], +}; + +export const reverificationMachine = createMachine({ + id: 'reverification', + initial: 'inactive', + context: { + inputValue: '', + errorMessage: undefined, + direction: 1, + activeMethod: null, + methods: [], + canResend: true, + abortRequested: false, + overlayFrom: 'factor', + supportEmail: '', + verificationStatus: null, + deps: unseatedDeps, + }, + states: { + inactive: { + entry: assign(() => ({ + inputValue: '', + errorMessage: undefined, + abortRequested: false, + canResend: true, + verificationStatus: null, + })), + on: { START: 'starting' }, + }, + + starting: { + on: { RESET: 'inactive' }, + invoke: fromPromise(ctx => ctx.deps.start(), { + onDone: afterResult, + onError: 'unavailable', + }), + }, + + preparing: { + on: { RESET: 'inactive', ABORT: abortNow }, + invoke: fromPromise(prepareActive, { + onDone: 'verifying', + onError: { + target: 'verifying', + actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), + }, + }), + }, + + verifying: { + after: { + 30_000: { guard: ctx => !ctx.canResend, actions: assign(() => ({ canResend: true })) }, + }, + on: { + TYPE: { + actions: assign((_, event) => ({ inputValue: event.value, errorMessage: undefined })), + }, + SUBMIT: 'submitting', + RESEND: { + target: 'resending', + guard: ctx => Boolean(ctx.activeMethod && needsPrepare(ctx.activeMethod.strategy) && ctx.canResend), + }, + SHOW_METHODS: { + target: 'methodPicker', + guard: ctx => ctx.methods.filter(method => method.id !== ctx.activeMethod?.id).length > 0, + actions: assign(() => ({ direction: 1 as const, overlayFrom: 'factor' as const })), + }, + SHOW_HELP: { + target: 'help', + actions: assign(() => ({ direction: 1 as const, overlayFrom: 'factor' as const })), + }, + ABORT: abortNow, + RESET: 'inactive', + }, + }, + + submitting: { + on: { + ABORT: { actions: assign(() => ({ abortRequested: true })) }, + RESET: { actions: assign(() => ({ abortRequested: true })) }, + }, + invoke: fromPromise(submit, { + onDone: afterResult, + onError: [ + abortAfterInvoke, + { + target: 'verifying', + actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), + }, + ], + }), + }, + + resending: { + on: { RESET: 'inactive' }, + invoke: fromPromise(prepareActive, { + onDone: { + target: 'verifying', + actions: assign(() => ({ canResend: false, inputValue: '', errorMessage: undefined })), + }, + onError: { + target: 'verifying', + actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), + }, + }), + }, + + methodPicker: { + on: { + SELECT_METHOD: [ + { + target: 'preparing', + guard: (ctx, event) => { + const method = ctx.methods.find(candidate => candidate.id === event.id); + return Boolean(method && needsPrepare(method.strategy)); + }, + actions: assign((ctx, event) => selectMethod(ctx, event.id)), + }, + { target: 'verifying', actions: assign((ctx, event) => selectMethod(ctx, event.id)) }, + ], + SHOW_HELP: { + target: 'help', + actions: assign(() => ({ direction: 1 as const, overlayFrom: 'method-picker' as const })), + }, + BACK: { target: 'verifying', actions: assign(() => ({ direction: -1 as const })) }, + ABORT: abortNow, + RESET: 'inactive', + }, + }, + + help: { + on: { + BACK: [ + { + target: 'methodPicker', + guard: ctx => ctx.overlayFrom === 'method-picker', + actions: assign(() => ({ direction: -1 as const })), + }, + { target: 'verifying', actions: assign(() => ({ direction: -1 as const })) }, + ], + ABORT: abortNow, + RESET: 'inactive', + }, + }, + + unavailable: { + on: { ABORT: abortNow, RESET: 'inactive' }, + }, + + completing: { + on: { RESET: 'inactive' }, + invoke: fromPromise(ctx => ctx.deps.finish(), { + onDone: 'done', + onError: 'done', + }), + }, + + done: { + on: { RESET: 'inactive' }, + }, + }, +}); + +const pendingStates = new Set(['starting', 'preparing', 'submitting', 'resending', 'completing']); + +function viewStep(value: string, method: ReverificationMethod | null): ReverificationViewProps['step'] | undefined { + if (value === 'methodPicker') { + return 'method-picker'; + } + if (value === 'help') { + return 'help'; + } + if ( + value === 'verifying' || + value === 'submitting' || + value === 'preparing' || + value === 'resending' || + value === 'completing' + ) { + 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 undefined; +} + +/** + * 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 + * + * Note that there are two loading states. + * - status: 'loading' - Full card spinner + * - status: 'ready' && isPending: true - Current action is pending, inline loading state + */ +export function useReverificationController(model: ReverificationModel): ReverificationController { + const ready = model.status === 'ready' ? model : null; + + const [snapshot, send] = useMachine( + reverificationMachine, + ready + ? { + context: { + supportEmail: ready.supportEmail, + deps: { + start: ready.start, + prepare: ready.prepare, + attempt: ready.attempt, + verifyPasskey: ready.verifyPasskey, + finish: ready.finish, + cancel: ready.cancel, + }, + }, + } + : undefined, + ); + + useEffect(() => { + if (model.isActive && ready && snapshot.value === 'inactive') { + send({ type: 'START' }); + } else if (!model.isActive && snapshot.value !== 'inactive') { + send({ type: 'RESET' }); + } + }); + + if (!model.isActive) { + return { status: 'idle' }; + } + + if (snapshot.value === 'inactive' || snapshot.value === 'starting' || snapshot.value === 'done') { + return { status: 'loading' }; + } + + if (snapshot.value === 'unavailable') { + return { status: 'unavailable' }; + } + + const { context } = snapshot; + const step = viewStep(snapshot.value, context.activeMethod); + if (!step) { + return { status: 'unavailable' }; + } + + const activeMethod = context.activeMethod; + const alternativeMethods = context.methods.filter(method => method.id !== activeMethod?.id); + + return { + status: 'ready', + step, + direction: context.direction, + value: context.inputValue, + onValueChange: value => send({ type: 'TYPE', value }), + errorMessage: context.errorMessage, + isPending: pendingStates.has(snapshot.value), + onSubmit: () => send({ type: 'SUBMIT' }), + onVerifyPasskey: () => send({ type: 'SUBMIT' }), + onShowMethods: alternativeMethods.length > 0 ? () => send({ type: 'SHOW_METHODS' }) : undefined, + onShowHelp: () => send({ type: 'SHOW_HELP' }), + onBack: step === 'method-picker' || step === 'help' ? () => send({ type: 'BACK' }) : undefined, + onEmailSupport: () => { + if (context.supportEmail) { + window.location.assign(`mailto:${context.supportEmail}`); + } + }, + methods: alternativeMethods, + onSelectMethod: id => send({ type: 'SELECT_METHOD', id }), + otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, + identifier: activeMethod?.identifier, + onResend: + activeMethod && needsPrepare(activeMethod.strategy) && context.canResend + ? () => send({ type: 'RESEND' }) + : undefined, + canResend: context.canResend, + }; +} diff --git a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx new file mode 100644 index 00000000000..59726da7b2c --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx @@ -0,0 +1,190 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { useClerk, useSession } from '@clerk/shared/react'; +import type { + PreferredSignInStrategy, + SessionVerificationFirstFactor, + SessionVerificationResource, + SessionVerificationSecondFactor, +} from '@clerk/shared/types'; +import { isWebAuthnSupported } from '@clerk/shared/webauthn'; + +import { useMosaicEnvironment } from '../../hooks/useMosaicEnvironment'; +import type { ReverificationMethod, ReverificationProps, ReverificationResult } from './reverification.types'; +import { pickStartingMethod } from './reverification.utils'; + +export type ReverificationReadyModel = { + status: 'ready'; + isActive: boolean; + supportEmail: string; + start: () => Promise; + prepare: (method: ReverificationMethod, verificationStatus: ReverificationResult['status']) => Promise; + attempt: ( + method: ReverificationMethod, + value: string, + verificationStatus: ReverificationResult['status'], + ) => Promise; + verifyPasskey: () => Promise; + finish: () => Promise; + 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.'); +} + +function toMethod( + factor: SessionVerificationFirstFactor | SessionVerificationSecondFactor, + webAuthnSupported: boolean, +): ReverificationMethod | null { + if (factor.strategy === 'passkey' && !webAuthnSupported) { + return null; + } + + if (factor.strategy === 'email_code') { + return { + id: `email_code:${factor.emailAddressId}`, + strategy: 'email_code', + identifier: factor.safeIdentifier, + emailAddressId: factor.emailAddressId, + }; + } + + if (factor.strategy === 'phone_code') { + return { + id: `phone_code:${factor.phoneNumberId}`, + strategy: 'phone_code', + identifier: factor.safeIdentifier, + phoneNumberId: factor.phoneNumberId, + }; + } + + switch (factor.strategy) { + case 'password': + case 'passkey': + case 'totp': + case 'backup_code': + return { id: factor.strategy, strategy: factor.strategy }; + default: + return null; + } +} + +function toResult( + resource: SessionVerificationResource, + preferredSignInStrategy: PreferredSignInStrategy | undefined, + webAuthnSupported: boolean, +): ReverificationResult { + const raw = + resource.status === 'needs_second_factor' ? resource.supportedSecondFactors : resource.supportedFirstFactors; + const methods = (raw ?? []) + .map(factor => toMethod(factor, webAuthnSupported)) + .filter((method): method is ReverificationMethod => method !== null); + + return { + status: resource.status, + methods, + startingMethod: pickStartingMethod(methods, resource.status, preferredSignInStrategy, webAuthnSupported), + }; +} + +export function useReverificationModel(props: ReverificationProps): ReverificationModel { + const { session } = useSession(); + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + const { isActive, cancel, complete, level } = props; + + if (!session || !environment) { + return { status: 'loading', isActive }; + } + + const webAuthnSupported = isWebAuthnSupported(); + const preferredSignInStrategy = environment.displayConfig.preferredSignInStrategy; + + const handleResponse = (resource: SessionVerificationResource) => + toResult(resource, preferredSignInStrategy, webAuthnSupported); + + return { + status: 'ready', + isActive, + supportEmail: environment.displayConfig.supportEmail ?? '', + start: async () => { + try { + return handleResponse(await session.startVerification({ level: level ?? 'first_factor' })); + } catch (error) { + throw toError(error); + } + }, + verifyPasskey: async () => { + try { + return handleResponse(await session.verifyWithPasskey()); + } catch (error) { + throw toError(error); + } + }, + cancel: () => { + cancel?.(); + }, + prepare: async (method, verificationStatus) => { + try { + if (verificationStatus === 'needs_second_factor') { + await session.prepareSecondFactorVerification({ + strategy: 'phone_code', + phoneNumberId: method.phoneNumberId, + }); + return; + } + if (method.strategy === 'email_code') { + await session.prepareFirstFactorVerification({ + strategy: 'email_code', + emailAddressId: method.emailAddressId ?? '', + }); + return; + } + if (method.strategy === 'phone_code') { + await session.prepareFirstFactorVerification({ + strategy: 'phone_code', + phoneNumberId: method.phoneNumberId ?? '', + }); + } + } catch (error) { + throw toError(error); + } + }, + attempt: async (method, value, verificationStatus) => { + try { + if (verificationStatus === 'needs_second_factor') { + const strategy = method.strategy === 'totp' || method.strategy === 'backup_code' ? method.strategy : 'phone_code'; + return handleResponse(await session.attemptSecondFactorVerification({ strategy, code: value })); + } + if (method.strategy === 'password') { + return handleResponse(await session.attemptFirstFactorVerification({ strategy: 'password', password: value })); + } + return handleResponse( + await session.attemptFirstFactorVerification({ + strategy: method.strategy === 'phone_code' ? 'phone_code' : 'email_code', + code: value, + }), + ); + } catch (error) { + throw toError(error); + } + }, + finish: async () => { + try { + try { + await clerk.setActive({ session: session.id }); + } finally { + complete?.(); + } + } catch (error) { + throw toError(error); + } + }, + }; +} diff --git a/packages/ui/src/mosaic/features/reverification/reverification.tsx b/packages/ui/src/mosaic/features/reverification/reverification.tsx new file mode 100644 index 00000000000..cb995d5f843 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/reverification.tsx @@ -0,0 +1,18 @@ +import { useReverificationController } from './reverification.controller'; +import { useReverificationModel } from './reverification.model'; +import type { ReverificationProps } from './reverification.types'; + +/** + * Takes `ReverificationProps` and runs the local flow. Renders nothing until + * the block view is wired to `ReverificationViewProps`. + */ +export function Reverification(props: ReverificationProps): null { + const model = useReverificationModel(props); + const ui = useReverificationController(model); + + if (!props.isActive || ui.status === 'idle') { + return null; + } + + return null; +} diff --git a/packages/ui/src/mosaic/features/reverification/reverification.types.ts b/packages/ui/src/mosaic/features/reverification/reverification.types.ts new file mode 100644 index 00000000000..eec60c70649 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/reverification.types.ts @@ -0,0 +1,48 @@ +import type { SessionVerificationLevel } from '@clerk/shared/types'; + +import type { FlowDirection } from '../../components/flow'; + +export type ReverificationStrategy = 'password' | 'passkey' | 'email_code' | 'phone_code' | 'totp' | 'backup_code'; + +export type ReverificationOtpChannel = 'email' | 'phone' | 'totp'; + +export type ReverificationStep = 'password' | 'passkey' | 'otp' | 'backup-code' | 'method-picker' | 'help'; + +export type ReverificationMethod = { + id: string; + strategy: ReverificationStrategy; + identifier?: string; + emailAddressId?: string; + phoneNumberId?: string; +}; + +export type ReverificationViewProps = { + step: ReverificationStep; + direction?: FlowDirection; + value: string; + onValueChange: (value: string) => void; + errorMessage?: string; + isPending: boolean; + onSubmit: () => void; + onVerifyPasskey: () => void; + onShowMethods?: () => void; + onShowHelp: () => void; + onBack?: () => void; + onEmailSupport: () => void; + methods: readonly ReverificationMethod[]; + onSelectMethod: (id: string) => void; + otpChannel?: ReverificationOtpChannel; + identifier?: string; + onResend?: () => void; + canResend?: boolean; +}; + +export type ReverificationProps = + | { isActive: false; complete?: undefined; cancel?: undefined; level?: undefined } + | { isActive: true; complete: () => void; cancel: () => void; level: SessionVerificationLevel | undefined }; + +export type ReverificationResult = { + status: 'needs_first_factor' | 'needs_second_factor' | 'complete'; + methods: readonly ReverificationMethod[]; + startingMethod: ReverificationMethod | null; +}; diff --git a/packages/ui/src/mosaic/features/reverification/reverification.utils.ts b/packages/ui/src/mosaic/features/reverification/reverification.utils.ts new file mode 100644 index 00000000000..0ab365af43b --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/reverification.utils.ts @@ -0,0 +1,70 @@ +import type { PreferredSignInStrategy } from '@clerk/shared/types'; + +import type { + ReverificationMethod, + ReverificationOtpChannel, + ReverificationResult, + ReverificationStrategy, +} from './reverification.types'; + +export function otpChannelFor(strategy: ReverificationStrategy): ReverificationOtpChannel | undefined { + if (strategy === 'email_code') { + return 'email'; + } + if (strategy === 'phone_code') { + return 'phone'; + } + if (strategy === 'totp') { + return 'totp'; + } + return undefined; +} + +export function needsPrepare(strategy: ReverificationStrategy): boolean { + return strategy === 'email_code' || strategy === 'phone_code'; +} + +function pickStartingFirstFactor( + methods: readonly ReverificationMethod[], + preferredSignInStrategy: PreferredSignInStrategy | undefined, + webAuthnSupported: boolean, +): ReverificationMethod | null { + if (methods.length === 0) { + return null; + } + + if (webAuthnSupported) { + const passkey = methods.find(method => method.strategy === 'passkey'); + if (passkey) { + return passkey; + } + } + + if (preferredSignInStrategy === 'password') { + return methods.find(method => method.strategy === 'password') ?? methods[0] ?? null; + } + + return ( + methods.find(method => method.strategy === 'email_code' || method.strategy === 'phone_code') ?? methods[0] ?? null + ); +} + +function pickStartingSecondFactor(methods: readonly ReverificationMethod[]): ReverificationMethod | null { + return ( + methods.find(method => method.strategy === 'totp') ?? + methods.find(method => method.strategy === 'phone_code') ?? + methods[0] ?? + null + ); +} + +export function pickStartingMethod( + methods: readonly ReverificationMethod[], + status: ReverificationResult['status'], + preferredSignInStrategy: PreferredSignInStrategy | undefined, + webAuthnSupported: boolean, +): ReverificationMethod | null { + return status === 'needs_second_factor' + ? pickStartingSecondFactor(methods) + : pickStartingFirstFactor(methods, preferredSignInStrategy, webAuthnSupported); +} diff --git a/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts b/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts new file mode 100644 index 00000000000..046b01b772b --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts @@ -0,0 +1,73 @@ +import { useReverification, useSession } from '@clerk/shared/react'; +import { useEffect, useRef, useState } from 'react'; + +import type { ReverificationProps } from './reverification.types'; + +type Fetcher = (...args: any[]) => Promise | undefined; + +type UseReverificationOptions = NonNullable[1]>; + +export type UseReverificationWithStateOptions = Omit; + +export type UseReverificationWithStateResult = readonly [ + ReturnType>, + ReverificationProps, +]; + +/** + * Same fetcher wrap as `useReverification`, with the need-reverification callback + * returned as `ReverificationProps` instead of `onNeedsReverification`. + */ +export function useReverificationWithState( + fetcher: F, + options?: UseReverificationWithStateOptions, +): UseReverificationWithStateResult { + const { session } = useSession(); + const [props, setProps] = useState({ isActive: false }); + const openedSessionId = useRef(null); + + const wrapped = useReverification(fetcher, { + ...options, + onNeedsReverification: ({ complete, cancel, level }) => { + openedSessionId.current = session?.id ?? null; + setProps({ + isActive: true, + level, + complete: () => { + setProps({ isActive: false }); + complete(); + }, + cancel: () => { + setProps({ isActive: false }); + cancel(); + }, + }); + }, + }); + + // Cancel if the session changes mid-flight + const { isActive, cancel } = props; + useEffect(() => { + if (!isActive) { + openedSessionId.current = null; + return; + } + // Do not reset on the transitive state + if (session === undefined) { + return; + } + if (session === null) { + cancel?.(); + return; + } + if (openedSessionId.current === null) { + openedSessionId.current = session.id; + return; + } + if (session.id !== openedSessionId.current) { + cancel?.(); + } + }, [isActive, cancel, session]); + + return [wrapped, props]; +} From b917f5fd38579a54752f438882372ff8f5bfac72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 1 Sep 2026 16:25:54 +0200 Subject: [PATCH 02/33] Move Reverification block into /features --- .../swingset/src/stories/reverification.mdx | 2 +- .../src/stories/reverification.stories.tsx | 20 +++++++++---------- .../src/mosaic/blocks/reverification/index.ts | 18 ----------------- .../__tests__/reverification.view.test.tsx} | 4 ++-- .../reverification-backup-code.tsx | 0 .../reverification/reverification-help.tsx | 0 .../reverification-method-picker.tsx | 0 .../reverification/reverification-otp.tsx | 0 .../reverification/reverification-passkey.tsx | 0 .../reverification-password.tsx | 0 .../reverification/reverification.messages.ts | 0 .../reverification/reverification.view.tsx} | 0 12 files changed, 12 insertions(+), 32 deletions(-) delete mode 100644 packages/ui/src/mosaic/blocks/reverification/index.ts rename packages/ui/src/mosaic/{blocks/reverification/reverification.test.tsx => features/reverification/__tests__/reverification.view.test.tsx} (97%) rename packages/ui/src/mosaic/{blocks => features}/reverification/reverification-backup-code.tsx (100%) rename packages/ui/src/mosaic/{blocks => features}/reverification/reverification-help.tsx (100%) rename packages/ui/src/mosaic/{blocks => features}/reverification/reverification-method-picker.tsx (100%) rename packages/ui/src/mosaic/{blocks => features}/reverification/reverification-otp.tsx (100%) rename packages/ui/src/mosaic/{blocks => features}/reverification/reverification-passkey.tsx (100%) rename packages/ui/src/mosaic/{blocks => features}/reverification/reverification-password.tsx (100%) rename packages/ui/src/mosaic/{blocks => features}/reverification/reverification.messages.ts (100%) rename packages/ui/src/mosaic/{blocks/reverification/reverification.tsx => features/reverification/reverification.view.tsx} (100%) diff --git a/packages/swingset/src/stories/reverification.mdx b/packages/swingset/src/stories/reverification.mdx index a1f0176fea2..448531211be 100644 --- a/packages/swingset/src/stories/reverification.mdx +++ b/packages/swingset/src/stories/reverification.mdx @@ -20,7 +20,7 @@ The example pauses briefly in each pending state. Enter `error` in password or b The controller supplies one model containing the active view, transition direction, resolved messages, form state, and event handlers. Spread that model directly into the prop-driven `Reverification` view. ```tsx -import { Reverification } from '@clerk/ui/mosaic/blocks/reverification'; +import { Reverification } from '@clerk/ui/mosaic/features/reverification/reverification.view'; ; ``` diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx index 5c24fa85158..cceec7983a2 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -1,13 +1,11 @@ -import type { ReverificationModel } from '@clerk/ui/mosaic/blocks/reverification'; -import { - Reverification, - ReverificationBackupCode, - ReverificationHelp, - ReverificationMethodPicker, - ReverificationOTP, - ReverificationPasskey, - ReverificationPassword, -} from '@clerk/ui/mosaic/blocks/reverification'; +import type { ReverificationModel } from '@clerk/ui/mosaic/features/reverification/reverification.view'; +import { Reverification } from '@clerk/ui/mosaic/features/reverification/reverification.view'; +import { ReverificationBackupCode } from '@clerk/ui/mosaic/features/reverification/reverification-backup-code'; +import { ReverificationHelp } from '@clerk/ui/mosaic/features/reverification/reverification-help'; +import { ReverificationMethodPicker } from '@clerk/ui/mosaic/features/reverification/reverification-method-picker'; +import { ReverificationOTP } from '@clerk/ui/mosaic/features/reverification/reverification-otp'; +import { ReverificationPasskey } from '@clerk/ui/mosaic/features/reverification/reverification-passkey'; +import { ReverificationPassword } from '@clerk/ui/mosaic/features/reverification/reverification-password'; import { Card } from '@clerk/ui/mosaic/components/card'; import { useState } from 'react'; @@ -19,7 +17,7 @@ export const meta: StoryMeta = { group: 'Blocks', status: 'wip', title: 'Reverification', - source: 'packages/ui/src/mosaic/blocks/reverification/reverification.tsx', + source: 'packages/ui/src/mosaic/features/reverification/reverification.view.tsx', }; const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); diff --git a/packages/ui/src/mosaic/blocks/reverification/index.ts b/packages/ui/src/mosaic/blocks/reverification/index.ts deleted file mode 100644 index f315bfee6c1..00000000000 --- a/packages/ui/src/mosaic/blocks/reverification/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export { Reverification } from './reverification'; -export type { ReverificationModel, ReverificationProps, ReverificationStatus } from './reverification'; -export { ReverificationBackupCode } from './reverification-backup-code'; -export type { ReverificationBackupCodeMessages, ReverificationBackupCodeProps } from './reverification-backup-code'; -export { ReverificationHelp } from './reverification-help'; -export type { ReverificationHelpMessages, ReverificationHelpProps } from './reverification-help'; -export { ReverificationMethodPicker } from './reverification-method-picker'; -export type { - ReverificationMethod, - ReverificationMethodPickerMessages, - ReverificationMethodPickerProps, -} from './reverification-method-picker'; -export { ReverificationOTP } from './reverification-otp'; -export type { ReverificationOtpMessages, ReverificationOTPProps, ReverificationOtpResend } from './reverification-otp'; -export { ReverificationPasskey } from './reverification-passkey'; -export type { ReverificationPasskeyMessages, ReverificationPasskeyProps } from './reverification-passkey'; -export { ReverificationPassword } from './reverification-password'; -export type { ReverificationPasswordMessages, ReverificationPasswordProps } from './reverification-password'; diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx similarity index 97% rename from packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx rename to packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx index 9d173802cd7..2f3499c3ffa 100644 --- a/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx @@ -1,8 +1,8 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import type { ReverificationModel } from './reverification'; -import { Reverification } from './reverification'; +import type { ReverificationModel } from '../reverification.view'; +import { Reverification } from '../reverification.view'; const actions = { secondaryActionLabel: 'Use another method', diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx b/packages/ui/src/mosaic/features/reverification/reverification-backup-code.tsx similarity index 100% rename from packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx rename to packages/ui/src/mosaic/features/reverification/reverification-backup-code.tsx diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx b/packages/ui/src/mosaic/features/reverification/reverification-help.tsx similarity index 100% rename from packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx rename to packages/ui/src/mosaic/features/reverification/reverification-help.tsx diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx b/packages/ui/src/mosaic/features/reverification/reverification-method-picker.tsx similarity index 100% rename from packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx rename to packages/ui/src/mosaic/features/reverification/reverification-method-picker.tsx diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx b/packages/ui/src/mosaic/features/reverification/reverification-otp.tsx similarity index 100% rename from packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx rename to packages/ui/src/mosaic/features/reverification/reverification-otp.tsx diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx b/packages/ui/src/mosaic/features/reverification/reverification-passkey.tsx similarity index 100% rename from packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx rename to packages/ui/src/mosaic/features/reverification/reverification-passkey.tsx diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx b/packages/ui/src/mosaic/features/reverification/reverification-password.tsx similarity index 100% rename from packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx rename to packages/ui/src/mosaic/features/reverification/reverification-password.tsx diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts b/packages/ui/src/mosaic/features/reverification/reverification.messages.ts similarity index 100% rename from packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts rename to packages/ui/src/mosaic/features/reverification/reverification.messages.ts diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.tsx b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx similarity index 100% rename from packages/ui/src/mosaic/blocks/reverification/reverification.tsx rename to packages/ui/src/mosaic/features/reverification/reverification.view.tsx From 4c63d6d8c8eb6cf41e0131ae9466ad156f14f13c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 1 Sep 2026 16:51:14 +0200 Subject: [PATCH 03/33] Wire up view to controller --- .../swingset/src/stories/reverification.mdx | 13 +- .../src/stories/reverification.stories.tsx | 545 ++++++++---------- .../__tests__/reverification.test.tsx | 55 +- .../__tests__/reverification.view.test.tsx | 133 ++--- .../reverification/reverification.tsx | 12 +- .../reverification/reverification.view.tsx | 193 ++++++- 6 files changed, 501 insertions(+), 450 deletions(-) diff --git a/packages/swingset/src/stories/reverification.mdx b/packages/swingset/src/stories/reverification.mdx index 448531211be..af7611f3a0c 100644 --- a/packages/swingset/src/stories/reverification.mdx +++ b/packages/swingset/src/stories/reverification.mdx @@ -17,12 +17,17 @@ The example pauses briefly in each pending state. Enter `error` in password or b ## Usage -The controller supplies one model containing the active view, transition direction, resolved messages, form state, and event handlers. Spread that model directly into the prop-driven `Reverification` view. +The feature wrapper reads Clerk state and drives `ReverificationView`. Isolated panel stories below are for visual development only. ```tsx -import { Reverification } from '@clerk/ui/mosaic/features/reverification/reverification.view'; - -; +import { Reverification } from '@clerk/ui/mosaic/features/reverification'; + + {}} + cancel={() => {}} + level='first_factor' +/>; ``` ## Panels diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx index cceec7983a2..c639046fb4d 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -1,12 +1,13 @@ -import type { ReverificationModel } from '@clerk/ui/mosaic/features/reverification/reverification.view'; -import { Reverification } from '@clerk/ui/mosaic/features/reverification/reverification.view'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Card } from '@clerk/ui/mosaic/components/card'; +import type { ReverificationMethod, ReverificationStep } from '@clerk/ui/mosaic/features/reverification'; +import { ReverificationView } from '@clerk/ui/mosaic/features/reverification/reverification.view'; import { ReverificationBackupCode } from '@clerk/ui/mosaic/features/reverification/reverification-backup-code'; import { ReverificationHelp } from '@clerk/ui/mosaic/features/reverification/reverification-help'; import { ReverificationMethodPicker } from '@clerk/ui/mosaic/features/reverification/reverification-method-picker'; import { ReverificationOTP } from '@clerk/ui/mosaic/features/reverification/reverification-otp'; import { ReverificationPasskey } from '@clerk/ui/mosaic/features/reverification/reverification-passkey'; import { ReverificationPassword } from '@clerk/ui/mosaic/features/reverification/reverification-password'; -import { Card } from '@clerk/ui/mosaic/components/card'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -22,47 +23,6 @@ export const meta: StoryMeta = { const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -const actions = { - secondaryActionLabel: 'Use another method', - primaryActionLabel: 'Continue', - pendingLabel: 'Verifying', -}; - -const passwordMessages = { - title: 'Verification required', - description: 'Enter your current password to continue', - fieldLabel: 'Password', - fieldPlaceholder: 'Enter your password', - ...actions, -}; - -const passkeyMessages = { - title: 'Use your passkey', - description: - 'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.', - ...actions, - primaryActionLabel: 'Use your passkey', -}; - -const phoneOtpMessages = { - title: 'Verification required', - description: 'Enter the code sent to your phone to continue', - fieldLabel: 'Verification code', - ...actions, -}; - -const authenticatorOtpMessages = { - ...phoneOtpMessages, - description: 'Enter the code generated by your authenticator app to continue', -}; - -const backupCodeMessages = { - title: 'Enter a backup code', - description: 'Enter the backup code you received when setting up two-step authentication', - fieldLabel: 'Backup code', - ...actions, -}; - const methodPickerMessages = { title: 'Use another method', description: 'Facing issues? You can use any of these methods for verification.', @@ -79,190 +39,203 @@ const helpMessages = { supportButton: 'Email support', }; -const methodPresentation = { - password: { label: 'Continue with your password', icon: 'security-lock-square' }, - otp: { label: 'Use your authenticator app', icon: 'security-authenticator' }, - 'backup-code': { label: 'Use a backup code', icon: 'security-phone' }, - passkey: { label: 'Use your passkey', icon: 'security-passkey' }, -} satisfies Record>; - -type MethodId = keyof typeof methodPresentation; +const allMethods: ReverificationMethod[] = [ + { id: 'password', strategy: 'password' }, + { id: 'passkey', strategy: 'passkey' }, + { id: 'totp', strategy: 'totp' }, + { id: 'backup_code', strategy: 'backup_code' }, +]; + +function stepFor(id: string): ReverificationStep { + if (id === 'passkey') { + return 'passkey'; + } + if (id === 'backup_code') { + return 'backup-code'; + } + if (id === 'totp') { + return 'otp'; + } + return 'password'; +} -function usePrototype(): ReverificationModel { - const [status, setStatus] = useState('password'); - const [direction, setDirection] = useState(1); - const [methodId, setMethodId] = useState('password'); +function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element { + const [step, setStep] = useState('password'); + const [direction, setDirection] = useState<-1 | 1>(1); + const [methodId, setMethodId] = useState('password'); const [value, setValue] = useState(''); - const [fieldError, setFieldError] = useState(); - const [pendingMethod, setPendingMethod] = useState(); - const [pendingMethodId, setPendingMethodId] = useState(); - const [isResending, setIsResending] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + const [isPending, setIsPending] = useState(false); + const [supportRequested, setSupportRequested] = useState(false); - const navigate = (nextStatus: ReverificationModel['status'], nextDirection: ReverificationModel['direction']) => { + const navigate = (next: ReverificationStep, nextDirection: -1 | 1) => { setDirection(nextDirection); - setStatus(nextStatus); + setStep(next); setValue(''); - setFieldError(undefined); + setErrorMessage(undefined); }; const onValueChange = (nextValue: string) => { setValue(nextValue); - setFieldError(undefined); - }; - - const complete = () => { - setDirection(1); - setStatus('password'); - setMethodId('password'); - setValue(''); - setFieldError(undefined); + setErrorMessage(undefined); }; const submitPassword = async () => { if (!value.trim()) { - setFieldError('This field is required.'); + setErrorMessage('This field is required.'); return; } - setPendingMethod('password'); + setIsPending(true); await settleAfter(700); - setPendingMethod(undefined); + setIsPending(false); if (value.toLowerCase() === 'error') { - setFieldError('That password is incorrect. Try again.'); + setErrorMessage('That password is incorrect. Try again.'); return; } - complete(); + setMethodId('totp'); + navigate('otp', 1); }; const submitOtp = async (code: string) => { if (code.length !== 6) { - setFieldError('Enter the complete verification code.'); + setErrorMessage('Enter the complete verification code.'); return; } - setPendingMethod('otp'); + setIsPending(true); await settleAfter(700); - setPendingMethod(undefined); + setIsPending(false); if (code === '000000') { - setFieldError('That verification code is incorrect. Try again.'); + setErrorMessage('That verification code is incorrect. Try again.'); return; } - complete(); + onComplete(); }; const submitBackupCode = async () => { if (!value.trim()) { - setFieldError('This field is required.'); + setErrorMessage('This field is required.'); return; } - setPendingMethod('backup-code'); + setIsPending(true); await settleAfter(700); - setPendingMethod(undefined); + setIsPending(false); if (value.toLowerCase() === 'error') { - setFieldError('That backup code is incorrect. Try again.'); + setErrorMessage('That backup code is incorrect. Try again.'); return; } - complete(); + onComplete(); }; const submitPasskey = async () => { - setPendingMethod('passkey'); + setIsPending(true); await settleAfter(700); - setPendingMethod(undefined); - complete(); + setIsPending(false); + onComplete(); }; const selectMethod = async (id: string) => { - const nextMethodId = id as MethodId; - setPendingMethodId(id); + setIsPending(true); await settleAfter(700); - setPendingMethodId(undefined); - setMethodId(nextMethodId); - navigate(nextMethodId, 1); + setIsPending(false); + setMethodId(id); + navigate(stepFor(id), 1); }; - const resend = async () => { - setIsResending(true); - await settleAfter(700); - setIsResending(false); - onValueChange(''); - }; - - const showMethods = () => navigate('method-picker', -1); - const model: ReverificationModel = { - status, - direction, - password: { - messages: passwordMessages, - value, - errorMessage: fieldError, - isPending: pendingMethod === 'password', - onValueChange, - onSubmit: () => void submitPassword(), - onCancel: showMethods, - }, - passkey: { - messages: passkeyMessages, - isPending: pendingMethod === 'passkey', - onVerify: () => void submitPasskey(), - onCancel: showMethods, - }, - otp: { - messages: authenticatorOtpMessages, - value, - errorMessage: fieldError, - isPending: pendingMethod === 'otp', - resend: { - label: isResending ? 'Sending a new code…' : 'Didn’t receive a code? Resend', - disabled: isResending || pendingMethod === 'otp', - onClick: () => void resend(), - }, - onValueChange, - onComplete: code => void submitOtp(code), - onSubmit: () => void submitOtp(value), - onCancel: showMethods, - }, - backupCode: { - messages: backupCodeMessages, - value, - errorMessage: fieldError, - isPending: pendingMethod === 'backup-code', - onValueChange, - onSubmit: () => void submitBackupCode(), - onCancel: showMethods, - }, - methodPicker: { - messages: methodPickerMessages, - methods: (Object.keys(methodPresentation) as MethodId[]) - .filter(id => id !== methodId) - .map(id => ({ id, ...methodPresentation[id] })), - pendingMethodId, - onSelect: id => void selectMethod(id), - onHelp: () => navigate('help', 1), - onBack: () => navigate(methodId, 1), - }, - help: { - messages: helpMessages, - onEmailSupport: () => undefined, - onBack: () => navigate('method-picker', -1), - }, + const onSubmit = () => { + if (step === 'backup-code') { + void submitBackupCode(); + return; + } + if (step === 'otp') { + void submitOtp(value); + return; + } + void submitPassword(); }; - return model; + return ( + <> + void submitPasskey()} + onShowMethods={() => navigate('method-picker', 1)} + onShowHelp={() => navigate('help', 1)} + onBack={ + step === 'help' + ? () => navigate('method-picker', -1) + : step === 'method-picker' + ? () => navigate(stepFor(methodId), -1) + : undefined + } + onEmailSupport={() => setSupportRequested(true)} + methods={allMethods.filter(method => method.id !== methodId)} + onSelectMethod={id => void selectMethod(id)} + otpChannel='totp' + /> + {supportRequested ?

Email support requested.

: null} + + ); } export function Default(): JSX.Element { - const prototype = usePrototype(); + const [runId, setRunId] = useState(0); + const [complete, setComplete] = useState(false); + + if (complete) { + return ( +
+

Reverification complete.

+ +
+ ); + } - return ; + return ( + setComplete(true)} + /> + ); } -export function Password(): JSX.Element { - const [value, setValue] = useState(''); +function PasswordPanel({ + isPending = false, + errorMessage, +}: { + isPending?: boolean; + errorMessage?: string; +}): JSX.Element { + const [value, setValue] = useState(errorMessage ? 'incorrect-password' : ''); return ( undefined} onCancel={() => setValue('')} @@ -271,41 +244,38 @@ export function Password(): JSX.Element { ); } +export function Password(): JSX.Element { + return ; +} + export function PasswordPending(): JSX.Element { - return ( - - undefined} - onSubmit={() => undefined} - onCancel={() => undefined} - /> - - ); + return ; } export function PasswordError(): JSX.Element { - return ( - - undefined} - onSubmit={() => undefined} - onCancel={() => undefined} - /> - - ); + return ; } -export function Passkey(): JSX.Element { +function PasskeyPanel({ + isPending = false, + errorMessage, +}: { + isPending?: boolean; + errorMessage?: string; +}): JSX.Element { return ( undefined} onCancel={() => undefined} /> @@ -313,44 +283,56 @@ export function Passkey(): JSX.Element { ); } +export function Passkey(): JSX.Element { + return ; +} + export function PasskeyPending(): JSX.Element { - return ( - - undefined} - onCancel={() => undefined} - /> - - ); + return ; } export function PasskeyError(): JSX.Element { - return ( - - undefined} - onCancel={() => undefined} - /> - - ); + return ; } -export function OTP(): JSX.Element { - const [value, setValue] = useState(''); +function OTPPanel({ + description, + isPending = false, + errorMessage, + isResending = false, + renderResend = true, +}: { + description: string; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + renderResend?: boolean; +}): JSX.Element { + const [value, setValue] = useState(isPending || errorMessage ? '123456' : ''); return ( setValue(''), + messages={{ + title: 'Verification required', + description, + fieldLabel: 'Verification code', + secondaryActionLabel: 'Cancel', + primaryActionLabel: 'Continue', + pendingLabel: 'Verifying', }} + value={value} + errorMessage={errorMessage} + isPending={isPending} + resend={ + renderResend + ? { + label: isResending ? 'Sending a new code…' : 'Didn’t receive a code? Resend', + disabled: isResending, + onClick: () => setValue(''), + } + : undefined + } onValueChange={setValue} onSubmit={() => undefined} onCancel={() => setValue('')} @@ -359,88 +341,69 @@ export function OTP(): JSX.Element { ); } -export function AuthenticatorOTP(): JSX.Element { - const [value, setValue] = useState(''); +export function OTP(): JSX.Element { + return ; +} +export function AuthenticatorOTP(): JSX.Element { return ( - - undefined} - onCancel={() => setValue('')} - /> - + ); } export function OTPPending(): JSX.Element { return ( - - undefined, - }} - onValueChange={() => undefined} - onSubmit={() => undefined} - onCancel={() => undefined} - /> - + ); } export function OTPError(): JSX.Element { return ( - - undefined, - }} - onValueChange={() => undefined} - onSubmit={() => undefined} - onCancel={() => undefined} - /> - + ); } export function OTPResending(): JSX.Element { return ( - - undefined, - }} - onValueChange={() => undefined} - onSubmit={() => undefined} - onCancel={() => undefined} - /> - + ); } -export function BackupCode(): JSX.Element { - const [value, setValue] = useState(''); +function BackupCodePanel({ + isPending = false, + errorMessage, +}: { + isPending?: boolean; + errorMessage?: string; +}): JSX.Element { + const [value, setValue] = useState(errorMessage ? 'invalid-code' : ''); return ( undefined} onCancel={() => setValue('')} @@ -449,34 +412,16 @@ export function BackupCode(): JSX.Element { ); } +export function BackupCode(): JSX.Element { + return ; +} + export function BackupCodePending(): JSX.Element { - return ( - - undefined} - onSubmit={() => undefined} - onCancel={() => undefined} - /> - - ); + return ; } export function BackupCodeError(): JSX.Element { - return ( - - undefined} - onSubmit={() => undefined} - onCancel={() => undefined} - /> - - ); + return ; } export function MethodPicker(): JSX.Element { diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx index 1d99ff6b2ea..0d3495d067d 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx @@ -1,9 +1,10 @@ -import { render } from '@testing-library/react'; +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', @@ -27,34 +28,54 @@ 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(), + onVerifyPasskey: vi.fn(), + onShowHelp: vi.fn(), + onEmailSupport: vi.fn(), + methods: [], + onSelectMethod: vi.fn(), + ...overrides, + }; +} + describe('Reverification', () => { it('renders nothing when reverification is not active', () => { - const { container } = render( - , - ); + controller = { status: 'idle' }; + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); - it('renders nothing while the controller is loading, unavailable, or ready', () => { + it('renders nothing while the controller is loading or unavailable', () => { controller = { status: 'loading' }; - const active = { - isActive: true as const, - complete: vi.fn(), - cancel: vi.fn(), - level: 'first_factor' as const, - }; - const loading = render(); expect(loading.container).toBeEmptyDOMElement(); controller = { status: 'unavailable' }; const unavailable = render(); expect(unavailable.container).toBeEmptyDOMElement(); + }); - controller = { status: 'ready' } as ReverificationController; - const ready = render(); - expect(ready.container).toBeEmptyDOMElement(); - - controller = { status: 'idle' }; + 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/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx index 2f3499c3ffa..bc17987a3f7 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx @@ -1,95 +1,38 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import type { ReverificationModel } from '../reverification.view'; -import { Reverification } from '../reverification.view'; - -const actions = { - secondaryActionLabel: 'Use another method', - primaryActionLabel: 'Continue', - pendingLabel: 'Verifying', -}; - -function model( - status: ReverificationModel['status'], - direction: ReverificationModel['direction'] = 1, -): ReverificationModel { - const onValueChange = vi.fn(); - const onSubmit = vi.fn(); +import { MosaicProvider } from '../../../MosaicProvider'; +import type { ReverificationViewProps } from '../reverification.types'; +import { ReverificationView } from '../reverification.view'; +function viewProps(overrides: Partial = {}): ReverificationViewProps { return { - status, - direction, - password: { - messages: { - title: 'Verification required', - description: 'Enter your password.', - fieldLabel: 'Password', - fieldPlaceholder: 'Enter your password', - ...actions, - }, - value: '', - onValueChange, - onSubmit, - }, - passkey: { - messages: { - title: 'Use your passkey', - description: 'Verify with your passkey.', - ...actions, - }, - onVerify: onSubmit, - }, - otp: { - messages: { - title: 'Verification required', - description: 'Enter the code from your authenticator.', - fieldLabel: 'Verification code', - ...actions, - }, - value: '', - onValueChange, - onSubmit, - }, - backupCode: { - messages: { - title: 'Enter a backup code', - description: 'Enter one of your backup codes.', - fieldLabel: 'Backup code', - ...actions, - }, - value: '', - onValueChange, - onSubmit, - }, - methodPicker: { - messages: { - title: 'Use another method', - description: 'Choose another way to verify.', - backButton: 'Back', - helpText: 'Need help?', - helpButton: 'Get help', - }, - methods: [], - onSelect: vi.fn(), - onHelp: vi.fn(), - }, - help: { - messages: { - title: 'Get help', - description: 'Contact support for help.', - backButton: 'Back', - supportButton: 'Email support', - }, - onEmailSupport: vi.fn(), - onBack: vi.fn(), - }, + step: 'password', + value: '', + onValueChange: vi.fn(), + isPending: false, + onSubmit: vi.fn(), + onVerifyPasskey: vi.fn(), + onShowMethods: vi.fn(), + onShowHelp: vi.fn(), + onEmailSupport: vi.fn(), + methods: [], + onSelectMethod: vi.fn(), + ...overrides, }; } -describe('Reverification', () => { +function renderView(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe('ReverificationView', () => { it('keeps one Card and Flow surface while view props and panels change', () => { - const { container, rerender } = render(); + const { container, rerender } = renderView(); const card = container.querySelector('.cl-card-root'); const flow = container.querySelector('.cl-flow-root'); @@ -99,15 +42,21 @@ describe('Reverification', () => { expect(card).toContainElement(flow); expect(flow).toHaveAttribute('data-value', 'password'); - const pendingPassword = model('password'); - pendingPassword.password.isPending = true; - rerender(); + 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(); + rerender( + + + , + ); expect(container.querySelector('.cl-card-root')).toBe(card); expect(container.querySelector('.cl-flow-root')).toBe(flow); @@ -118,16 +67,16 @@ describe('Reverification', () => { }); it('does not render Card branding', () => { - render(); + renderView(); expect(screen.queryByText('Secured by')).not.toBeInTheDocument(); }); it('renders a passkey attempt error in a negative Banner', () => { - const errorModel = model('passkey'); - errorModel.passkey.errorMessage = 'We couldn’t verify that passkey. Try again.'; - - render(); + renderView({ + step: 'passkey', + errorMessage: 'We couldn’t verify that passkey. Try again.', + }); const banner = screen.getByRole('alert'); expect(banner).toHaveClass('cl-banner-root'); diff --git a/packages/ui/src/mosaic/features/reverification/reverification.tsx b/packages/ui/src/mosaic/features/reverification/reverification.tsx index cb995d5f843..6cdceb9b252 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.tsx @@ -1,18 +1,16 @@ import { useReverificationController } from './reverification.controller'; import { useReverificationModel } from './reverification.model'; import type { ReverificationProps } from './reverification.types'; +import { ReverificationView } from './reverification.view'; -/** - * Takes `ReverificationProps` and runs the local flow. Renders nothing until - * the block view is wired to `ReverificationViewProps`. - */ -export function Reverification(props: ReverificationProps): null { +export function Reverification(props: ReverificationProps) { const model = useReverificationModel(props); const ui = useReverificationController(model); - if (!props.isActive || ui.status === 'idle') { + if (!props.isActive || ui.status !== 'ready') { return null; } - return null; + const { status: _status, ...viewProps } = ui; + return ; } diff --git a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx index cf0657fb324..08c6a4010a7 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx @@ -1,67 +1,200 @@ import { Card } from '../../components/card'; -import type { FlowDirection } from '../../components/flow'; import { Flow } from '../../components/flow'; -import type { ReverificationBackupCodeProps } from './reverification-backup-code'; +import type { IconName } from '../../icons/registry'; +import { fill, reverificationBase as m } from './reverification.messages'; +import type { ReverificationMethod, ReverificationOtpChannel, ReverificationViewProps } from './reverification.types'; import { ReverificationBackupCode } from './reverification-backup-code'; -import type { ReverificationHelpProps } from './reverification-help'; import { ReverificationHelp } from './reverification-help'; -import type { ReverificationMethodPickerProps } from './reverification-method-picker'; import { ReverificationMethodPicker } from './reverification-method-picker'; -import type { ReverificationOTPProps } from './reverification-otp'; import { ReverificationOTP } from './reverification-otp'; -import type { ReverificationPasskeyProps } from './reverification-passkey'; import { ReverificationPasskey } from './reverification-passkey'; -import type { ReverificationPasswordProps } from './reverification-password'; import { ReverificationPassword } from './reverification-password'; -export type ReverificationStatus = 'password' | 'passkey' | 'otp' | 'backup-code' | 'method-picker' | 'help'; +const actions = { + secondaryActionLabel: m.footerActionLink__useAnotherMethod, + primaryActionLabel: m.formButtonPrimary, + pendingLabel: m.verifying, +}; -/** Controlled rendering model produced by a reverification controller. */ -export interface ReverificationModel { - status: ReverificationStatus; - direction: FlowDirection; - password: ReverificationPasswordProps; - passkey: ReverificationPasskeyProps; - otp: ReverificationOTPProps; - backupCode: ReverificationBackupCodeProps; - methodPicker: ReverificationMethodPickerProps; - help: ReverificationHelpProps; +const methodIcon = { + password: 'security-lock-square', + passkey: 'security-passkey', + email_code: 'code', + phone_code: 'security-phone', + totp: 'security-authenticator', + backup_code: 'security-phone', +} as const satisfies Record; + +function methodLabel(method: ReverificationMethod): string { + const identifier = method.identifier ?? ''; + switch (method.strategy) { + case 'password': + return m.alternativeMethods.blockButton__password; + case 'passkey': + return m.alternativeMethods.blockButton__passkey; + case 'email_code': + return fill(m.alternativeMethods.blockButton__emailCode, { identifier }); + case 'phone_code': + return fill(m.alternativeMethods.blockButton__phoneCode, { identifier }); + case 'totp': + return m.alternativeMethods.blockButton__totp; + case 'backup_code': + return m.alternativeMethods.blockButton__backupCode; + } +} + +function otpCopy(channel: ReverificationOtpChannel | undefined) { + if (channel === 'email') { + return m.emailCode; + } + if (channel === 'phone') { + return m.phoneCode; + } + return m.totpMfa; } -export type ReverificationProps = ReverificationModel; +export function ReverificationView(props: ReverificationViewProps): JSX.Element { + const { + step, + direction, + value, + onValueChange, + errorMessage, + isPending, + onSubmit, + onVerifyPasskey, + onShowMethods, + onShowHelp, + onBack, + onEmailSupport, + methods, + onSelectMethod, + otpChannel, + onResend, + canResend, + } = props; + + const otp = otpCopy(otpChannel); -export function Reverification(model: ReverificationProps): JSX.Element { return ( - {current => ( + {() => ( <> - + - + - + { + onValueChange(code); + onSubmit(); + }} + onSubmit={onSubmit} + onCancel={onShowMethods} + /> - + - + ({ + id: method.id, + label: methodLabel(method), + icon: methodIcon[method.strategy], + }))} + onSelect={onSelectMethod} + onHelp={onShowHelp} + onBack={onBack} + /> - + {})} + /> )} From 5f51d9c16e84e0c2c42d7d37e51ae0481704fd21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 1 Sep 2026 16:56:47 +0200 Subject: [PATCH 04/33] Move Reverification components into /panels --- .../swingset/src/stories/reverification.stories.tsx | 12 ++++++------ .../{ => panels}/reverification-backup-code.tsx | 8 ++++---- .../{ => panels}/reverification-help.tsx | 4 ++-- .../{ => panels}/reverification-method-picker.tsx | 13 +++++++------ .../{ => panels}/reverification-otp.tsx | 8 ++++---- .../{ => panels}/reverification-passkey.tsx | 6 +++--- .../{ => panels}/reverification-password.tsx | 8 ++++---- .../features/reverification/reverification.view.tsx | 12 ++++++------ 8 files changed, 36 insertions(+), 35 deletions(-) rename packages/ui/src/mosaic/features/reverification/{ => panels}/reverification-backup-code.tsx (91%) rename packages/ui/src/mosaic/features/reverification/{ => panels}/reverification-help.tsx (90%) rename packages/ui/src/mosaic/features/reverification/{ => panels}/reverification-method-picker.tsx (87%) rename packages/ui/src/mosaic/features/reverification/{ => panels}/reverification-otp.tsx (93%) rename packages/ui/src/mosaic/features/reverification/{ => panels}/reverification-passkey.tsx (90%) rename packages/ui/src/mosaic/features/reverification/{ => panels}/reverification-password.tsx (91%) diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx index c639046fb4d..ba6ce802d3d 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -1,13 +1,13 @@ import { Button } from '@clerk/ui/mosaic/components/button'; import { Card } from '@clerk/ui/mosaic/components/card'; import type { ReverificationMethod, ReverificationStep } from '@clerk/ui/mosaic/features/reverification'; +import { ReverificationBackupCode } from '@clerk/ui/mosaic/features/reverification/panels/reverification-backup-code'; +import { ReverificationHelp } from '@clerk/ui/mosaic/features/reverification/panels/reverification-help'; +import { ReverificationMethodPicker } from '@clerk/ui/mosaic/features/reverification/panels/reverification-method-picker'; +import { ReverificationOTP } from '@clerk/ui/mosaic/features/reverification/panels/reverification-otp'; +import { ReverificationPasskey } from '@clerk/ui/mosaic/features/reverification/panels/reverification-passkey'; +import { ReverificationPassword } from '@clerk/ui/mosaic/features/reverification/panels/reverification-password'; import { ReverificationView } from '@clerk/ui/mosaic/features/reverification/reverification.view'; -import { ReverificationBackupCode } from '@clerk/ui/mosaic/features/reverification/reverification-backup-code'; -import { ReverificationHelp } from '@clerk/ui/mosaic/features/reverification/reverification-help'; -import { ReverificationMethodPicker } from '@clerk/ui/mosaic/features/reverification/reverification-method-picker'; -import { ReverificationOTP } from '@clerk/ui/mosaic/features/reverification/reverification-otp'; -import { ReverificationPasskey } from '@clerk/ui/mosaic/features/reverification/reverification-passkey'; -import { ReverificationPassword } from '@clerk/ui/mosaic/features/reverification/reverification-password'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; diff --git a/packages/ui/src/mosaic/features/reverification/reverification-backup-code.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx similarity index 91% rename from packages/ui/src/mosaic/features/reverification/reverification-backup-code.tsx rename to packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx index d18e8d51ee3..3bf52f7a921 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification-backup-code.tsx +++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx @@ -1,10 +1,10 @@ import type { FormEvent } from 'react'; import { useId } from 'react'; -import { Button, SubmitButton } from '../../components/button'; -import { Card } from '../../components/card'; -import { Field } from '../../components/field'; -import { Input } from '../../components/input'; +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Field } from '../../../components/field'; +import { Input } from '../../../components/input'; export interface ReverificationBackupCodeMessages { title: string; diff --git a/packages/ui/src/mosaic/features/reverification/reverification-help.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-help.tsx similarity index 90% rename from packages/ui/src/mosaic/features/reverification/reverification-help.tsx rename to packages/ui/src/mosaic/features/reverification/panels/reverification-help.tsx index 11a883fcff6..7d4a2577fb3 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification-help.tsx +++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-help.tsx @@ -1,5 +1,5 @@ -import { Button } from '../../components/button'; -import { Card } from '../../components/card'; +import { Button } from '../../../components/button'; +import { Card } from '../../../components/card'; export interface ReverificationHelpMessages { title: string; diff --git a/packages/ui/src/mosaic/features/reverification/reverification-method-picker.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx similarity index 87% rename from packages/ui/src/mosaic/features/reverification/reverification-method-picker.tsx rename to packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx index da1138bcd59..8900fa47b70 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification-method-picker.tsx +++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx @@ -1,10 +1,11 @@ -import { Button } from '../../components/button'; -import { Card } from '../../components/card'; +import { Button } from '../../../components/button'; +import { Card } from '../../../components/card'; +import type { IconProps } from '../../../components/icon'; +import { Icon, IconFrame } from '../../../components/icon'; +import { Item } from '../../../components/item'; +import { Spinner } from '../../../components/spinner'; +import { Text } from '../../../components/text'; import type { IconProps } from '../../components/icon'; -import { Icon, IconFrame } from '../../components/icon'; -import { Item } from '../../components/item'; -import { Spinner } from '../../components/spinner'; -import { Text } from '../../components/text'; export interface ReverificationMethod { id: string; diff --git a/packages/ui/src/mosaic/features/reverification/reverification-otp.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-otp.tsx similarity index 93% rename from packages/ui/src/mosaic/features/reverification/reverification-otp.tsx rename to packages/ui/src/mosaic/features/reverification/panels/reverification-otp.tsx index 685e38e02f2..fa0d297d188 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification-otp.tsx +++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-otp.tsx @@ -1,10 +1,10 @@ import type { FormEvent } from 'react'; import { useId } from 'react'; -import { Button, SubmitButton } from '../../components/button'; -import { Card } from '../../components/card'; -import { Field } from '../../components/field'; -import { Otp } from '../../components/otp'; +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Field } from '../../../components/field'; +import { Otp } from '../../../components/otp'; export interface ReverificationOtpMessages { title: string; diff --git a/packages/ui/src/mosaic/features/reverification/reverification-passkey.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-passkey.tsx similarity index 90% rename from packages/ui/src/mosaic/features/reverification/reverification-passkey.tsx rename to packages/ui/src/mosaic/features/reverification/panels/reverification-passkey.tsx index 7609b921742..943be1f3dde 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification-passkey.tsx +++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-passkey.tsx @@ -1,6 +1,6 @@ -import { Banner } from '../../components/banner'; -import { Button, SubmitButton } from '../../components/button'; -import { Card } from '../../components/card'; +import { Banner } from '../../../components/banner'; +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; export interface ReverificationPasskeyMessages { title: string; diff --git a/packages/ui/src/mosaic/features/reverification/reverification-password.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-password.tsx similarity index 91% rename from packages/ui/src/mosaic/features/reverification/reverification-password.tsx rename to packages/ui/src/mosaic/features/reverification/panels/reverification-password.tsx index f229fe2e44d..2a569903e45 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification-password.tsx +++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-password.tsx @@ -1,10 +1,10 @@ import type { FormEvent } from 'react'; import { useId } from 'react'; -import { Button, SubmitButton } from '../../components/button'; -import { Card } from '../../components/card'; -import { Field } from '../../components/field'; -import { Input } from '../../components/input'; +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Field } from '../../../components/field'; +import { Input } from '../../../components/input'; export interface ReverificationPasswordMessages { title: string; diff --git a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx index 08c6a4010a7..e04869c6600 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx @@ -1,14 +1,14 @@ import { Card } from '../../components/card'; import { Flow } from '../../components/flow'; import type { IconName } from '../../icons/registry'; +import { ReverificationBackupCode } from './panels/reverification-backup-code'; +import { ReverificationHelp } from './panels/reverification-help'; +import { ReverificationMethodPicker } from './panels/reverification-method-picker'; +import { ReverificationOTP } from './panels/reverification-otp'; +import { ReverificationPasskey } from './panels/reverification-passkey'; +import { ReverificationPassword } from './panels/reverification-password'; import { fill, reverificationBase as m } from './reverification.messages'; import type { ReverificationMethod, ReverificationOtpChannel, ReverificationViewProps } from './reverification.types'; -import { ReverificationBackupCode } from './reverification-backup-code'; -import { ReverificationHelp } from './reverification-help'; -import { ReverificationMethodPicker } from './reverification-method-picker'; -import { ReverificationOTP } from './reverification-otp'; -import { ReverificationPasskey } from './reverification-passkey'; -import { ReverificationPassword } from './reverification-password'; const actions = { secondaryActionLabel: m.footerActionLink__useAnotherMethod, From 6329b6b0c5aa2a72f2259790f2c42bd138d73469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 7 Sep 2026 11:10:12 +0200 Subject: [PATCH 05/33] Handle incompatible model action calls explicitly --- .../__tests__/reverification.model.test.tsx | 120 +++++++++++++++--- .../reverification.controller.ts | 27 ++-- .../reverification/reverification.model.tsx | 116 ++++++++++++----- .../reverification/reverification.types.ts | 2 + 4 files changed, 202 insertions(+), 63 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx index 187af8f5ad1..1d8bb4ff6a2 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx @@ -1,10 +1,14 @@ -import type * as SharedReact from '@clerk/shared/react'; 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 { useReverificationModel, type ReverificationModel, type ReverificationReadyModel } from '../reverification.model'; +import { + type ReverificationModel, + type ReverificationReadyModel, + useReverificationModel, +} from '../reverification.model'; function ready(model: ReverificationModel): ReverificationReadyModel { expect(model.status).toBe('ready'); @@ -14,15 +18,18 @@ function ready(model: ReverificationModel): ReverificationReadyModel { return model; } -let session: { - id: string; - startVerification: ReturnType; - prepareFirstFactorVerification: ReturnType; - prepareSecondFactorVerification: ReturnType; - attemptFirstFactorVerification: ReturnType; - attemptSecondFactorVerification: ReturnType; - verifyWithPasskey: ReturnType; -} | null | undefined; +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; @@ -30,9 +37,7 @@ let webAuthnSupported: boolean; let setActive: ReturnType; function environment() { - return environmentHydrated - ? { displayConfig: { preferredSignInStrategy, supportEmail } } - : undefined; + return environmentHydrated ? { displayConfig: { preferredSignInStrategy, supportEmail } } : undefined; } vi.mock('@clerk/shared/react', async importOriginal => { @@ -115,7 +120,12 @@ describe('useReverificationModel', () => { { 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' }, + { + strategy: 'enterprise_sso', + emailAddressId: 'idn_2', + enterpriseConnectionId: 'ec_1', + safeIdentifier: 'sso', + }, ], }), ); @@ -219,11 +229,85 @@ describe('useReverificationModel', () => { expect(session?.attemptSecondFactorVerification).toHaveBeenCalledWith({ strategy: 'totp', code: '123456' }); }); + it('no-ops prepare for methods that have no prepare step', async () => { + const { result } = renderHook(() => useReverificationModel(activeProps())); + await ready(result.current).prepare({ id: 'password', strategy: 'password' }, 'needs_first_factor'); + await ready(result.current).prepare({ id: 'passkey', strategy: 'passkey' }, 'needs_first_factor'); + await ready(result.current).prepare({ id: 'totp', strategy: 'totp' }, 'needs_second_factor'); + await ready(result.current).prepare({ id: 'backup_code', strategy: 'backup_code' }, 'needs_second_factor'); + + expect(session?.prepareFirstFactorVerification).not.toHaveBeenCalled(); + expect(session?.prepareSecondFactorVerification).not.toHaveBeenCalled(); + }); + + it('rejects prepare when the method does not belong to the verification stage', async () => { + const { result } = renderHook(() => useReverificationModel(activeProps())); + const model = ready(result.current); + + await expect(model.prepare({ id: 'totp', strategy: 'totp' }, 'needs_first_factor')).rejects.toThrow( + 'Cannot prepare totp when verification is needs_first_factor.', + ); + await expect(model.prepare({ id: 'password', strategy: 'password' }, 'needs_second_factor')).rejects.toThrow( + 'Cannot prepare password when verification is needs_second_factor.', + ); + expect(session?.prepareFirstFactorVerification).not.toHaveBeenCalled(); + expect(session?.prepareSecondFactorVerification).not.toHaveBeenCalled(); + }); + + it('rejects prepare when a code method is missing its factor id', async () => { + const { result } = renderHook(() => useReverificationModel(activeProps())); + const model = ready(result.current); + + await expect(model.prepare({ id: 'email_code:', strategy: 'email_code' }, 'needs_first_factor')).rejects.toThrow( + 'Cannot prepare email_code without an email address.', + ); + await expect(model.prepare({ id: 'phone_code:', strategy: 'phone_code' }, 'needs_first_factor')).rejects.toThrow( + 'Cannot prepare phone_code without a phone number.', + ); + await expect(model.prepare({ id: 'phone_code:', strategy: 'phone_code' }, 'needs_second_factor')).rejects.toThrow( + 'Cannot prepare phone_code without a phone number.', + ); + expect(session?.prepareFirstFactorVerification).not.toHaveBeenCalled(); + expect(session?.prepareSecondFactorVerification).not.toHaveBeenCalled(); + }); + + it('rejects attempt when the method does not belong to the verification stage', async () => { + const { result } = renderHook(() => useReverificationModel(activeProps())); + const model = ready(result.current); + + await expect(model.attempt({ id: 'passkey', strategy: 'passkey' }, 'x', 'needs_first_factor')).rejects.toThrow( + 'Cannot attempt passkey when verification is needs_first_factor.', + ); + await expect(model.attempt({ id: 'password', strategy: 'password' }, 'x', 'needs_second_factor')).rejects.toThrow( + 'Cannot attempt password when verification is needs_second_factor.', + ); + expect(session?.attemptFirstFactorVerification).not.toHaveBeenCalled(); + expect(session?.attemptSecondFactorVerification).not.toHaveBeenCalled(); + }); + + it('verifies a passkey only during first-factor verification', async () => { + session?.verifyWithPasskey.mockResolvedValue(resource({ status: 'complete' })); + const { result } = renderHook(() => useReverificationModel(activeProps())); + const model = ready(result.current); + + await expect(model.verifyPasskey('needs_second_factor')).rejects.toThrow( + 'Cannot verify passkey when verification is needs_second_factor.', + ); + expect(session?.verifyWithPasskey).not.toHaveBeenCalled(); + + await model.verifyPasskey('needs_first_factor'); + 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.' }, + { + code: 'form_password_incorrect', + message: 'Incorrect password', + long_message: 'That password is incorrect.', + }, ], status: 422, }), @@ -232,9 +316,7 @@ describe('useReverificationModel', () => { const { result } = renderHook(() => useReverificationModel(activeProps())); await expect( ready(result.current).attempt({ id: 'password', strategy: 'password' }, 'bad', 'needs_first_factor'), - ).rejects.toThrow( - 'That password is incorrect.', - ); + ).rejects.toThrow('That password is incorrect.'); }); it('activates the verified session and calls complete', async () => { diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index af7c682e554..7f3093abbcb 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -5,7 +5,12 @@ import { setup } from '../../machine/setup'; import type { DoneInvokeEvent } from '../../machine/types'; import { useMachine } from '../../machine/useMachine'; import type { ReverificationModel } from './reverification.model'; -import type { ReverificationMethod, ReverificationResult, ReverificationViewProps } from './reverification.types'; +import type { + ReverificationFactorStatus, + ReverificationMethod, + ReverificationResult, + ReverificationViewProps, +} from './reverification.types'; import { needsPrepare, otpChannelFor } from './reverification.utils'; export type ReverificationController = @@ -18,13 +23,13 @@ type OverlayFrom = 'factor' | 'method-picker'; export type ReverificationDeps = { start: () => Promise; - prepare: (method: ReverificationMethod, verificationStatus: ReverificationResult['status']) => Promise; + prepare: (method: ReverificationMethod, verificationStatus: ReverificationFactorStatus) => Promise; attempt: ( method: ReverificationMethod, value: string, - verificationStatus: ReverificationResult['status'], + verificationStatus: ReverificationFactorStatus, ) => Promise; - verifyPasskey: () => Promise; + verifyPasskey: (verificationStatus: ReverificationFactorStatus) => Promise; finish: () => Promise; cancel: () => void; }; @@ -39,7 +44,7 @@ interface ReverificationContext { abortRequested: boolean; overlayFrom: OverlayFrom; supportEmail: string; - verificationStatus: ReverificationResult['status'] | null; + verificationStatus: ReverificationFactorStatus | null; deps: ReverificationDeps; } @@ -80,23 +85,21 @@ function selectMethod(ctx: ReverificationContext, id: string): Partial { const method = ctx.activeMethod as ReverificationMethod; + const verificationStatus = ctx.verificationStatus ?? 'needs_first_factor'; return method.strategy === 'passkey' - ? ctx.deps.verifyPasskey() - : ctx.deps.attempt(method, ctx.inputValue, ctx.verificationStatus ?? 'needs_first_factor'); + ? ctx.deps.verifyPasskey(verificationStatus) + : ctx.deps.attempt(method, ctx.inputValue, verificationStatus); } const applyResult = assign>((_, event) => ({ methods: event.output.methods, activeMethod: event.output.startingMethod, - verificationStatus: event.output.status, + verificationStatus: event.output.status === 'complete' ? null : event.output.status, inputValue: '', errorMessage: undefined, canResend: true, diff --git a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx index 59726da7b2c..20c43bfe2a4 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx @@ -9,7 +9,12 @@ import type { import { isWebAuthnSupported } from '@clerk/shared/webauthn'; import { useMosaicEnvironment } from '../../hooks/useMosaicEnvironment'; -import type { ReverificationMethod, ReverificationProps, ReverificationResult } from './reverification.types'; +import type { + ReverificationFactorStatus, + ReverificationMethod, + ReverificationProps, + ReverificationResult, +} from './reverification.types'; import { pickStartingMethod } from './reverification.utils'; export type ReverificationReadyModel = { @@ -17,13 +22,13 @@ export type ReverificationReadyModel = { isActive: boolean; supportEmail: string; start: () => Promise; - prepare: (method: ReverificationMethod, verificationStatus: ReverificationResult['status']) => Promise; + prepare: (method: ReverificationMethod, verificationStatus: ReverificationFactorStatus) => Promise; attempt: ( method: ReverificationMethod, value: string, - verificationStatus: ReverificationResult['status'], + verificationStatus: ReverificationFactorStatus, ) => Promise; - verifyPasskey: () => Promise; + verifyPasskey: (verificationStatus: ReverificationFactorStatus) => Promise; finish: () => Promise; cancel: () => void; }; @@ -38,6 +43,22 @@ function toError(error: unknown): Error { return error instanceof Error ? error : new Error('Something went wrong. Please try again.'); } +// This only happens if there is a bug in the controller +function incompatible(action: 'prepare' | 'attempt' | 'verify', subject: string, status: string): Error { + return new Error(`Cannot ${action} ${subject} when verification is ${status}.`); +} + +function requireFactorId(value: string | undefined, strategy: 'email_code' | 'phone_code'): string { + if (!value) { + throw new Error( + strategy === 'email_code' + ? 'Cannot prepare email_code without an email address.' + : 'Cannot prepare phone_code without a phone number.', + ); + } + return value; +} + function toMethod( factor: SessionVerificationFirstFactor | SessionVerificationSecondFactor, webAuthnSupported: boolean, @@ -120,8 +141,11 @@ export function useReverificationModel(props: ReverificationProps): Reverificati throw toError(error); } }, - verifyPasskey: async () => { + verifyPasskey: async verificationStatus => { try { + if (verificationStatus !== 'needs_first_factor') { + throw incompatible('verify', 'passkey', verificationStatus); + } return handleResponse(await session.verifyWithPasskey()); } catch (error) { throw toError(error); @@ -133,24 +157,39 @@ export function useReverificationModel(props: ReverificationProps): Reverificati prepare: async (method, verificationStatus) => { try { if (verificationStatus === 'needs_second_factor') { - await session.prepareSecondFactorVerification({ - strategy: 'phone_code', - phoneNumberId: method.phoneNumberId, - }); - return; - } - if (method.strategy === 'email_code') { - await session.prepareFirstFactorVerification({ - strategy: 'email_code', - emailAddressId: method.emailAddressId ?? '', - }); - return; + switch (method.strategy) { + case 'phone_code': + await session.prepareSecondFactorVerification({ + strategy: 'phone_code', + phoneNumberId: requireFactorId(method.phoneNumberId, 'phone_code'), + }); + return; + case 'totp': + case 'backup_code': + return; + default: + throw incompatible('prepare', method.strategy, verificationStatus); + } } - if (method.strategy === 'phone_code') { - await session.prepareFirstFactorVerification({ - strategy: 'phone_code', - phoneNumberId: method.phoneNumberId ?? '', - }); + + switch (method.strategy) { + case 'email_code': + await session.prepareFirstFactorVerification({ + strategy: 'email_code', + emailAddressId: requireFactorId(method.emailAddressId, 'email_code'), + }); + return; + case 'phone_code': + await session.prepareFirstFactorVerification({ + strategy: 'phone_code', + phoneNumberId: requireFactorId(method.phoneNumberId, 'phone_code'), + }); + return; + case 'password': + case 'passkey': + return; + default: + throw incompatible('prepare', method.strategy, verificationStatus); } } catch (error) { throw toError(error); @@ -159,18 +198,31 @@ export function useReverificationModel(props: ReverificationProps): Reverificati attempt: async (method, value, verificationStatus) => { try { if (verificationStatus === 'needs_second_factor') { - const strategy = method.strategy === 'totp' || method.strategy === 'backup_code' ? method.strategy : 'phone_code'; - return handleResponse(await session.attemptSecondFactorVerification({ strategy, code: value })); + switch (method.strategy) { + case 'phone_code': + case 'totp': + case 'backup_code': + return handleResponse( + await session.attemptSecondFactorVerification({ strategy: method.strategy, code: value }), + ); + default: + throw incompatible('attempt', method.strategy, verificationStatus); + } } - if (method.strategy === 'password') { - return handleResponse(await session.attemptFirstFactorVerification({ strategy: 'password', password: value })); + + switch (method.strategy) { + case 'password': + return handleResponse( + await session.attemptFirstFactorVerification({ strategy: 'password', password: value }), + ); + case 'email_code': + case 'phone_code': + return handleResponse( + await session.attemptFirstFactorVerification({ strategy: method.strategy, code: value }), + ); + default: + throw incompatible('attempt', method.strategy, verificationStatus); } - return handleResponse( - await session.attemptFirstFactorVerification({ - strategy: method.strategy === 'phone_code' ? 'phone_code' : 'email_code', - code: value, - }), - ); } catch (error) { throw toError(error); } diff --git a/packages/ui/src/mosaic/features/reverification/reverification.types.ts b/packages/ui/src/mosaic/features/reverification/reverification.types.ts index eec60c70649..e655487f190 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.types.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.types.ts @@ -46,3 +46,5 @@ export type ReverificationResult = { methods: readonly ReverificationMethod[]; startingMethod: ReverificationMethod | null; }; + +export type ReverificationFactorStatus = 'needs_first_factor' | 'needs_second_factor'; From 8a397456875833b35b10fd1873a6b817f44ead66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 7 Sep 2026 11:13:09 +0200 Subject: [PATCH 06/33] Tweak --- .../mosaic/features/reverification/reverification.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/reverification.tsx b/packages/ui/src/mosaic/features/reverification/reverification.tsx index 6cdceb9b252..5b277c9df62 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.tsx @@ -5,12 +5,17 @@ import { ReverificationView } from './reverification.view'; export function Reverification(props: ReverificationProps) { const model = useReverificationModel(props); - const ui = useReverificationController(model); + const controller = useReverificationController(model); - if (!props.isActive || ui.status !== 'ready') { + if (!props.isActive) { return null; } - const { status: _status, ...viewProps } = ui; + if (controller.status !== 'ready') { + // TODO: Implement unavailable and loading states, could also live in the .view. + return null; + } + + const { status: _status, ...viewProps } = controller; return ; } From a12455ab9bbad263b6392804ccb715f342db308f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 7 Sep 2026 12:01:06 +0200 Subject: [PATCH 07/33] Additional hardening --- .../__tests__/reverification.model.test.tsx | 3 +- .../use-reverification-with-state.test.tsx | 31 +++++++++++++++++++ .../reverification/reverification.model.tsx | 4 +++ .../use-reverification-with-state.ts | 9 ++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx index 1d8bb4ff6a2..ee222063523 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx @@ -323,7 +323,8 @@ describe('useReverificationModel', () => { session?.startVerification.mockResolvedValue(resource({ status: 'complete' })); const props = activeProps(); const { result } = renderHook(() => useReverificationModel(props)); - await ready(result.current).start(); + 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(); diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx index 45f6d14c861..afac2c885a0 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -136,4 +136,35 @@ describe('useReverificationWithState', () => { await waitFor(() => expect(cancel).toHaveBeenCalledOnce()); expect(result.current[1].isActive).toBe(false); }); + + it('cancels when the owner unmounts mid-flow', () => { + const { unmount } = renderHook(() => useReverificationWithState(fetcher)); + const cancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + }); + + unmount(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('does not cancel again on unmount after the flow already settled', () => { + const { result, unmount } = renderHook(() => useReverificationWithState(fetcher)); + const cancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + }); + act(() => { + const [, state] = result.current; + if (state.isActive) { + state.cancel(); + } + }); + expect(cancel).toHaveBeenCalledOnce(); + + unmount(); + expect(cancel).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx index 20c43bfe2a4..b272a6d1f73 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx @@ -101,6 +101,10 @@ function toResult( preferredSignInStrategy: PreferredSignInStrategy | undefined, webAuthnSupported: boolean, ): ReverificationResult { + if (resource.status === 'complete') { + return { status: 'complete', methods: [], startingMethod: null }; + } + const raw = resource.status === 'needs_second_factor' ? resource.supportedSecondFactors : resource.supportedFirstFactors; const methods = (raw ?? []) diff --git a/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts b/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts index 046b01b772b..bf0360e8950 100644 --- a/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts +++ b/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts @@ -25,6 +25,7 @@ export function useReverificationWithState( const { session } = useSession(); const [props, setProps] = useState({ isActive: false }); const openedSessionId = useRef(null); + const cancelOnUnmountRef = useRef<(() => void) | undefined>(undefined); const wrapped = useReverification(fetcher, { ...options, @@ -48,6 +49,8 @@ export function useReverificationWithState( // Cancel if the session changes mid-flight const { isActive, cancel } = props; useEffect(() => { + cancelOnUnmountRef.current = isActive ? cancel : undefined; + if (!isActive) { openedSessionId.current = null; return; @@ -69,5 +72,11 @@ export function useReverificationWithState( } }, [isActive, cancel, session]); + useEffect(() => { + return () => { + cancelOnUnmountRef.current?.(); + }; + }, []); + return [wrapped, props]; } From 2ec6563d4d31c5f85da2cf8148884ff9298888bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 7 Sep 2026 12:09:41 +0200 Subject: [PATCH 08/33] Fix import order --- .../__tests__/reverification.controller.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index 1d1be5cfe89..f981b1b46c1 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -4,9 +4,9 @@ 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, - type ReverificationDeps, } from '../reverification.controller'; import type { ReverificationModel, ReverificationReadyModel } from '../reverification.model'; import type { ReverificationMethod, ReverificationResult } from '../reverification.types'; @@ -32,10 +32,10 @@ function firstFactorResult(overrides: Partial = {}): Rever function seatedDeps(overrides: Partial = {}): ReverificationDeps { return { start: vi.fn(async () => firstFactorResult()), - prepare: vi.fn(async () => {}), + prepare: vi.fn(async () => { }), attempt: vi.fn(async () => firstFactorResult({ status: 'complete' })), verifyPasskey: vi.fn(async () => firstFactorResult({ status: 'complete' })), - finish: vi.fn(async () => {}), + finish: vi.fn(async () => { }), cancel: vi.fn(), ...overrides, }; @@ -53,10 +53,10 @@ function readyModel(overrides: Partial = {}): Reverifi isActive: true, supportEmail: 'support@example.com', start: vi.fn(async () => firstFactorResult()), - prepare: vi.fn(async () => {}), + prepare: vi.fn(async () => { }), attempt: vi.fn(async () => firstFactorResult({ status: 'complete' })), verifyPasskey: vi.fn(async () => firstFactorResult({ status: 'complete' })), - finish: vi.fn(async () => {}), + finish: vi.fn(async () => { }), cancel: vi.fn(), ...overrides, }; @@ -84,7 +84,7 @@ describe('reverificationMachine', () => { }); it('prepares an email code once when that method is selected', async () => { - const prepare = vi.fn(async () => {}); + const prepare = vi.fn(async () => { }); const actor = startActor(seatedDeps({ prepare })); await tick(); actor.send({ type: 'SHOW_METHODS' }); From 862600653323811b72983f34d670263a7d130222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 7 Sep 2026 14:24:23 +0200 Subject: [PATCH 09/33] Rename setProps in useReverificationWithState --- .../reverification/use-reverification-with-state.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts b/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts index bf0360e8950..f289b4d434b 100644 --- a/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts +++ b/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts @@ -23,7 +23,7 @@ export function useReverificationWithState( options?: UseReverificationWithStateOptions, ): UseReverificationWithStateResult { const { session } = useSession(); - const [props, setProps] = useState({ isActive: false }); + const [reverificationState, setReverificationState] = useState({ isActive: false }); const openedSessionId = useRef(null); const cancelOnUnmountRef = useRef<(() => void) | undefined>(undefined); @@ -31,15 +31,15 @@ export function useReverificationWithState( ...options, onNeedsReverification: ({ complete, cancel, level }) => { openedSessionId.current = session?.id ?? null; - setProps({ + setReverificationState({ isActive: true, level, complete: () => { - setProps({ isActive: false }); + setReverificationState({ isActive: false }); complete(); }, cancel: () => { - setProps({ isActive: false }); + setReverificationState({ isActive: false }); cancel(); }, }); @@ -47,7 +47,7 @@ export function useReverificationWithState( }); // Cancel if the session changes mid-flight - const { isActive, cancel } = props; + const { isActive, cancel } = reverificationState; useEffect(() => { cancelOnUnmountRef.current = isActive ? cancel : undefined; @@ -78,5 +78,5 @@ export function useReverificationWithState( }; }, []); - return [wrapped, props]; + return [wrapped, reverificationState]; } From e7f1ed679097baf35bcf8bce82cbe0934f2547ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 8 Sep 2026 13:45:38 +0200 Subject: [PATCH 10/33] Fix formatting --- .../reverification.controller.test.tsx | 18 +++++++----------- .../__tests__/reverification.model.test.tsx | 16 ++++++++-------- .../use-reverification-with-state.test.tsx | 5 ++++- .../mosaic/features/reverification/index.ts | 5 ++++- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index f981b1b46c1..3a7b50e9493 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -32,10 +32,10 @@ function firstFactorResult(overrides: Partial = {}): Rever function seatedDeps(overrides: Partial = {}): ReverificationDeps { return { start: vi.fn(async () => firstFactorResult()), - prepare: vi.fn(async () => { }), + prepare: vi.fn(async () => {}), attempt: vi.fn(async () => firstFactorResult({ status: 'complete' })), verifyPasskey: vi.fn(async () => firstFactorResult({ status: 'complete' })), - finish: vi.fn(async () => { }), + finish: vi.fn(async () => {}), cancel: vi.fn(), ...overrides, }; @@ -53,10 +53,10 @@ function readyModel(overrides: Partial = {}): Reverifi isActive: true, supportEmail: 'support@example.com', start: vi.fn(async () => firstFactorResult()), - prepare: vi.fn(async () => { }), + prepare: vi.fn(async () => {}), attempt: vi.fn(async () => firstFactorResult({ status: 'complete' })), verifyPasskey: vi.fn(async () => firstFactorResult({ status: 'complete' })), - finish: vi.fn(async () => { }), + finish: vi.fn(async () => {}), cancel: vi.fn(), ...overrides, }; @@ -84,7 +84,7 @@ describe('reverificationMachine', () => { }); it('prepares an email code once when that method is selected', async () => { - const prepare = vi.fn(async () => { }); + const prepare = vi.fn(async () => {}); const actor = startActor(seatedDeps({ prepare })); await tick(); actor.send({ type: 'SHOW_METHODS' }); @@ -172,9 +172,7 @@ describe('reverificationMachine', () => { describe('useReverificationController', () => { it('is idle when reverification is not active', () => { - const { result } = renderHook(() => - useReverificationController(readyModel({ isActive: false })), - ); + const { result } = renderHook(() => useReverificationController(readyModel({ isActive: false }))); expect(result.current.status).toBe('idle'); }); @@ -224,9 +222,7 @@ describe('useReverificationController', () => { it('marks the current step pending while an attempt is in flight', async () => { const attempt = deferred(); - const { result } = renderHook(() => - useReverificationController(readyModel({ attempt: () => attempt.promise })), - ); + const { result } = renderHook(() => useReverificationController(readyModel({ attempt: () => attempt.promise }))); await waitFor(() => expect(result.current.status).toBe('ready')); act(() => { diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx index ee222063523..f9c959f31d6 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx @@ -20,14 +20,14 @@ function ready(model: ReverificationModel): ReverificationReadyModel { let session: | { - id: string; - startVerification: ReturnType; - prepareFirstFactorVerification: ReturnType; - prepareSecondFactorVerification: ReturnType; - attemptFirstFactorVerification: ReturnType; - attemptSecondFactorVerification: ReturnType; - verifyWithPasskey: ReturnType; - } + id: string; + startVerification: ReturnType; + prepareFirstFactorVerification: ReturnType; + prepareSecondFactorVerification: ReturnType; + attemptFirstFactorVerification: ReturnType; + attemptSecondFactorVerification: ReturnType; + verifyWithPasskey: ReturnType; + } | null | undefined; let environmentHydrated: boolean; diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx index afac2c885a0..9fe9f970948 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -20,7 +20,10 @@ vi.mock('@clerk/shared/react', async importOriginal => { return { ...actual, useSession: () => ({ session }), - useReverification: (_fetcher: unknown, options?: { onNeedsReverification?: (params: NeedsReverificationParameters) => void }) => { + useReverification: ( + _fetcher: unknown, + options?: { onNeedsReverification?: (params: NeedsReverificationParameters) => void }, + ) => { capturedOnNeeds = options?.onNeedsReverification; return wrapped; }, diff --git a/packages/ui/src/mosaic/features/reverification/index.ts b/packages/ui/src/mosaic/features/reverification/index.ts index fbb031b5636..15f03750df7 100644 --- a/packages/ui/src/mosaic/features/reverification/index.ts +++ b/packages/ui/src/mosaic/features/reverification/index.ts @@ -7,5 +7,8 @@ export type { ReverificationStrategy, ReverificationViewProps, } from './reverification.types'; -export type { UseReverificationWithStateOptions, UseReverificationWithStateResult } from './use-reverification-with-state'; +export type { + UseReverificationWithStateOptions, + UseReverificationWithStateResult, +} from './use-reverification-with-state'; export { useReverificationWithState } from './use-reverification-with-state'; From 27b10cc5f526ed8e74fa598a2c972cd3d4270a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 8 Sep 2026 14:42:26 +0200 Subject: [PATCH 11/33] Fix test --- .../reverification/__tests__/reverification.controller.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index 3a7b50e9493..0c53156356b 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -233,6 +233,7 @@ describe('useReverificationController', () => { }); await waitFor(() => { + expect(result.current.status).toBe('ready'); if (result.current.status === 'ready') { expect(result.current.isPending).toBe(true); } From 50902bf3c6be1542c3ef8bd892bb26901c6de907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 8 Sep 2026 14:44:17 +0200 Subject: [PATCH 12/33] Abort correctly after submitting --- .../reverification.controller.test.tsx | 17 +++++++++++++++++ .../reverification/reverification.controller.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index 0c53156356b..b3aaa4e0c4b 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -151,6 +151,23 @@ describe('reverificationMachine', () => { expect(cancel).toHaveBeenCalledOnce(); }); + it('cancels a successful attempt that settled after abort was requested', 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: 'ABORT' }); + + 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( diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index 7f3093abbcb..20447e35e65 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -226,7 +226,7 @@ export const reverificationMachine = createMachine({ RESET: { actions: assign(() => ({ abortRequested: true })) }, }, invoke: fromPromise(submit, { - onDone: afterResult, + onDone: [abortAfterInvoke, ...afterResult], onError: [ abortAfterInvoke, { From 394dd899a909907b0b53ed81dd510143ad239049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 8 Sep 2026 14:57:43 +0200 Subject: [PATCH 13/33] Handle failed setActive --- .../reverification.controller.test.tsx | 44 +++++++++++++++++++ .../__tests__/reverification.model.test.tsx | 10 +++++ .../reverification.controller.ts | 5 ++- .../reverification/reverification.model.tsx | 7 +-- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index b3aaa4e0c4b..cfa752444a6 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -185,6 +185,22 @@ describe('reverificationMachine', () => { 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(); + }); }); describe('useReverificationController', () => { @@ -294,6 +310,34 @@ describe('useReverificationController', () => { 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( diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx index f9c959f31d6..63a99cd77d9 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx @@ -329,4 +329,14 @@ describe('useReverificationModel', () => { 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/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index 20447e35e65..fb36862fabb 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -297,7 +297,10 @@ export const reverificationMachine = createMachine({ on: { RESET: 'inactive' }, invoke: fromPromise(ctx => ctx.deps.finish(), { onDone: 'done', - onError: 'done', + onError: { + target: 'verifying', + actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), + }, }), }, diff --git a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx index b272a6d1f73..c65bae6390a 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx @@ -233,11 +233,8 @@ export function useReverificationModel(props: ReverificationProps): Reverificati }, finish: async () => { try { - try { - await clerk.setActive({ session: session.id }); - } finally { - complete?.(); - } + await clerk.setActive({ session: session.id }); + complete?.(); } catch (error) { throw toError(error); } From edef84b21c29aa03dea1b7fa21668ef98ed0408a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 8 Sep 2026 15:07:00 +0200 Subject: [PATCH 14/33] Fix story --- .../src/stories/reverification.stories.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx index ba6ce802d3d..08cd2dc1aa6 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -8,7 +8,7 @@ import { ReverificationOTP } from '@clerk/ui/mosaic/features/reverification/pane import { ReverificationPasskey } from '@clerk/ui/mosaic/features/reverification/panels/reverification-passkey'; import { ReverificationPassword } from '@clerk/ui/mosaic/features/reverification/panels/reverification-password'; import { ReverificationView } from '@clerk/ui/mosaic/features/reverification/reverification.view'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -64,6 +64,7 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element const [direction, setDirection] = useState<-1 | 1>(1); const [methodId, setMethodId] = useState('password'); const [value, setValue] = useState(''); + const valueRef = useRef(value); const [errorMessage, setErrorMessage] = useState(); const [isPending, setIsPending] = useState(false); const [supportRequested, setSupportRequested] = useState(false); @@ -71,24 +72,27 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element const navigate = (next: ReverificationStep, nextDirection: -1 | 1) => { setDirection(nextDirection); setStep(next); + valueRef.current = ''; setValue(''); setErrorMessage(undefined); }; const onValueChange = (nextValue: string) => { + valueRef.current = nextValue; setValue(nextValue); setErrorMessage(undefined); }; const submitPassword = async () => { - if (!value.trim()) { + const password = valueRef.current; + if (!password.trim()) { setErrorMessage('This field is required.'); return; } setIsPending(true); await settleAfter(700); setIsPending(false); - if (value.toLowerCase() === 'error') { + if (password.toLowerCase() === 'error') { setErrorMessage('That password is incorrect. Try again.'); return; } @@ -112,14 +116,15 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element }; const submitBackupCode = async () => { - if (!value.trim()) { + const code = valueRef.current; + if (!code.trim()) { setErrorMessage('This field is required.'); return; } setIsPending(true); await settleAfter(700); setIsPending(false); - if (value.toLowerCase() === 'error') { + if (code.toLowerCase() === 'error') { setErrorMessage('That backup code is incorrect. Try again.'); return; } @@ -147,7 +152,7 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element return; } if (step === 'otp') { - void submitOtp(value); + void submitOtp(valueRef.current); return; } void submitPassword(); From 415bf1622d08772ff22ff37dd6b778ea8dcb00e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 9 Sep 2026 14:24:14 +0200 Subject: [PATCH 15/33] Refactor verificationStatus out of the controller into the model --- .../src/stories/reverification.stories.tsx | 8 +- .../reverification.controller.test.tsx | 5 +- .../__tests__/reverification.model.test.tsx | 118 ++++++--------- .../mosaic/features/reverification/index.ts | 1 + .../reverification.controller.ts | 35 ++--- .../reverification/reverification.model.tsx | 142 ++++++++---------- .../reverification/reverification.types.ts | 16 +- .../reverification/reverification.utils.ts | 10 +- .../reverification/reverification.view.tsx | 2 +- 9 files changed, 134 insertions(+), 203 deletions(-) diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx index 08cd2dc1aa6..6cb472f860b 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -40,10 +40,10 @@ const helpMessages = { }; const allMethods: ReverificationMethod[] = [ - { id: 'password', strategy: 'password' }, - { id: 'passkey', strategy: 'passkey' }, - { id: 'totp', strategy: 'totp' }, - { id: 'backup_code', strategy: 'backup_code' }, + { id: 'password', stage: 'first', strategy: 'password' }, + { id: 'passkey', stage: 'first', strategy: 'passkey' }, + { id: 'totp', stage: 'second', strategy: 'totp' }, + { id: 'backup_code', stage: 'second', strategy: 'backup_code' }, ]; function stepFor(id: string): ReverificationStep { diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index cfa752444a6..88e9ad3cefb 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -11,14 +11,15 @@ import { import type { ReverificationModel, ReverificationReadyModel } from '../reverification.model'; import type { ReverificationMethod, ReverificationResult } from '../reverification.types'; -const password: ReverificationMethod = { id: 'password', strategy: 'password' }; +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', strategy: 'totp' }; +const totp: ReverificationMethod = { id: 'totp', stage: 'second', strategy: 'totp' }; function firstFactorResult(overrides: Partial = {}): ReverificationResult { return { diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx index 63a99cd77d9..5b72c9f3c74 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx @@ -138,6 +138,7 @@ describe('useReverificationModel', () => { 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', @@ -172,7 +173,14 @@ describe('useReverificationModel', () => { 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?.strategy).toBe('totp'); + 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 () => { @@ -182,25 +190,35 @@ describe('useReverificationModel', () => { const { result } = renderHook(() => useReverificationModel(activeProps())); await ready(result.current).start(); - await ready(result.current).prepare( - { - id: 'email_code:idn_1', - strategy: 'email_code', - identifier: 'a***@ex.com', - emailAddressId: 'idn_1', - }, - 'needs_first_factor', - ); + 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', strategy: 'password' }, 'secret', 'needs_first_factor'); + 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 () => { @@ -216,86 +234,38 @@ describe('useReverificationModel', () => { const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: 'second_factor' })); await ready(result.current).start(); - await ready(result.current).prepare( - { id: 'phone_code:pn_1', strategy: 'phone_code', phoneNumberId: 'pn_1' }, - 'needs_second_factor', - ); + 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', strategy: 'totp' }, '123456', 'needs_second_factor'); + await ready(result.current).attempt({ id: 'totp', stage: 'second', strategy: 'totp' }, '123456'); expect(session?.attemptSecondFactorVerification).toHaveBeenCalledWith({ strategy: 'totp', code: '123456' }); }); it('no-ops prepare for methods that have no prepare step', async () => { const { result } = renderHook(() => useReverificationModel(activeProps())); - await ready(result.current).prepare({ id: 'password', strategy: 'password' }, 'needs_first_factor'); - await ready(result.current).prepare({ id: 'passkey', strategy: 'passkey' }, 'needs_first_factor'); - await ready(result.current).prepare({ id: 'totp', strategy: 'totp' }, 'needs_second_factor'); - await ready(result.current).prepare({ id: 'backup_code', strategy: 'backup_code' }, 'needs_second_factor'); - - expect(session?.prepareFirstFactorVerification).not.toHaveBeenCalled(); - expect(session?.prepareSecondFactorVerification).not.toHaveBeenCalled(); - }); - - it('rejects prepare when the method does not belong to the verification stage', async () => { - const { result } = renderHook(() => useReverificationModel(activeProps())); - const model = ready(result.current); + await ready(result.current).prepare({ id: 'password', stage: 'first', strategy: 'password' }); + await ready(result.current).prepare({ id: 'passkey', stage: 'first', strategy: 'passkey' }); + await ready(result.current).prepare({ id: 'totp', stage: 'second', strategy: 'totp' }); + await ready(result.current).prepare({ id: 'backup_code', stage: 'second', strategy: 'backup_code' }); - await expect(model.prepare({ id: 'totp', strategy: 'totp' }, 'needs_first_factor')).rejects.toThrow( - 'Cannot prepare totp when verification is needs_first_factor.', - ); - await expect(model.prepare({ id: 'password', strategy: 'password' }, 'needs_second_factor')).rejects.toThrow( - 'Cannot prepare password when verification is needs_second_factor.', - ); expect(session?.prepareFirstFactorVerification).not.toHaveBeenCalled(); expect(session?.prepareSecondFactorVerification).not.toHaveBeenCalled(); }); - it('rejects prepare when a code method is missing its factor id', async () => { - const { result } = renderHook(() => useReverificationModel(activeProps())); - const model = ready(result.current); - - await expect(model.prepare({ id: 'email_code:', strategy: 'email_code' }, 'needs_first_factor')).rejects.toThrow( - 'Cannot prepare email_code without an email address.', - ); - await expect(model.prepare({ id: 'phone_code:', strategy: 'phone_code' }, 'needs_first_factor')).rejects.toThrow( - 'Cannot prepare phone_code without a phone number.', - ); - await expect(model.prepare({ id: 'phone_code:', strategy: 'phone_code' }, 'needs_second_factor')).rejects.toThrow( - 'Cannot prepare phone_code without a phone number.', - ); - expect(session?.prepareFirstFactorVerification).not.toHaveBeenCalled(); - expect(session?.prepareSecondFactorVerification).not.toHaveBeenCalled(); - }); - - it('rejects attempt when the method does not belong to the verification stage', async () => { - const { result } = renderHook(() => useReverificationModel(activeProps())); - const model = ready(result.current); - - await expect(model.attempt({ id: 'passkey', strategy: 'passkey' }, 'x', 'needs_first_factor')).rejects.toThrow( - 'Cannot attempt passkey when verification is needs_first_factor.', - ); - await expect(model.attempt({ id: 'password', strategy: 'password' }, 'x', 'needs_second_factor')).rejects.toThrow( - 'Cannot attempt password when verification is needs_second_factor.', - ); - expect(session?.attemptFirstFactorVerification).not.toHaveBeenCalled(); - expect(session?.attemptSecondFactorVerification).not.toHaveBeenCalled(); - }); - - it('verifies a passkey only during first-factor verification', async () => { + it('verifies a passkey', async () => { session?.verifyWithPasskey.mockResolvedValue(resource({ status: 'complete' })); const { result } = renderHook(() => useReverificationModel(activeProps())); - const model = ready(result.current); - - await expect(model.verifyPasskey('needs_second_factor')).rejects.toThrow( - 'Cannot verify passkey when verification is needs_second_factor.', - ); - expect(session?.verifyWithPasskey).not.toHaveBeenCalled(); - await model.verifyPasskey('needs_first_factor'); + await ready(result.current).verifyPasskey(); expect(session?.verifyWithPasskey).toHaveBeenCalledOnce(); }); @@ -315,7 +285,7 @@ describe('useReverificationModel', () => { const { result } = renderHook(() => useReverificationModel(activeProps())); await expect( - ready(result.current).attempt({ id: 'password', strategy: 'password' }, 'bad', 'needs_first_factor'), + ready(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'bad'), ).rejects.toThrow('That password is incorrect.'); }); diff --git a/packages/ui/src/mosaic/features/reverification/index.ts b/packages/ui/src/mosaic/features/reverification/index.ts index 15f03750df7..4ac85e89457 100644 --- a/packages/ui/src/mosaic/features/reverification/index.ts +++ b/packages/ui/src/mosaic/features/reverification/index.ts @@ -3,6 +3,7 @@ export type { ReverificationMethod, ReverificationOtpChannel, ReverificationProps, + ReverificationStage, ReverificationStep, ReverificationStrategy, ReverificationViewProps, diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index fb36862fabb..368dfaa769b 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -5,12 +5,7 @@ import { setup } from '../../machine/setup'; import type { DoneInvokeEvent } from '../../machine/types'; import { useMachine } from '../../machine/useMachine'; import type { ReverificationModel } from './reverification.model'; -import type { - ReverificationFactorStatus, - ReverificationMethod, - ReverificationResult, - ReverificationViewProps, -} from './reverification.types'; +import type { ReverificationMethod, ReverificationResult, ReverificationViewProps } from './reverification.types'; import { needsPrepare, otpChannelFor } from './reverification.utils'; export type ReverificationController = @@ -23,13 +18,9 @@ type OverlayFrom = 'factor' | 'method-picker'; export type ReverificationDeps = { start: () => Promise; - prepare: (method: ReverificationMethod, verificationStatus: ReverificationFactorStatus) => Promise; - attempt: ( - method: ReverificationMethod, - value: string, - verificationStatus: ReverificationFactorStatus, - ) => Promise; - verifyPasskey: (verificationStatus: ReverificationFactorStatus) => Promise; + prepare: (method: ReverificationMethod) => Promise; + attempt: (method: ReverificationMethod, value: string) => Promise; + verifyPasskey: () => Promise; finish: () => Promise; cancel: () => void; }; @@ -44,7 +35,6 @@ interface ReverificationContext { abortRequested: boolean; overlayFrom: OverlayFrom; supportEmail: string; - verificationStatus: ReverificationFactorStatus | null; deps: ReverificationDeps; } @@ -85,21 +75,20 @@ function selectMethod(ctx: ReverificationContext, id: string): Partial { - const method = ctx.activeMethod as ReverificationMethod; - const verificationStatus = ctx.verificationStatus ?? 'needs_first_factor'; - return method.strategy === 'passkey' - ? ctx.deps.verifyPasskey(verificationStatus) - : ctx.deps.attempt(method, ctx.inputValue, verificationStatus); + const method = ctx.activeMethod; + if (!method) { + return Promise.reject(new Error('No active method')); + } + return method.strategy === 'passkey' ? ctx.deps.verifyPasskey() : ctx.deps.attempt(method, ctx.inputValue); } const applyResult = assign>((_, event) => ({ methods: event.output.methods, activeMethod: event.output.startingMethod, - verificationStatus: event.output.status === 'complete' ? null : event.output.status, inputValue: '', errorMessage: undefined, canResend: true, @@ -159,7 +148,6 @@ export const reverificationMachine = createMachine({ abortRequested: false, overlayFrom: 'factor', supportEmail: '', - verificationStatus: null, deps: unseatedDeps, }, states: { @@ -169,7 +157,6 @@ export const reverificationMachine = createMachine({ errorMessage: undefined, abortRequested: false, canResend: true, - verificationStatus: null, })), on: { START: 'starting' }, }, @@ -426,7 +413,7 @@ export function useReverificationController(model: ReverificationModel): Reverif methods: alternativeMethods, onSelectMethod: id => send({ type: 'SELECT_METHOD', id }), otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, - identifier: activeMethod?.identifier, + identifier: activeMethod && 'identifier' in activeMethod ? activeMethod.identifier : undefined, onResend: activeMethod && needsPrepare(activeMethod.strategy) && context.canResend ? () => send({ type: 'RESEND' }) diff --git a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx index c65bae6390a..547d688abe3 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx @@ -10,10 +10,10 @@ import { isWebAuthnSupported } from '@clerk/shared/webauthn'; import { useMosaicEnvironment } from '../../hooks/useMosaicEnvironment'; import type { - ReverificationFactorStatus, ReverificationMethod, ReverificationProps, ReverificationResult, + ReverificationStage, } from './reverification.types'; import { pickStartingMethod } from './reverification.utils'; @@ -22,13 +22,9 @@ export type ReverificationReadyModel = { isActive: boolean; supportEmail: string; start: () => Promise; - prepare: (method: ReverificationMethod, verificationStatus: ReverificationFactorStatus) => Promise; - attempt: ( - method: ReverificationMethod, - value: string, - verificationStatus: ReverificationFactorStatus, - ) => Promise; - verifyPasskey: (verificationStatus: ReverificationFactorStatus) => Promise; + prepare: (method: ReverificationMethod) => Promise; + attempt: (method: ReverificationMethod, value: string) => Promise; + verifyPasskey: () => Promise; finish: () => Promise; cancel: () => void; }; @@ -43,33 +39,32 @@ function toError(error: unknown): Error { return error instanceof Error ? error : new Error('Something went wrong. Please try again.'); } -// This only happens if there is a bug in the controller -function incompatible(action: 'prepare' | 'attempt' | 'verify', subject: string, status: string): Error { - return new Error(`Cannot ${action} ${subject} when verification is ${status}.`); -} - -function requireFactorId(value: string | undefined, strategy: 'email_code' | 'phone_code'): string { - if (!value) { - throw new Error( - strategy === 'email_code' - ? 'Cannot prepare email_code without an email address.' - : 'Cannot prepare phone_code without a phone number.', - ); - } - return value; -} - function toMethod( factor: SessionVerificationFirstFactor | SessionVerificationSecondFactor, + stage: ReverificationStage, webAuthnSupported: boolean, ): ReverificationMethod | null { - if (factor.strategy === 'passkey' && !webAuthnSupported) { - return null; + if (factor.strategy === 'passkey') { + if (stage !== 'first' || !webAuthnSupported) { + return null; + } + return { id: 'passkey', stage: 'first', strategy: 'passkey' }; + } + + if (factor.strategy === 'password') { + if (stage !== 'first') { + return null; + } + return { id: 'password', stage: 'first', strategy: 'password' }; } if (factor.strategy === 'email_code') { + if (stage !== 'first') { + return null; + } return { id: `email_code:${factor.emailAddressId}`, + stage: 'first', strategy: 'email_code', identifier: factor.safeIdentifier, emailAddressId: factor.emailAddressId, @@ -79,21 +74,21 @@ function toMethod( if (factor.strategy === 'phone_code') { return { id: `phone_code:${factor.phoneNumberId}`, + stage, strategy: 'phone_code', identifier: factor.safeIdentifier, phoneNumberId: factor.phoneNumberId, }; } - switch (factor.strategy) { - case 'password': - case 'passkey': - case 'totp': - case 'backup_code': - return { id: factor.strategy, strategy: factor.strategy }; - default: + if (factor.strategy === 'totp' || factor.strategy === 'backup_code') { + if (stage !== 'second') { return null; + } + return { id: factor.strategy, stage: 'second', strategy: factor.strategy }; } + + return null; } function toResult( @@ -105,16 +100,16 @@ function toResult( return { status: 'complete', methods: [], startingMethod: null }; } - const raw = - resource.status === 'needs_second_factor' ? resource.supportedSecondFactors : resource.supportedFirstFactors; + const stage: ReverificationStage = resource.status === 'needs_second_factor' ? 'second' : 'first'; + const raw = stage === 'second' ? resource.supportedSecondFactors : resource.supportedFirstFactors; const methods = (raw ?? []) - .map(factor => toMethod(factor, webAuthnSupported)) + .map(factor => toMethod(factor, stage, webAuthnSupported)) .filter((method): method is ReverificationMethod => method !== null); return { status: resource.status, methods, - startingMethod: pickStartingMethod(methods, resource.status, preferredSignInStrategy, webAuthnSupported), + startingMethod: pickStartingMethod(methods, preferredSignInStrategy, webAuthnSupported), }; } @@ -145,11 +140,8 @@ export function useReverificationModel(props: ReverificationProps): Reverificati throw toError(error); } }, - verifyPasskey: async verificationStatus => { + verifyPasskey: async () => { try { - if (verificationStatus !== 'needs_first_factor') { - throw incompatible('verify', 'passkey', verificationStatus); - } return handleResponse(await session.verifyWithPasskey()); } catch (error) { throw toError(error); @@ -158,74 +150,62 @@ export function useReverificationModel(props: ReverificationProps): Reverificati cancel: () => { cancel?.(); }, - prepare: async (method, verificationStatus) => { + prepare: async method => { try { - if (verificationStatus === 'needs_second_factor') { - switch (method.strategy) { - case 'phone_code': - await session.prepareSecondFactorVerification({ - strategy: 'phone_code', - phoneNumberId: requireFactorId(method.phoneNumberId, 'phone_code'), - }); - return; - case 'totp': - case 'backup_code': - return; - default: - throw incompatible('prepare', method.strategy, verificationStatus); - } - } - switch (method.strategy) { case 'email_code': await session.prepareFirstFactorVerification({ strategy: 'email_code', - emailAddressId: requireFactorId(method.emailAddressId, '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({ strategy: 'phone_code', - phoneNumberId: requireFactorId(method.phoneNumberId, 'phone_code'), + phoneNumberId: method.phoneNumberId, }); return; - case 'password': - case 'passkey': - return; default: - throw incompatible('prepare', method.strategy, verificationStatus); + return; } } catch (error) { throw toError(error); } }, - attempt: async (method, value, verificationStatus) => { + attempt: async (method, value) => { try { - if (verificationStatus === 'needs_second_factor') { - switch (method.strategy) { - case 'phone_code': - case 'totp': - case 'backup_code': - return handleResponse( - await session.attemptSecondFactorVerification({ strategy: method.strategy, code: value }), - ); - default: - throw incompatible('attempt', method.strategy, verificationStatus); - } - } - 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.attemptSecondFactorVerification({ strategy: 'phone_code', code: value }), + ); + } return handleResponse( - await session.attemptFirstFactorVerification({ strategy: method.strategy, code: value }), + await session.attemptFirstFactorVerification({ strategy: 'phone_code', code: value }), ); - default: - throw incompatible('attempt', method.strategy, verificationStatus); + 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); diff --git a/packages/ui/src/mosaic/features/reverification/reverification.types.ts b/packages/ui/src/mosaic/features/reverification/reverification.types.ts index e655487f190..ca0c1ff2cb0 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.types.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.types.ts @@ -4,17 +4,17 @@ import type { FlowDirection } from '../../components/flow'; export type ReverificationStrategy = 'password' | 'passkey' | 'email_code' | 'phone_code' | 'totp' | 'backup_code'; +export type ReverificationStage = 'first' | 'second'; + export type ReverificationOtpChannel = 'email' | 'phone' | 'totp'; export type ReverificationStep = 'password' | 'passkey' | 'otp' | 'backup-code' | 'method-picker' | 'help'; -export type ReverificationMethod = { - id: string; - strategy: ReverificationStrategy; - identifier?: string; - emailAddressId?: string; - phoneNumberId?: string; -}; +export type ReverificationMethod = + | { id: string; stage: 'first'; strategy: 'password' | 'passkey' } + | { id: string; stage: 'first'; strategy: 'email_code'; emailAddressId: string; identifier: string } + | { id: string; stage: 'first' | 'second'; strategy: 'phone_code'; phoneNumberId: string; identifier: string } + | { id: string; stage: 'second'; strategy: 'totp' | 'backup_code' }; export type ReverificationViewProps = { step: ReverificationStep; @@ -46,5 +46,3 @@ export type ReverificationResult = { methods: readonly ReverificationMethod[]; startingMethod: ReverificationMethod | null; }; - -export type ReverificationFactorStatus = 'needs_first_factor' | 'needs_second_factor'; diff --git a/packages/ui/src/mosaic/features/reverification/reverification.utils.ts b/packages/ui/src/mosaic/features/reverification/reverification.utils.ts index 0ab365af43b..78d391971b9 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.utils.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.utils.ts @@ -1,11 +1,6 @@ import type { PreferredSignInStrategy } from '@clerk/shared/types'; -import type { - ReverificationMethod, - ReverificationOtpChannel, - ReverificationResult, - ReverificationStrategy, -} from './reverification.types'; +import type { ReverificationMethod, ReverificationOtpChannel, ReverificationStrategy } from './reverification.types'; export function otpChannelFor(strategy: ReverificationStrategy): ReverificationOtpChannel | undefined { if (strategy === 'email_code') { @@ -60,11 +55,10 @@ function pickStartingSecondFactor(methods: readonly ReverificationMethod[]): Rev export function pickStartingMethod( methods: readonly ReverificationMethod[], - status: ReverificationResult['status'], preferredSignInStrategy: PreferredSignInStrategy | undefined, webAuthnSupported: boolean, ): ReverificationMethod | null { - return status === 'needs_second_factor' + return methods[0]?.stage === 'second' ? pickStartingSecondFactor(methods) : pickStartingFirstFactor(methods, preferredSignInStrategy, webAuthnSupported); } diff --git a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx index e04869c6600..f5659b6c387 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx @@ -26,7 +26,7 @@ const methodIcon = { } as const satisfies Record; function methodLabel(method: ReverificationMethod): string { - const identifier = method.identifier ?? ''; + const identifier = 'identifier' in method ? method.identifier : ''; switch (method.strategy) { case 'password': return m.alternativeMethods.blockButton__password; From 3a1c72fbfdd957486a169db1edea8d2c1085cab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 9 Sep 2026 14:49:43 +0200 Subject: [PATCH 16/33] Fix resend button --- .../src/stories/reverification.stories.tsx | 47 +++++++++++++++++-- .../reverification.controller.test.tsx | 34 +++++++++++++- .../__tests__/reverification.test.tsx | 3 ++ .../__tests__/reverification.view.test.tsx | 12 +++++ .../reverification.controller.ts | 7 +-- .../reverification/reverification.types.ts | 6 +-- .../reverification/reverification.view.tsx | 27 ++++++----- 7 files changed, 109 insertions(+), 27 deletions(-) diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx index 6cb472f860b..5627b6ba6a7 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -7,6 +7,7 @@ import { ReverificationMethodPicker } from '@clerk/ui/mosaic/features/reverifica import { ReverificationOTP } from '@clerk/ui/mosaic/features/reverification/panels/reverification-otp'; import { ReverificationPasskey } from '@clerk/ui/mosaic/features/reverification/panels/reverification-passkey'; import { ReverificationPassword } from '@clerk/ui/mosaic/features/reverification/panels/reverification-password'; +import { otpChannelFor } from '@clerk/ui/mosaic/features/reverification/reverification.utils'; import { ReverificationView } from '@clerk/ui/mosaic/features/reverification/reverification.view'; import { useRef, useState } from 'react'; @@ -42,18 +43,37 @@ const helpMessages = { const allMethods: ReverificationMethod[] = [ { id: 'password', stage: 'first', strategy: 'password' }, { id: 'passkey', stage: 'first', strategy: 'passkey' }, + { + id: 'email_code:idn_1', + stage: 'first', + strategy: 'email_code', + emailAddressId: 'idn_1', + identifier: 'a***@ex.com', + }, + { + id: 'phone_code:pn_1', + stage: 'first', + strategy: 'phone_code', + phoneNumberId: 'pn_1', + identifier: '+1••••1', + }, { id: 'totp', stage: 'second', strategy: 'totp' }, { id: 'backup_code', stage: 'second', strategy: 'backup_code' }, ]; +function methodById(id: string): ReverificationMethod | undefined { + return allMethods.find(method => method.id === id); +} + function stepFor(id: string): ReverificationStep { - if (id === 'passkey') { + const strategy = methodById(id)?.strategy; + if (strategy === 'passkey') { return 'passkey'; } - if (id === 'backup_code') { + if (strategy === 'backup_code') { return 'backup-code'; } - if (id === 'totp') { + if (strategy === 'email_code' || strategy === 'phone_code' || strategy === 'totp') { return 'otp'; } return 'password'; @@ -67,7 +87,9 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element const valueRef = useRef(value); const [errorMessage, setErrorMessage] = useState(); const [isPending, setIsPending] = useState(false); + const [canResend, setCanResend] = useState(true); const [supportRequested, setSupportRequested] = useState(false); + const activeMethod = methodById(methodId); const navigate = (next: ReverificationStep, nextDirection: -1 | 1) => { setDirection(nextDirection); @@ -75,6 +97,7 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element valueRef.current = ''; setValue(''); setErrorMessage(undefined); + setCanResend(true); }; const onValueChange = (nextValue: string) => { @@ -146,6 +169,20 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element navigate(stepFor(id), 1); }; + const onResend = () => { + if (!canResend || isPending) { + return; + } + void (async () => { + setIsPending(true); + await settleAfter(700); + setIsPending(false); + setCanResend(false); + await settleAfter(3_000); + setCanResend(true); + })(); + }; + const onSubmit = () => { if (step === 'backup-code') { void submitBackupCode(); @@ -170,6 +207,8 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element onSubmit={onSubmit} onVerifyPasskey={() => void submitPasskey()} onShowMethods={() => navigate('method-picker', 1)} + onResend={onResend} + canResend={canResend} onShowHelp={() => navigate('help', 1)} onBack={ step === 'help' @@ -181,7 +220,7 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element onEmailSupport={() => setSupportRequested(true)} methods={allMethods.filter(method => method.id !== methodId)} onSelectMethod={id => void selectMethod(id)} - otpChannel='totp' + otpChannel={activeMethod ? otpChannelFor(activeMethod.strategy) : undefined} /> {supportRequested ?

Email support requested.

: null} diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index 88e9ad3cefb..4c35e8a8599 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -237,7 +237,7 @@ describe('useReverificationController', () => { await waitFor(() => expect(result.current.status).toBe('unavailable')); }); - it('omits onShowMethods when only one method is available', async () => { + it('still passes onShowMethods when only one method is available', async () => { const { result } = renderHook(() => useReverificationController( readyModel({ @@ -250,10 +250,40 @@ describe('useReverificationController', () => { if (result.current.status !== 'ready') { throw new Error('expected ready'); } - expect(result.current.onShowMethods).toBeUndefined(); + 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.onResend).toEqual(expect.any(Function)); + expect(result.current.canResend).toBe(true); + + act(() => { + result.current.onResend(); + }); + + await waitFor(() => { + expect(result.current.status).toBe('ready'); + if (result.current.status === 'ready') { + expect(result.current.canResend).toBe(false); + expect(result.current.onResend).toEqual(expect.any(Function)); + } + }); + }); + it('marks the current step pending while an attempt is in flight', async () => { const attempt = deferred(); const { result } = renderHook(() => useReverificationController(readyModel({ attempt: () => attempt.promise }))); diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx index 0d3495d067d..d30ccbf7173 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx @@ -48,8 +48,11 @@ function ready(overrides: Partial = {}): Reverification isPending: false, onSubmit: vi.fn(), onVerifyPasskey: vi.fn(), + onShowMethods: vi.fn(), onShowHelp: vi.fn(), onEmailSupport: vi.fn(), + onResend: vi.fn(), + canResend: true, methods: [], onSelectMethod: vi.fn(), ...overrides, diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx index bc17987a3f7..b7769f1e60b 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx @@ -16,6 +16,8 @@ function viewProps(overrides: Partial = {}): Reverifica onShowMethods: vi.fn(), onShowHelp: vi.fn(), onEmailSupport: vi.fn(), + onResend: vi.fn(), + canResend: true, methods: [], onSelectMethod: vi.fn(), ...overrides, @@ -66,6 +68,16 @@ describe('ReverificationView', () => { expect(otpStep?.style.getPropertyValue('--cl-flow-transition-direction')).toBe('-1'); }); + it('keeps a disabled resend control during the cooldown', () => { + renderView({ + step: 'otp', + otpChannel: 'email', + canResend: false, + }); + + expect(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })).toBeDisabled(); + }); + it('does not render Card branding', () => { renderView(); diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index 368dfaa769b..75662ec460b 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -402,7 +402,7 @@ export function useReverificationController(model: ReverificationModel): Reverif isPending: pendingStates.has(snapshot.value), onSubmit: () => send({ type: 'SUBMIT' }), onVerifyPasskey: () => send({ type: 'SUBMIT' }), - onShowMethods: alternativeMethods.length > 0 ? () => send({ type: 'SHOW_METHODS' }) : undefined, + onShowMethods: () => send({ type: 'SHOW_METHODS' }), onShowHelp: () => send({ type: 'SHOW_HELP' }), onBack: step === 'method-picker' || step === 'help' ? () => send({ type: 'BACK' }) : undefined, onEmailSupport: () => { @@ -414,10 +414,7 @@ export function useReverificationController(model: ReverificationModel): Reverif onSelectMethod: id => send({ type: 'SELECT_METHOD', id }), otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, identifier: activeMethod && 'identifier' in activeMethod ? activeMethod.identifier : undefined, - onResend: - activeMethod && needsPrepare(activeMethod.strategy) && context.canResend - ? () => send({ type: 'RESEND' }) - : undefined, + onResend: () => send({ type: 'RESEND' }), canResend: context.canResend, }; } diff --git a/packages/ui/src/mosaic/features/reverification/reverification.types.ts b/packages/ui/src/mosaic/features/reverification/reverification.types.ts index ca0c1ff2cb0..52ba78a4993 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.types.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.types.ts @@ -25,7 +25,7 @@ export type ReverificationViewProps = { isPending: boolean; onSubmit: () => void; onVerifyPasskey: () => void; - onShowMethods?: () => void; + onShowMethods: () => void; onShowHelp: () => void; onBack?: () => void; onEmailSupport: () => void; @@ -33,8 +33,8 @@ export type ReverificationViewProps = { onSelectMethod: (id: string) => void; otpChannel?: ReverificationOtpChannel; identifier?: string; - onResend?: () => void; - canResend?: boolean; + onResend: () => void; + canResend: boolean; }; export type ReverificationProps = diff --git a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx index f5659b6c387..78eb63708a0 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx @@ -75,6 +75,15 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element } = props; const otp = otpCopy(otpChannel); + const hasAlternatives = methods.length > 0; + const resend = + otpChannel === 'email' || otpChannel === 'phone' + ? { + label: otpChannel === 'phone' ? m.phoneCode.resendButton : m.emailCode.resendButton, + disabled: !canResend || isPending, + onClick: onResend, + } + : undefined; return ( @@ -99,7 +108,7 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element isPending={isPending} onValueChange={onValueChange} onSubmit={onSubmit} - onCancel={onShowMethods} + onCancel={hasAlternatives ? onShowMethods : undefined} /> @@ -113,7 +122,7 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element errorMessage={errorMessage} isPending={isPending} onVerify={onVerifyPasskey} - onCancel={onShowMethods} + onCancel={hasAlternatives ? onShowMethods : undefined} /> @@ -128,22 +137,14 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element value={value} errorMessage={errorMessage} isPending={isPending} - resend={ - onResend - ? { - label: otpChannel === 'phone' ? m.phoneCode.resendButton : m.emailCode.resendButton, - disabled: !canResend || isPending, - onClick: onResend, - } - : undefined - } + resend={resend} onValueChange={onValueChange} onComplete={code => { onValueChange(code); onSubmit(); }} onSubmit={onSubmit} - onCancel={onShowMethods} + onCancel={hasAlternatives ? onShowMethods : undefined} /> @@ -160,7 +161,7 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element isPending={isPending} onValueChange={onValueChange} onSubmit={onSubmit} - onCancel={onShowMethods} + onCancel={hasAlternatives ? onShowMethods : undefined} /> From cd24101162cb1475aef575b55764832529ff6c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 9 Sep 2026 14:53:12 +0200 Subject: [PATCH 17/33] Type improvements --- .../reverification.controller.ts | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index 75662ec460b..82cb75cc288 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -4,7 +4,7 @@ import type { FlowDirection } from '../../components/flow'; import { setup } from '../../machine/setup'; import type { DoneInvokeEvent } from '../../machine/types'; import { useMachine } from '../../machine/useMachine'; -import type { ReverificationModel } from './reverification.model'; +import type { ReverificationModel, ReverificationReadyModel } from './reverification.model'; import type { ReverificationMethod, ReverificationResult, ReverificationViewProps } from './reverification.types'; import { needsPrepare, otpChannelFor } from './reverification.utils'; @@ -16,14 +16,10 @@ export type ReverificationController = type OverlayFrom = 'factor' | 'method-picker'; -export type ReverificationDeps = { - start: () => Promise; - prepare: (method: ReverificationMethod) => Promise; - attempt: (method: ReverificationMethod, value: string) => Promise; - verifyPasskey: () => Promise; - finish: () => Promise; - cancel: () => void; -}; +export type ReverificationDeps = Pick< + ReverificationReadyModel, + 'start' | 'prepare' | 'attempt' | 'verifyPasskey' | 'finish' | 'cancel' +>; interface ReverificationContext { inputValue: string; @@ -52,12 +48,16 @@ type ReverificationEvent = const { createMachine, assign, fromPromise } = setup(); +function notSeated(): Promise { + return Promise.reject(new Error('reverification deps are not seated')); +} + const unseatedDeps: ReverificationDeps = { - start: () => Promise.reject(new Error('start is not seated')), - prepare: () => Promise.resolve(), - attempt: () => Promise.reject(new Error('attempt is not seated')), - verifyPasskey: () => Promise.reject(new Error('verifyPasskey is not seated')), - finish: () => Promise.resolve(), + start: notSeated, + prepare: notSeated, + attempt: notSeated, + verifyPasskey: notSeated, + finish: notSeated, cancel: () => {}, }; @@ -350,14 +350,7 @@ export function useReverificationController(model: ReverificationModel): Reverif ? { context: { supportEmail: ready.supportEmail, - deps: { - start: ready.start, - prepare: ready.prepare, - attempt: ready.attempt, - verifyPasskey: ready.verifyPasskey, - finish: ready.finish, - cancel: ready.cancel, - }, + deps: ready, }, } : undefined, From 7277830d154861dd5d2c5f85cf03617d894791fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 9 Sep 2026 16:45:04 +0200 Subject: [PATCH 18/33] Remove unnecessary abort --- .../reverification.controller.test.tsx | 17 +++++++++----- .../reverification.controller.ts | 22 ++++++------------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index 4c35e8a8599..9ab8399503a 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -133,7 +133,7 @@ describe('reverificationMachine', () => { expect(actor.getSnapshot().value).toBe('methodPicker'); }); - it('does not leave submitting when abort is requested until the attempt settles', async () => { + 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 })); @@ -142,7 +142,7 @@ describe('reverificationMachine', () => { actor.send({ type: 'SUBMIT' }); expect(actor.getSnapshot().value).toBe('submitting'); - actor.send({ type: 'ABORT' }); + actor.send({ type: 'RESET' }); expect(actor.getSnapshot().value).toBe('submitting'); expect(cancel).not.toHaveBeenCalled(); @@ -152,7 +152,7 @@ describe('reverificationMachine', () => { expect(cancel).toHaveBeenCalledOnce(); }); - it('cancels a successful attempt that settled after abort was requested', async () => { + it('cancels a successful attempt that settled after reset', async () => { const attempt = deferred(); const finish = vi.fn(async () => {}); const cancel = vi.fn(); @@ -160,7 +160,7 @@ describe('reverificationMachine', () => { await tick(); actor.send({ type: 'TYPE', value: 'secret' }); actor.send({ type: 'SUBMIT' }); - actor.send({ type: 'ABORT' }); + actor.send({ type: 'RESET' }); attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); await tick(); @@ -220,21 +220,28 @@ describe('useReverificationController', () => { }); 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'))) })), + 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 () => { diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index 82cb75cc288..959a98cc61c 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -43,8 +43,7 @@ type ReverificationEvent = | { type: 'SHOW_HELP' } | { type: 'SELECT_METHOD'; id: string } | { type: 'BACK' } - | { type: 'RESEND' } - | { type: 'ABORT' }; + | { type: 'RESEND' }; const { createMachine, assign, fromPromise } = setup(); @@ -117,13 +116,6 @@ const afterResult = [ { target: 'verifying' as const, actions: applyResult }, ]; -const abortNow = { - target: 'done' as const, - actions: (ctx: ReverificationContext) => { - ctx.deps.cancel(); - }, -}; - const abortAfterInvoke = { target: 'done' as const, guard: (ctx: ReverificationContext) => ctx.abortRequested, @@ -170,7 +162,7 @@ export const reverificationMachine = createMachine({ }, preparing: { - on: { RESET: 'inactive', ABORT: abortNow }, + on: { RESET: 'inactive' }, invoke: fromPromise(prepareActive, { onDone: 'verifying', onError: { @@ -181,6 +173,7 @@ export const reverificationMachine = createMachine({ }, verifying: { + always: [{ guard: ctx => ctx.activeMethod === null, target: 'unavailable' }], after: { 30_000: { guard: ctx => !ctx.canResend, actions: assign(() => ({ canResend: true })) }, }, @@ -202,14 +195,12 @@ export const reverificationMachine = createMachine({ target: 'help', actions: assign(() => ({ direction: 1 as const, overlayFrom: 'factor' as const })), }, - ABORT: abortNow, RESET: 'inactive', }, }, submitting: { on: { - ABORT: { actions: assign(() => ({ abortRequested: true })) }, RESET: { actions: assign(() => ({ abortRequested: true })) }, }, invoke: fromPromise(submit, { @@ -256,7 +247,6 @@ export const reverificationMachine = createMachine({ actions: assign(() => ({ direction: 1 as const, overlayFrom: 'method-picker' as const })), }, BACK: { target: 'verifying', actions: assign(() => ({ direction: -1 as const })) }, - ABORT: abortNow, RESET: 'inactive', }, }, @@ -271,13 +261,15 @@ export const reverificationMachine = createMachine({ }, { target: 'verifying', actions: assign(() => ({ direction: -1 as const })) }, ], - ABORT: abortNow, RESET: 'inactive', }, }, unavailable: { - on: { ABORT: abortNow, RESET: 'inactive' }, + entry: (ctx: ReverificationContext) => { + ctx.deps.cancel(); + }, + on: { RESET: 'inactive' }, }, completing: { From f1fc88fad5132c9b12f8f9264cfe46643df7be8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 12:18:16 +0200 Subject: [PATCH 19/33] Fix inconsistent preparing loading states --- .../reverification.controller.test.tsx | 204 +++++++++++++++++- .../panels/reverification-method-picker.tsx | 8 +- .../reverification.controller.ts | 89 +++++++- .../reverification/reverification.types.ts | 1 + .../reverification/reverification.view.tsx | 2 + 5 files changed, 291 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index 9ab8399503a..aef0ee66966 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -91,7 +91,7 @@ describe('reverificationMachine', () => { actor.send({ type: 'SHOW_METHODS' }); expect(actor.getSnapshot().value).toBe('methodPicker'); actor.send({ type: 'SELECT_METHOD', id: email.id }); - expect(actor.getSnapshot().value).toBe('preparing'); + expect(actor.getSnapshot().value).toBe('methodPickerPreparing'); await tick(); expect(prepare).toHaveBeenCalledOnce(); expect(actor.getSnapshot().value).toBe('verifying'); @@ -202,6 +202,51 @@ describe('reverificationMachine', () => { 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.canResend).toBe(false); + + 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.canResend).toBe(true); + }); + + 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'); + }); }); describe('useReverificationController', () => { @@ -271,12 +316,16 @@ describe('useReverificationController', () => { ), ); - await waitFor(() => expect(result.current.status).toBe('ready')); + await waitFor(() => { + expect(result.current.status).toBe('ready'); + if (result.current.status === 'ready') { + expect(result.current.canResend).toBe(true); + } + }); if (result.current.status !== 'ready') { throw new Error('expected ready'); } expect(result.current.onResend).toEqual(expect.any(Function)); - expect(result.current.canResend).toBe(true); act(() => { result.current.onResend(); @@ -453,4 +502,153 @@ describe('useReverificationController', () => { 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(true); + 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(); + } + }); + }); + + 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(); + } + }); + }); }); diff --git a/packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx index 8900fa47b70..71991474ad6 100644 --- a/packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx +++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx @@ -80,6 +80,7 @@ export function ReverificationMethodPicker({ type='button' variant='ghost' fullWidth + disabled={Boolean(pendingMethodId)} onClick={onBack} > {messages.backButton} @@ -97,7 +98,12 @@ export function ReverificationMethodPicker({ type='button' size='sm' variant='link' - onClick={onHelp} + disabled={Boolean(pendingMethodId)} + onClick={() => { + if (!pendingMethodId) { + onHelp(); + } + }} > {messages.helpButton} diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index 959a98cc61c..f6322e870db 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -29,6 +29,8 @@ interface ReverificationContext { methods: readonly ReverificationMethod[]; canResend: boolean; abortRequested: boolean; + submitRequested: boolean; + frozenActiveMethodId: string | undefined; overlayFrom: OverlayFrom; supportEmail: string; deps: ReverificationDeps; @@ -70,6 +72,7 @@ function selectMethod(ctx: ReverificationContext, id: string): Partial ({ canResend: false }))], }, { target: 'verifying' as const, actions: applyResult }, ]; @@ -138,6 +141,8 @@ export const reverificationMachine = createMachine({ methods: [], canResend: true, abortRequested: false, + submitRequested: false, + frozenActiveMethodId: undefined, overlayFrom: 'factor', supportEmail: '', deps: unseatedDeps, @@ -148,6 +153,7 @@ export const reverificationMachine = createMachine({ inputValue: '', errorMessage: undefined, abortRequested: false, + submitRequested: false, canResend: true, })), on: { START: 'starting' }, @@ -161,13 +167,69 @@ export const reverificationMachine = createMachine({ }), }, + /* + Preparing a factor behind the scenes (e.g. sending an OTP) + + This shows the factor card with optimistic UI, so it needs to handle the same actions + as verifying, but if the user submits, we queue that up until after prepare resolves + */ preparing: { + on: { + TYPE: { + actions: assign((_, event) => ({ inputValue: event.value, errorMessage: undefined })), + }, + SUBMIT: { + actions: assign(() => ({ submitRequested: true })), + }, + SHOW_METHODS: { + target: 'methodPicker', + guard: ctx => ctx.methods.filter(method => method.id !== ctx.activeMethod?.id).length > 0, + actions: assign(ctx => ({ + direction: 1 as const, + overlayFrom: 'factor' as const, + submitRequested: false, + frozenActiveMethodId: ctx.activeMethod?.id, + })), + }, + SHOW_HELP: { + target: 'help', + actions: assign(() => ({ direction: 1 as const, overlayFrom: 'factor' as const, submitRequested: false })), + }, + RESET: 'inactive', + }, + invoke: fromPromise(prepareActive, { + onDone: [ + { + guard: (ctx: ReverificationContext) => ctx.submitRequested, + target: 'submitting' as const, + actions: assign(() => ({ submitRequested: false, canResend: true })), + }, + { target: 'verifying' as const, actions: assign(() => ({ canResend: true })) }, + ], + onError: { + target: 'verifying', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + submitRequested: false, + canResend: true, + })), + }, + }), + }, + + /* + When selecting a method from the picker, we prepare before going to the factor card + + We don't use optimistic UI here simply because we're in a better position to show + some feedback that does not feel janky. The view handles disabling inputs while preparing. + */ + methodPickerPreparing: { on: { RESET: 'inactive' }, invoke: fromPromise(prepareActive, { - onDone: 'verifying', + onDone: { target: 'verifying', actions: assign(() => ({ canResend: true })) }, onError: { target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), + actions: assign((_, event) => ({ errorMessage: errorMessage(event.error), canResend: true })), }, }), }, @@ -189,7 +251,11 @@ export const reverificationMachine = createMachine({ SHOW_METHODS: { target: 'methodPicker', guard: ctx => ctx.methods.filter(method => method.id !== ctx.activeMethod?.id).length > 0, - actions: assign(() => ({ direction: 1 as const, overlayFrom: 'factor' as const })), + actions: assign(ctx => ({ + direction: 1 as const, + overlayFrom: 'factor' as const, + frozenActiveMethodId: ctx.activeMethod?.id, + })), }, SHOW_HELP: { target: 'help', @@ -233,7 +299,7 @@ export const reverificationMachine = createMachine({ on: { SELECT_METHOD: [ { - target: 'preparing', + target: 'methodPickerPreparing', guard: (ctx, event) => { const method = ctx.methods.find(candidate => candidate.id === event.id); return Boolean(method && needsPrepare(method.strategy)); @@ -289,10 +355,10 @@ export const reverificationMachine = createMachine({ }, }); -const pendingStates = new Set(['starting', 'preparing', 'submitting', 'resending', 'completing']); +const pendingStates = new Set(['submitting', 'resending', 'completing']); function viewStep(value: string, method: ReverificationMethod | null): ReverificationViewProps['step'] | undefined { - if (value === 'methodPicker') { + if (value === 'methodPicker' || value === 'methodPickerPreparing') { return 'method-picker'; } if (value === 'help') { @@ -375,7 +441,11 @@ export function useReverificationController(model: ReverificationModel): Reverif } const activeMethod = context.activeMethod; - const alternativeMethods = context.methods.filter(method => method.id !== activeMethod?.id); + // 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 methods = context.methods.filter(method => method.id !== excludeId); + const pendingMethodId = snapshot.value === 'methodPickerPreparing' ? activeMethod?.id : undefined; return { status: 'ready', @@ -395,7 +465,8 @@ export function useReverificationController(model: ReverificationModel): Reverif window.location.assign(`mailto:${context.supportEmail}`); } }, - methods: alternativeMethods, + methods, + pendingMethodId, onSelectMethod: id => send({ type: 'SELECT_METHOD', id }), otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, identifier: activeMethod && 'identifier' in activeMethod ? activeMethod.identifier : undefined, diff --git a/packages/ui/src/mosaic/features/reverification/reverification.types.ts b/packages/ui/src/mosaic/features/reverification/reverification.types.ts index 52ba78a4993..b573c8622d7 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.types.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.types.ts @@ -30,6 +30,7 @@ export type ReverificationViewProps = { onBack?: () => void; onEmailSupport: () => void; methods: readonly ReverificationMethod[]; + pendingMethodId?: string; onSelectMethod: (id: string) => void; otpChannel?: ReverificationOtpChannel; identifier?: string; diff --git a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx index 78eb63708a0..26aae147f1f 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx @@ -68,6 +68,7 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element onBack, onEmailSupport, methods, + pendingMethodId, onSelectMethod, otpChannel, onResend, @@ -179,6 +180,7 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element label: methodLabel(method), icon: methodIcon[method.strategy], }))} + pendingMethodId={pendingMethodId} onSelect={onSelectMethod} onHelp={onShowHelp} onBack={onBack} From 6baa8dae83c2f759a4786a2577ca3da0a0ba649f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 12:28:29 +0200 Subject: [PATCH 20/33] Fix resend behavior --- .../__tests__/reverification.controller.test.tsx | 3 +++ .../features/reverification/reverification.controller.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index aef0ee66966..242ddf1a078 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -96,6 +96,7 @@ describe('reverificationMachine', () => { expect(prepare).toHaveBeenCalledOnce(); expect(actor.getSnapshot().value).toBe('verifying'); expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('email_code'); + expect(actor.getSnapshot().context.canResend).toBe(false); actor.send({ type: 'SHOW_METHODS' }); actor.send({ type: 'BACK' }); @@ -617,6 +618,7 @@ describe('useReverificationController', () => { if (result.current.status === 'ready') { expect(result.current.step).toBe('otp'); expect(result.current.pendingMethodId).toBeUndefined(); + expect(result.current.canResend).toBe(false); } }); }); @@ -648,6 +650,7 @@ describe('useReverificationController', () => { 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/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index f6322e870db..90fc1f165d9 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -94,6 +94,7 @@ const applyResult = assign>((_, event) => inputValue: '', errorMessage: undefined, canResend: true, + direction: 1, })); const afterResult = [ { @@ -226,7 +227,7 @@ export const reverificationMachine = createMachine({ methodPickerPreparing: { on: { RESET: 'inactive' }, invoke: fromPromise(prepareActive, { - onDone: { target: 'verifying', actions: assign(() => ({ canResend: true })) }, + onDone: { target: 'verifying', actions: assign(() => ({ canResend: false })) }, onError: { target: 'verifying', actions: assign((_, event) => ({ errorMessage: errorMessage(event.error), canResend: true })), From b9144bfe84e4e404c995f44383363ad311b10afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 12:40:23 +0200 Subject: [PATCH 21/33] Fix resend for real --- .../reverification.controller.test.tsx | 24 ++++--------------- .../reverification.controller.ts | 7 +++--- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index 242ddf1a078..e54e18271df 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -222,7 +222,7 @@ describe('reverificationMachine', () => { prepare.resolve(); await tick(); expect(actor.getSnapshot().value).toBe('verifying'); - expect(actor.getSnapshot().context.canResend).toBe(true); + expect(actor.getSnapshot().context.canResend).toBe(false); }); it('queues an attempt submitted while the starting prepare is in flight', async () => { @@ -317,28 +317,12 @@ describe('useReverificationController', () => { ), ); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.canResend).toBe(true); - } - }); + 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)); - - act(() => { - result.current.onResend(); - }); - - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.canResend).toBe(false); - expect(result.current.onResend).toEqual(expect.any(Function)); - } - }); }); it('marks the current step pending while an attempt is in flight', async () => { @@ -539,7 +523,7 @@ describe('useReverificationController', () => { await waitFor(() => { expect(result.current.status).toBe('ready'); if (result.current.status === 'ready') { - expect(result.current.canResend).toBe(true); + expect(result.current.canResend).toBe(false); expect(result.current.isPending).toBe(false); } }); diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index 90fc1f165d9..5c2d782c480 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -203,9 +203,9 @@ export const reverificationMachine = createMachine({ { guard: (ctx: ReverificationContext) => ctx.submitRequested, target: 'submitting' as const, - actions: assign(() => ({ submitRequested: false, canResend: true })), + actions: assign(() => ({ submitRequested: false })), }, - { target: 'verifying' as const, actions: assign(() => ({ canResend: true })) }, + { target: 'verifying' as const }, ], onError: { target: 'verifying', @@ -225,9 +225,10 @@ export const reverificationMachine = createMachine({ some feedback that does not feel janky. The view handles disabling inputs while preparing. */ methodPickerPreparing: { + entry: assign(() => ({ canResend: false })), on: { RESET: 'inactive' }, invoke: fromPromise(prepareActive, { - onDone: { target: 'verifying', actions: assign(() => ({ canResend: false })) }, + onDone: 'verifying', onError: { target: 'verifying', actions: assign((_, event) => ({ errorMessage: errorMessage(event.error), canResend: true })), From cf14d33743bd9b46f004a45107720ced3a1400ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 12:54:37 +0200 Subject: [PATCH 22/33] Add timer to resend --- .../reverification.controller.test.tsx | 4 ++ .../__tests__/reverification.view.test.tsx | 3 +- .../reverification.controller.ts | 44 +++++++++++++++---- .../reverification/reverification.types.ts | 1 + .../reverification/reverification.view.tsx | 4 +- 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index e54e18271df..22cee3d72ab 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -97,6 +97,7 @@ describe('reverificationMachine', () => { expect(actor.getSnapshot().value).toBe('verifying'); expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('email_code'); expect(actor.getSnapshot().context.canResend).toBe(false); + expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); actor.send({ type: 'SHOW_METHODS' }); actor.send({ type: 'BACK' }); @@ -223,6 +224,7 @@ describe('reverificationMachine', () => { await tick(); expect(actor.getSnapshot().value).toBe('verifying'); expect(actor.getSnapshot().context.canResend).toBe(false); + expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); }); it('queues an attempt submitted while the starting prepare is in flight', async () => { @@ -323,6 +325,8 @@ describe('useReverificationController', () => { } 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 () => { diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx index b7769f1e60b..e275e33a74b 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx @@ -73,9 +73,10 @@ describe('ReverificationView', () => { step: 'otp', otpChannel: 'email', canResend: false, + resendRemainingSeconds: 23, }); - expect(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (23)' })).toBeDisabled(); }); it('does not render Card branding', () => { diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index 5c2d782c480..3cf765e235f 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import type { FlowDirection } from '../../components/flow'; import { setup } from '../../machine/setup'; @@ -28,6 +28,7 @@ interface ReverificationContext { activeMethod: ReverificationMethod | null; methods: readonly ReverificationMethod[]; canResend: boolean; + resendAvailableAt: number | undefined; abortRequested: boolean; submitRequested: boolean; frozenActiveMethodId: string | undefined; @@ -62,6 +63,16 @@ const unseatedDeps: ReverificationDeps = { cancel: () => {}, }; +export const RESEND_COOLDOWN_MS = 30_000; + +function lockResend(): Pick { + return { canResend: false, resendAvailableAt: Date.now() + RESEND_COOLDOWN_MS }; +} + +function unlockResend(): Pick { + return { canResend: true, resendAvailableAt: undefined }; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : 'Something went wrong. Please try again.'; } @@ -93,7 +104,7 @@ const applyResult = assign>((_, event) => activeMethod: event.output.startingMethod, inputValue: '', errorMessage: undefined, - canResend: true, + ...unlockResend(), direction: 1, })); const afterResult = [ @@ -115,7 +126,7 @@ const afterResult = [ return Boolean(strategy && needsPrepare(strategy)); }, target: 'preparing' as const, - actions: [applyResult, assign(() => ({ canResend: false }))], + actions: [applyResult, assign(() => lockResend())], }, { target: 'verifying' as const, actions: applyResult }, ]; @@ -141,6 +152,7 @@ export const reverificationMachine = createMachine({ activeMethod: null, methods: [], canResend: true, + resendAvailableAt: undefined, abortRequested: false, submitRequested: false, frozenActiveMethodId: undefined, @@ -155,7 +167,7 @@ export const reverificationMachine = createMachine({ errorMessage: undefined, abortRequested: false, submitRequested: false, - canResend: true, + ...unlockResend(), })), on: { START: 'starting' }, }, @@ -212,7 +224,7 @@ export const reverificationMachine = createMachine({ actions: assign((_, event) => ({ errorMessage: errorMessage(event.error), submitRequested: false, - canResend: true, + ...unlockResend(), })), }, }), @@ -225,13 +237,13 @@ export const reverificationMachine = createMachine({ some feedback that does not feel janky. The view handles disabling inputs while preparing. */ methodPickerPreparing: { - entry: assign(() => ({ canResend: false })), + entry: assign(() => lockResend()), on: { RESET: 'inactive' }, invoke: fromPromise(prepareActive, { onDone: 'verifying', onError: { target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error), canResend: true })), + actions: assign((_, event) => ({ errorMessage: errorMessage(event.error), ...unlockResend() })), }, }), }, @@ -239,7 +251,7 @@ export const reverificationMachine = createMachine({ verifying: { always: [{ guard: ctx => ctx.activeMethod === null, target: 'unavailable' }], after: { - 30_000: { guard: ctx => !ctx.canResend, actions: assign(() => ({ canResend: true })) }, + [RESEND_COOLDOWN_MS]: { guard: ctx => !ctx.canResend, actions: assign(() => unlockResend()) }, }, on: { TYPE: { @@ -288,7 +300,7 @@ export const reverificationMachine = createMachine({ invoke: fromPromise(prepareActive, { onDone: { target: 'verifying', - actions: assign(() => ({ canResend: false, inputValue: '', errorMessage: undefined })), + actions: assign(() => ({ ...lockResend(), inputValue: '', errorMessage: undefined })), }, onError: { target: 'verifying', @@ -416,6 +428,18 @@ export function useReverificationController(model: ReverificationModel): Reverif : undefined, ); + const [now, setNow] = useState(() => Date.now()); + const resendAvailableAt = snapshot.context.resendAvailableAt; + const countingDown = Boolean(resendAvailableAt && !snapshot.context.canResend); + + useEffect(() => { + if (!countingDown) { + return; + } + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [countingDown, resendAvailableAt]); + useEffect(() => { if (model.isActive && ready && snapshot.value === 'inactive') { send({ type: 'START' }); @@ -474,5 +498,7 @@ export function useReverificationController(model: ReverificationModel): Reverif identifier: activeMethod && 'identifier' in activeMethod ? activeMethod.identifier : undefined, onResend: () => send({ type: 'RESEND' }), canResend: context.canResend, + resendRemainingSeconds: + countingDown && resendAvailableAt ? Math.max(0, Math.ceil((resendAvailableAt - now) / 1000)) : undefined, }; } diff --git a/packages/ui/src/mosaic/features/reverification/reverification.types.ts b/packages/ui/src/mosaic/features/reverification/reverification.types.ts index b573c8622d7..31021c9a679 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.types.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.types.ts @@ -36,6 +36,7 @@ export type ReverificationViewProps = { identifier?: string; onResend: () => void; canResend: boolean; + resendRemainingSeconds?: number; }; export type ReverificationProps = diff --git a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx index 26aae147f1f..4483afd542a 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx @@ -73,14 +73,16 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element otpChannel, onResend, canResend, + resendRemainingSeconds, } = props; const otp = otpCopy(otpChannel); const hasAlternatives = methods.length > 0; + const resendLabel = otpChannel === 'phone' ? m.phoneCode.resendButton : m.emailCode.resendButton; const resend = otpChannel === 'email' || otpChannel === 'phone' ? { - label: otpChannel === 'phone' ? m.phoneCode.resendButton : m.emailCode.resendButton, + label: resendRemainingSeconds ? `${resendLabel} (${resendRemainingSeconds})` : resendLabel, disabled: !canResend || isPending, onClick: onResend, } From f81039d9ef986b72465c6d2ced5407ae059a9495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 13:54:01 +0200 Subject: [PATCH 23/33] Add useMosaicSupportEmail hook --- .../__tests__/reverification.model.test.tsx | 16 +++- .../reverification/reverification.model.tsx | 6 +- .../__tests__/useMosaicSupportEmail.test.ts | 86 +++++++++++++++++++ .../src/mosaic/hooks/useMosaicSupportEmail.ts | 39 +++++++++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/mosaic/hooks/__tests__/useMosaicSupportEmail.test.ts create mode 100644 packages/ui/src/mosaic/hooks/useMosaicSupportEmail.ts diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx index 5b72c9f3c74..b487ae30ddc 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx @@ -32,12 +32,12 @@ let session: | undefined; let environmentHydrated: boolean; let preferredSignInStrategy: PreferredSignInStrategy; -let supportEmail: string; +let supportEmail: string | undefined; let webAuthnSupported: boolean; let setActive: ReturnType; function environment() { - return environmentHydrated ? { displayConfig: { preferredSignInStrategy, supportEmail } } : undefined; + return environmentHydrated ? { displayConfig: { preferredSignInStrategy } } : undefined; } vi.mock('@clerk/shared/react', async importOriginal => { @@ -53,6 +53,10 @@ vi.mock('../../../hooks/useMosaicEnvironment', () => ({ useMosaicEnvironment: () => environment(), })); +vi.mock('../../../hooks/useMosaicSupportEmail', () => ({ + useMosaicSupportEmail: () => supportEmail, +})); + vi.mock('@clerk/shared/webauthn', () => ({ isWebAuthnSupported: () => webAuthnSupported, })); @@ -102,9 +106,17 @@ describe('useReverificationModel', () => { 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', () => { diff --git a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx index 547d688abe3..63981854006 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx @@ -9,6 +9,7 @@ import type { import { isWebAuthnSupported } from '@clerk/shared/webauthn'; import { useMosaicEnvironment } from '../../hooks/useMosaicEnvironment'; +import { useMosaicSupportEmail } from '../../hooks/useMosaicSupportEmail'; import type { ReverificationMethod, ReverificationProps, @@ -117,9 +118,10 @@ export function useReverificationModel(props: ReverificationProps): Reverificati const { session } = useSession(); const clerk = useClerk(); const environment = useMosaicEnvironment(); + const supportEmail = useMosaicSupportEmail(); const { isActive, cancel, complete, level } = props; - if (!session || !environment) { + if (!session || !environment || supportEmail === undefined) { return { status: 'loading', isActive }; } @@ -132,7 +134,7 @@ export function useReverificationModel(props: ReverificationProps): Reverificati return { status: 'ready', isActive, - supportEmail: environment.displayConfig.supportEmail ?? '', + supportEmail, start: async () => { try { return handleResponse(await session.startVerification({ level: level ?? 'first_factor' })); diff --git a/packages/ui/src/mosaic/hooks/__tests__/useMosaicSupportEmail.test.ts b/packages/ui/src/mosaic/hooks/__tests__/useMosaicSupportEmail.test.ts new file mode 100644 index 00000000000..af9054bc6d7 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/__tests__/useMosaicSupportEmail.test.ts @@ -0,0 +1,86 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useMosaicSupportEmail } from '../useMosaicSupportEmail'; + +let supportEmailFromOptions: string | undefined; +let frontendApi: string; +let environmentHydrated: boolean; +let supportEmailFromEnvironment: string; + +function environment() { + return environmentHydrated ? { displayConfig: { supportEmail: supportEmailFromEnvironment } } : undefined; +} + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useClerk: () => ({ + frontendApi, + __internal_getOption: (key: string) => (key === 'supportEmail' ? supportEmailFromOptions : undefined), + }), + }; +}); + +vi.mock('../useMosaicEnvironment', () => ({ + useMosaicEnvironment: () => environment(), +})); + +describe('useMosaicSupportEmail', () => { + beforeEach(() => { + supportEmailFromOptions = undefined; + frontendApi = ''; + environmentHydrated = true; + supportEmailFromEnvironment = ''; + }); + + it('uses the ClerkProvider option over the environment', () => { + supportEmailFromOptions = 'option@email.com'; + supportEmailFromEnvironment = 'env@email.com'; + + const { result } = renderHook(() => useMosaicSupportEmail()); + + expect(result.current).toBe('option@email.com'); + }); + + it('returns the option before the environment hydrates', () => { + supportEmailFromOptions = 'option@email.com'; + environmentHydrated = false; + + const { result } = renderHook(() => useMosaicSupportEmail()); + + expect(result.current).toBe('option@email.com'); + }); + + it('uses the environment when no option is set', () => { + supportEmailFromEnvironment = 'env@email.com'; + + const { result } = renderHook(() => useMosaicSupportEmail()); + + expect(result.current).toBe('env@email.com'); + }); + + it('falls back to the Frontend API host when neither source is set', () => { + const { result } = renderHook(() => useMosaicSupportEmail()); + + expect(result.current).toBe('support@clerk.com'); + }); + + it('strips the clerk. prefix from the Frontend API host', () => { + frontendApi = 'clerk.example.com'; + + const { result } = renderHook(() => useMosaicSupportEmail()); + + expect(result.current).toBe('support@example.com'); + }); + + it('is undefined until the environment hydrates when no option is set', () => { + environmentHydrated = false; + + const { result } = renderHook(() => useMosaicSupportEmail()); + + expect(result.current).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/mosaic/hooks/useMosaicSupportEmail.ts b/packages/ui/src/mosaic/hooks/useMosaicSupportEmail.ts new file mode 100644 index 00000000000..7c1b32567f0 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useMosaicSupportEmail.ts @@ -0,0 +1,39 @@ +import { buildEmailAddress } from '@clerk/shared/internal/clerk-js/email'; +import { useClerk } from '@clerk/shared/react'; + +import { useMosaicEnvironment } from './useMosaicEnvironment'; + +/** + * Resolves the support email Mosaic flows show in "contact us" copy. + * + * Priority matches clerk-js's `useSupportEmail`: the ClerkProvider `supportEmail` + * option, then `displayConfig.supportEmail`, then `support@` + the Frontend API + * host. Mosaic has no `OptionsContext`, so the option is read through + * `clerk.__internal_getOption` — quarantined here the same way + * `__internal_environment` lives only in `useMosaicEnvironment`. + * + * This is a one-shot read off the Clerk singleton, NOT a reactive subscription: + * `useClerk()` does not re-render when options or the environment mutate. Only + * use this for the hydration-time address; a later option/env change will not + * update the value. + * + * Returns `undefined` until the value is stable. The option can resolve before + * the environment hydrates; otherwise callers must wait so the Frontend API + * fallback is not locked in ahead of a dashboard-configured address. + */ +export function useMosaicSupportEmail(): string | undefined { + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + const emailFromOptions = clerk.__internal_getOption('supportEmail'); + + if (emailFromOptions) { + return emailFromOptions; + } + if (!environment) { + return undefined; + } + return ( + environment.displayConfig.supportEmail || + buildEmailAddress({ localPart: 'support', frontendApi: clerk.frontendApi }) + ); +} From 68b30a4b850469cca4a4c27ab1d8afda133c17f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 14:00:19 +0200 Subject: [PATCH 24/33] Remove explicit verifyPasskey now that attempt handles it --- packages/swingset/src/stories/reverification.stories.tsx | 7 +++++-- .../__tests__/reverification.controller.test.tsx | 2 -- .../__tests__/reverification.model.test.tsx | 2 +- .../reverification/__tests__/reverification.test.tsx | 2 -- .../reverification/__tests__/reverification.view.test.tsx | 1 - .../reverification/panels/reverification-passkey.tsx | 6 +++--- .../features/reverification/reverification.controller.ts | 7 ++----- .../features/reverification/reverification.model.tsx | 8 -------- .../features/reverification/reverification.types.ts | 2 -- .../features/reverification/reverification.view.tsx | 3 +-- 10 files changed, 12 insertions(+), 28 deletions(-) diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx index 5627b6ba6a7..dce78c66d0e 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -192,6 +192,10 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element void submitOtp(valueRef.current); return; } + if (step === 'passkey') { + void submitPasskey(); + return; + } void submitPassword(); }; @@ -205,7 +209,6 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element errorMessage={errorMessage} isPending={isPending} onSubmit={onSubmit} - onVerifyPasskey={() => void submitPasskey()} onShowMethods={() => navigate('method-picker', 1)} onResend={onResend} canResend={canResend} @@ -320,7 +323,7 @@ function PasskeyPanel({ }} errorMessage={errorMessage} isPending={isPending} - onVerify={() => undefined} + onSubmit={() => undefined} onCancel={() => undefined} /> diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index 22cee3d72ab..5b553a3f205 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -35,7 +35,6 @@ function seatedDeps(overrides: Partial = {}): Reverification start: vi.fn(async () => firstFactorResult()), prepare: vi.fn(async () => {}), attempt: vi.fn(async () => firstFactorResult({ status: 'complete' })), - verifyPasskey: vi.fn(async () => firstFactorResult({ status: 'complete' })), finish: vi.fn(async () => {}), cancel: vi.fn(), ...overrides, @@ -56,7 +55,6 @@ function readyModel(overrides: Partial = {}): Reverifi start: vi.fn(async () => firstFactorResult()), prepare: vi.fn(async () => {}), attempt: vi.fn(async () => firstFactorResult({ status: 'complete' })), - verifyPasskey: vi.fn(async () => firstFactorResult({ status: 'complete' })), finish: vi.fn(async () => {}), cancel: vi.fn(), ...overrides, diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx index b487ae30ddc..0042833ceee 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx @@ -277,7 +277,7 @@ describe('useReverificationModel', () => { session?.verifyWithPasskey.mockResolvedValue(resource({ status: 'complete' })); const { result } = renderHook(() => useReverificationModel(activeProps())); - await ready(result.current).verifyPasskey(); + await ready(result.current).attempt({ id: 'passkey', stage: 'first', strategy: 'passkey' }, ''); expect(session?.verifyWithPasskey).toHaveBeenCalledOnce(); }); diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx index d30ccbf7173..41ff7d25991 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx @@ -13,7 +13,6 @@ const model: ReverificationModel = { start: vi.fn(), prepare: vi.fn(), attempt: vi.fn(), - verifyPasskey: vi.fn(), finish: vi.fn(), cancel: vi.fn(), }; @@ -47,7 +46,6 @@ function ready(overrides: Partial = {}): Reverification onValueChange: vi.fn(), isPending: false, onSubmit: vi.fn(), - onVerifyPasskey: vi.fn(), onShowMethods: vi.fn(), onShowHelp: vi.fn(), onEmailSupport: vi.fn(), diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx index e275e33a74b..2f94eddcc52 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx @@ -12,7 +12,6 @@ function viewProps(overrides: Partial = {}): Reverifica onValueChange: vi.fn(), isPending: false, onSubmit: vi.fn(), - onVerifyPasskey: vi.fn(), onShowMethods: vi.fn(), onShowHelp: vi.fn(), onEmailSupport: vi.fn(), diff --git a/packages/ui/src/mosaic/features/reverification/panels/reverification-passkey.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-passkey.tsx index 943be1f3dde..261cfe794d1 100644 --- a/packages/ui/src/mosaic/features/reverification/panels/reverification-passkey.tsx +++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-passkey.tsx @@ -14,7 +14,7 @@ export interface ReverificationPasskeyProps { messages: ReverificationPasskeyMessages; errorMessage?: string; isPending?: boolean; - onVerify: () => void; + onSubmit: () => void; onCancel?: () => void; } @@ -22,7 +22,7 @@ export function ReverificationPasskey({ messages, errorMessage, isPending = false, - onVerify, + onSubmit, onCancel, }: ReverificationPasskeyProps) { return ( @@ -59,7 +59,7 @@ export function ReverificationPasskey({ fullWidth isPending={isPending} pendingLabel={messages.pendingLabel} - onClick={onVerify} + onClick={onSubmit} > {messages.primaryActionLabel} diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index 3cf765e235f..cf09f511acf 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -18,7 +18,7 @@ type OverlayFrom = 'factor' | 'method-picker'; export type ReverificationDeps = Pick< ReverificationReadyModel, - 'start' | 'prepare' | 'attempt' | 'verifyPasskey' | 'finish' | 'cancel' + 'start' | 'prepare' | 'attempt' | 'finish' | 'cancel' >; interface ReverificationContext { @@ -58,7 +58,6 @@ const unseatedDeps: ReverificationDeps = { start: notSeated, prepare: notSeated, attempt: notSeated, - verifyPasskey: notSeated, finish: notSeated, cancel: () => {}, }; @@ -96,7 +95,7 @@ function submit(ctx: ReverificationContext): Promise { if (!method) { return Promise.reject(new Error('No active method')); } - return method.strategy === 'passkey' ? ctx.deps.verifyPasskey() : ctx.deps.attempt(method, ctx.inputValue); + return ctx.deps.attempt(method, ctx.inputValue); } const applyResult = assign>((_, event) => ({ @@ -482,7 +481,6 @@ export function useReverificationController(model: ReverificationModel): Reverif errorMessage: context.errorMessage, isPending: pendingStates.has(snapshot.value), onSubmit: () => send({ type: 'SUBMIT' }), - onVerifyPasskey: () => send({ type: 'SUBMIT' }), onShowMethods: () => send({ type: 'SHOW_METHODS' }), onShowHelp: () => send({ type: 'SHOW_HELP' }), onBack: step === 'method-picker' || step === 'help' ? () => send({ type: 'BACK' }) : undefined, @@ -495,7 +493,6 @@ export function useReverificationController(model: ReverificationModel): Reverif pendingMethodId, onSelectMethod: id => send({ type: 'SELECT_METHOD', id }), otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, - identifier: activeMethod && 'identifier' in activeMethod ? activeMethod.identifier : undefined, onResend: () => send({ type: 'RESEND' }), canResend: context.canResend, resendRemainingSeconds: diff --git a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx index 63981854006..0f69b22b324 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx @@ -25,7 +25,6 @@ export type ReverificationReadyModel = { start: () => Promise; prepare: (method: ReverificationMethod) => Promise; attempt: (method: ReverificationMethod, value: string) => Promise; - verifyPasskey: () => Promise; finish: () => Promise; cancel: () => void; }; @@ -142,13 +141,6 @@ export function useReverificationModel(props: ReverificationProps): Reverificati throw toError(error); } }, - verifyPasskey: async () => { - try { - return handleResponse(await session.verifyWithPasskey()); - } catch (error) { - throw toError(error); - } - }, cancel: () => { cancel?.(); }, diff --git a/packages/ui/src/mosaic/features/reverification/reverification.types.ts b/packages/ui/src/mosaic/features/reverification/reverification.types.ts index 31021c9a679..89979f863e7 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.types.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.types.ts @@ -24,7 +24,6 @@ export type ReverificationViewProps = { errorMessage?: string; isPending: boolean; onSubmit: () => void; - onVerifyPasskey: () => void; onShowMethods: () => void; onShowHelp: () => void; onBack?: () => void; @@ -33,7 +32,6 @@ export type ReverificationViewProps = { pendingMethodId?: string; onSelectMethod: (id: string) => void; otpChannel?: ReverificationOtpChannel; - identifier?: string; onResend: () => void; canResend: boolean; resendRemainingSeconds?: number; diff --git a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx index 4483afd542a..4dd2c1416de 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx @@ -62,7 +62,6 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element errorMessage, isPending, onSubmit, - onVerifyPasskey, onShowMethods, onShowHelp, onBack, @@ -124,7 +123,7 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element }} errorMessage={errorMessage} isPending={isPending} - onVerify={onVerifyPasskey} + onSubmit={onSubmit} onCancel={hasAlternatives ? onShowMethods : undefined} /> From d2bb1411c873eacba136bab12482cdb23a3fe8e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 14:03:36 +0200 Subject: [PATCH 25/33] Tighten up prepare types --- .../__tests__/reverification.model.test.tsx | 11 ----------- .../reverification/reverification.controller.ts | 11 ++++++----- .../features/reverification/reverification.model.tsx | 5 ++--- .../features/reverification/reverification.types.ts | 2 ++ .../features/reverification/reverification.utils.ts | 11 ++++++++--- 5 files changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx index 0042833ceee..cd7d18a447c 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx @@ -262,17 +262,6 @@ describe('useReverificationModel', () => { expect(session?.attemptSecondFactorVerification).toHaveBeenCalledWith({ strategy: 'totp', code: '123456' }); }); - it('no-ops prepare for methods that have no prepare step', async () => { - const { result } = renderHook(() => useReverificationModel(activeProps())); - await ready(result.current).prepare({ id: 'password', stage: 'first', strategy: 'password' }); - await ready(result.current).prepare({ id: 'passkey', stage: 'first', strategy: 'passkey' }); - await ready(result.current).prepare({ id: 'totp', stage: 'second', strategy: 'totp' }); - await ready(result.current).prepare({ id: 'backup_code', stage: 'second', strategy: 'backup_code' }); - - expect(session?.prepareFirstFactorVerification).not.toHaveBeenCalled(); - expect(session?.prepareSecondFactorVerification).not.toHaveBeenCalled(); - }); - it('verifies a passkey', async () => { session?.verifyWithPasskey.mockResolvedValue(resource({ status: 'complete' })); const { result } = renderHook(() => useReverificationModel(activeProps())); diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index cf09f511acf..0f04de1fb18 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -87,7 +87,8 @@ function selectMethod(ctx: ReverificationContext, id: string): Partial { @@ -121,8 +122,8 @@ const afterResult = [ }, { guard: (_: ReverificationContext, event: DoneInvokeEvent) => { - const strategy = event.output.startingMethod?.strategy; - return Boolean(strategy && needsPrepare(strategy)); + const method = event.output.startingMethod; + return Boolean(method && needsPrepare(method)); }, target: 'preparing' as const, actions: [applyResult, assign(() => lockResend())], @@ -259,7 +260,7 @@ export const reverificationMachine = createMachine({ SUBMIT: 'submitting', RESEND: { target: 'resending', - guard: ctx => Boolean(ctx.activeMethod && needsPrepare(ctx.activeMethod.strategy) && ctx.canResend), + guard: ctx => Boolean(ctx.activeMethod && needsPrepare(ctx.activeMethod) && ctx.canResend), }, SHOW_METHODS: { target: 'methodPicker', @@ -315,7 +316,7 @@ export const reverificationMachine = createMachine({ target: 'methodPickerPreparing', guard: (ctx, event) => { const method = ctx.methods.find(candidate => candidate.id === event.id); - return Boolean(method && needsPrepare(method.strategy)); + return Boolean(method && needsPrepare(method)); }, actions: assign((ctx, event) => selectMethod(ctx, event.id)), }, diff --git a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx index 0f69b22b324..560c86878d9 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx @@ -12,6 +12,7 @@ import { useMosaicEnvironment } from '../../hooks/useMosaicEnvironment'; import { useMosaicSupportEmail } from '../../hooks/useMosaicSupportEmail'; import type { ReverificationMethod, + ReverificationPreparableMethod, ReverificationProps, ReverificationResult, ReverificationStage, @@ -23,7 +24,7 @@ export type ReverificationReadyModel = { isActive: boolean; supportEmail: string; start: () => Promise; - prepare: (method: ReverificationMethod) => Promise; + prepare: (method: ReverificationPreparableMethod) => Promise; attempt: (method: ReverificationMethod, value: string) => Promise; finish: () => Promise; cancel: () => void; @@ -166,8 +167,6 @@ export function useReverificationModel(props: ReverificationProps): Reverificati phoneNumberId: method.phoneNumberId, }); return; - default: - return; } } catch (error) { throw toError(error); diff --git a/packages/ui/src/mosaic/features/reverification/reverification.types.ts b/packages/ui/src/mosaic/features/reverification/reverification.types.ts index 89979f863e7..e24925d3f43 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.types.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.types.ts @@ -16,6 +16,8 @@ export type ReverificationMethod = | { id: string; stage: 'first' | 'second'; strategy: 'phone_code'; phoneNumberId: string; identifier: string } | { id: string; stage: 'second'; strategy: 'totp' | 'backup_code' }; +export type ReverificationPreparableMethod = Extract; + export type ReverificationViewProps = { step: ReverificationStep; direction?: FlowDirection; diff --git a/packages/ui/src/mosaic/features/reverification/reverification.utils.ts b/packages/ui/src/mosaic/features/reverification/reverification.utils.ts index 78d391971b9..42f07b27c3e 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.utils.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.utils.ts @@ -1,6 +1,11 @@ import type { PreferredSignInStrategy } from '@clerk/shared/types'; -import type { ReverificationMethod, ReverificationOtpChannel, ReverificationStrategy } from './reverification.types'; +import type { + ReverificationMethod, + ReverificationOtpChannel, + ReverificationPreparableMethod, + ReverificationStrategy, +} from './reverification.types'; export function otpChannelFor(strategy: ReverificationStrategy): ReverificationOtpChannel | undefined { if (strategy === 'email_code') { @@ -15,8 +20,8 @@ export function otpChannelFor(strategy: ReverificationStrategy): ReverificationO return undefined; } -export function needsPrepare(strategy: ReverificationStrategy): boolean { - return strategy === 'email_code' || strategy === 'phone_code'; +export function needsPrepare(method: ReverificationMethod): method is ReverificationPreparableMethod { + return method.strategy === 'email_code' || method.strategy === 'phone_code'; } function pickStartingFirstFactor( From 1f67fc3e4183541ef5a8e674df5b8c6731b6795f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 14:07:54 +0200 Subject: [PATCH 26/33] Make onBack required --- .../src/stories/reverification.stories.tsx | 16 +++++++++------- .../__tests__/reverification.test.tsx | 1 + .../__tests__/reverification.view.test.tsx | 1 + .../reverification/reverification.controller.ts | 2 +- .../reverification/reverification.types.ts | 2 +- .../reverification/reverification.view.tsx | 2 +- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx index dce78c66d0e..d5e42596144 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -213,13 +213,15 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element onResend={onResend} canResend={canResend} onShowHelp={() => navigate('help', 1)} - onBack={ - step === 'help' - ? () => navigate('method-picker', -1) - : step === 'method-picker' - ? () => navigate(stepFor(methodId), -1) - : undefined - } + 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)} diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx index 41ff7d25991..5611f71f638 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx @@ -48,6 +48,7 @@ function ready(overrides: Partial = {}): Reverification onSubmit: vi.fn(), onShowMethods: vi.fn(), onShowHelp: vi.fn(), + onBack: vi.fn(), onEmailSupport: vi.fn(), onResend: vi.fn(), canResend: true, diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx index 2f94eddcc52..17f1b01bf8a 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx @@ -14,6 +14,7 @@ function viewProps(overrides: Partial = {}): Reverifica onSubmit: vi.fn(), onShowMethods: vi.fn(), onShowHelp: vi.fn(), + onBack: vi.fn(), onEmailSupport: vi.fn(), onResend: vi.fn(), canResend: true, diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index 0f04de1fb18..b62c42d68c5 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -484,7 +484,7 @@ export function useReverificationController(model: ReverificationModel): Reverif onSubmit: () => send({ type: 'SUBMIT' }), onShowMethods: () => send({ type: 'SHOW_METHODS' }), onShowHelp: () => send({ type: 'SHOW_HELP' }), - onBack: step === 'method-picker' || step === 'help' ? () => send({ type: 'BACK' }) : undefined, + onBack: () => send({ type: 'BACK' }), onEmailSupport: () => { if (context.supportEmail) { window.location.assign(`mailto:${context.supportEmail}`); diff --git a/packages/ui/src/mosaic/features/reverification/reverification.types.ts b/packages/ui/src/mosaic/features/reverification/reverification.types.ts index e24925d3f43..c31890a4fbe 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.types.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.types.ts @@ -28,7 +28,7 @@ export type ReverificationViewProps = { onSubmit: () => void; onShowMethods: () => void; onShowHelp: () => void; - onBack?: () => void; + onBack: () => void; onEmailSupport: () => void; methods: readonly ReverificationMethod[]; pendingMethodId?: string; diff --git a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx index 4dd2c1416de..36b18cb3eae 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx @@ -197,7 +197,7 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element supportButton: m.alternativeMethods.getHelp.blockButton__emailSupport, }} onEmailSupport={onEmailSupport} - onBack={onBack ?? (() => {})} + onBack={onBack} /> From caf110be08fa5083da7a96373b566d60b169f84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 14:15:18 +0200 Subject: [PATCH 27/33] Format --- .../features/reverification/reverification.controller.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index b62c42d68c5..e8bb30456bb 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -16,10 +16,7 @@ export type ReverificationController = type OverlayFrom = 'factor' | 'method-picker'; -export type ReverificationDeps = Pick< - ReverificationReadyModel, - 'start' | 'prepare' | 'attempt' | 'finish' | 'cancel' ->; +export type ReverificationDeps = Pick; interface ReverificationContext { inputValue: string; From 9b00fe1105a4e508c800c221f0eceb17fe1918bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 14:32:34 +0200 Subject: [PATCH 28/33] Clamp timer to 30s --- .../reverification/reverification.controller.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index e8bb30456bb..1e1a79fa8b2 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -493,7 +493,15 @@ export function useReverificationController(model: ReverificationModel): Reverif otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, onResend: () => send({ type: 'RESEND' }), canResend: context.canResend, + // We clamp this to 30s because the first render after locking resend + // will have the old `now` state set, which would result in a value above + // 30s. There are other solutions like reading Date.now() in render and + // letting the interval only retrigger render, but that makes the render + // impure which is something the React compiler would warn about. + // useSyncExternalStore feels unnecessarily complex here resendRemainingSeconds: - countingDown && resendAvailableAt ? Math.max(0, Math.ceil((resendAvailableAt - now) / 1000)) : undefined, + countingDown && resendAvailableAt + ? Math.min(RESEND_COOLDOWN_MS / 1000, Math.max(0, Math.ceil((resendAvailableAt - now) / 1000))) + : undefined, }; } From 141775a2fbfad77bbdfb96cc93b418b1a0a6e8e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 14:46:55 +0200 Subject: [PATCH 29/33] Fold resend state into preparing state This also fixes spinner on continue when resending --- .../reverification.controller.test.tsx | 44 ++++++- .../reverification.controller.ts | 116 +++++++----------- 2 files changed, 87 insertions(+), 73 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index 5b553a3f205..18a28d6dc23 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -94,7 +94,6 @@ describe('reverificationMachine', () => { expect(prepare).toHaveBeenCalledOnce(); expect(actor.getSnapshot().value).toBe('verifying'); expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('email_code'); - expect(actor.getSnapshot().context.canResend).toBe(false); expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); actor.send({ type: 'SHOW_METHODS' }); @@ -213,7 +212,7 @@ describe('reverificationMachine', () => { ); await tick(); expect(actor.getSnapshot().value).toBe('preparing'); - expect(actor.getSnapshot().context.canResend).toBe(false); + expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); actor.send({ type: 'TYPE', value: '123456' }); expect(actor.getSnapshot().context.inputValue).toBe('123456'); @@ -221,7 +220,6 @@ describe('reverificationMachine', () => { prepare.resolve(); await tick(); expect(actor.getSnapshot().value).toBe('verifying'); - expect(actor.getSnapshot().context.canResend).toBe(false); expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); }); @@ -248,6 +246,46 @@ describe('reverificationMachine', () => { 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', () => { diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index 1e1a79fa8b2..da107902702 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import type { FlowDirection } from '../../components/flow'; import { setup } from '../../machine/setup'; -import type { DoneInvokeEvent } from '../../machine/types'; +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'; @@ -24,7 +24,6 @@ interface ReverificationContext { direction: FlowDirection; activeMethod: ReverificationMethod | null; methods: readonly ReverificationMethod[]; - canResend: boolean; resendAvailableAt: number | undefined; abortRequested: boolean; submitRequested: boolean; @@ -61,12 +60,12 @@ const unseatedDeps: ReverificationDeps = { export const RESEND_COOLDOWN_MS = 30_000; -function lockResend(): Pick { - return { canResend: false, resendAvailableAt: Date.now() + RESEND_COOLDOWN_MS }; +function lockResend(): Pick { + return { resendAvailableAt: Date.now() + RESEND_COOLDOWN_MS }; } -function unlockResend(): Pick { - return { canResend: true, resendAvailableAt: undefined }; +function unlockResend(): Pick { + return { resendAvailableAt: undefined }; } function errorMessage(error: unknown): string { @@ -139,6 +138,31 @@ const abortAfterInvoke = { ], }; +const factorEvents = { + TYPE: { + actions: assign((_, event) => ({ inputValue: event.value, errorMessage: undefined })), + }, + SHOW_METHODS: { + target: 'methodPicker', + guard: ctx => ctx.methods.filter(method => method.id !== ctx.activeMethod?.id).length > 0, + actions: assign(ctx => ({ + direction: 1 as const, + overlayFrom: 'factor' as const, + submitRequested: false, + frozenActiveMethodId: ctx.activeMethod?.id, + })), + }, + SHOW_HELP: { + target: 'help', + actions: assign(() => ({ + direction: 1 as const, + overlayFrom: 'factor' as const, + submitRequested: false, + })), + }, + RESET: 'inactive', +} satisfies NonNullable['on']>; + export const reverificationMachine = createMachine({ id: 'reverification', initial: 'inactive', @@ -148,7 +172,6 @@ export const reverificationMachine = createMachine({ direction: 1, activeMethod: null, methods: [], - canResend: true, resendAvailableAt: undefined, abortRequested: false, submitRequested: false, @@ -185,27 +208,10 @@ export const reverificationMachine = createMachine({ */ preparing: { on: { - TYPE: { - actions: assign((_, event) => ({ inputValue: event.value, errorMessage: undefined })), - }, + ...factorEvents, SUBMIT: { actions: assign(() => ({ submitRequested: true })), }, - SHOW_METHODS: { - target: 'methodPicker', - guard: ctx => ctx.methods.filter(method => method.id !== ctx.activeMethod?.id).length > 0, - actions: assign(ctx => ({ - direction: 1 as const, - overlayFrom: 'factor' as const, - submitRequested: false, - frozenActiveMethodId: ctx.activeMethod?.id, - })), - }, - SHOW_HELP: { - target: 'help', - actions: assign(() => ({ direction: 1 as const, overlayFrom: 'factor' as const, submitRequested: false })), - }, - RESET: 'inactive', }, invoke: fromPromise(prepareActive, { onDone: [ @@ -247,32 +253,21 @@ export const reverificationMachine = createMachine({ verifying: { always: [{ guard: ctx => ctx.activeMethod === null, target: 'unavailable' }], - after: { - [RESEND_COOLDOWN_MS]: { guard: ctx => !ctx.canResend, actions: assign(() => unlockResend()) }, - }, on: { - TYPE: { - actions: assign((_, event) => ({ inputValue: event.value, errorMessage: undefined })), - }, + ...factorEvents, SUBMIT: 'submitting', RESEND: { - target: 'resending', - guard: ctx => Boolean(ctx.activeMethod && needsPrepare(ctx.activeMethod) && ctx.canResend), - }, - SHOW_METHODS: { - target: 'methodPicker', - guard: ctx => ctx.methods.filter(method => method.id !== ctx.activeMethod?.id).length > 0, - actions: assign(ctx => ({ - direction: 1 as const, - overlayFrom: 'factor' as const, - frozenActiveMethodId: ctx.activeMethod?.id, + target: 'preparing', + guard: ctx => + Boolean(ctx.activeMethod && needsPrepare(ctx.activeMethod)) && + (ctx.resendAvailableAt === undefined || Date.now() >= ctx.resendAvailableAt), + actions: assign(() => ({ + ...lockResend(), + inputValue: '', + errorMessage: undefined, + submitRequested: false, })), }, - SHOW_HELP: { - target: 'help', - actions: assign(() => ({ direction: 1 as const, overlayFrom: 'factor' as const })), - }, - RESET: 'inactive', }, }, @@ -292,20 +287,6 @@ export const reverificationMachine = createMachine({ }), }, - resending: { - on: { RESET: 'inactive' }, - invoke: fromPromise(prepareActive, { - onDone: { - target: 'verifying', - actions: assign(() => ({ ...lockResend(), inputValue: '', errorMessage: undefined })), - }, - onError: { - target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), - }, - }), - }, - methodPicker: { on: { SELECT_METHOD: [ @@ -366,7 +347,7 @@ export const reverificationMachine = createMachine({ }, }); -const pendingStates = new Set(['submitting', 'resending', 'completing']); +const pendingStates = new Set(['submitting', 'completing']); function viewStep(value: string, method: ReverificationMethod | null): ReverificationViewProps['step'] | undefined { if (value === 'methodPicker' || value === 'methodPickerPreparing') { @@ -375,13 +356,7 @@ function viewStep(value: string, method: ReverificationMethod | null): Reverific if (value === 'help') { return 'help'; } - if ( - value === 'verifying' || - value === 'submitting' || - value === 'preparing' || - value === 'resending' || - value === 'completing' - ) { + if (value === 'verifying' || value === 'submitting' || value === 'preparing' || value === 'completing') { if (!method) { return undefined; } @@ -427,7 +402,8 @@ export function useReverificationController(model: ReverificationModel): Reverif const [now, setNow] = useState(() => Date.now()); const resendAvailableAt = snapshot.context.resendAvailableAt; - const countingDown = Boolean(resendAvailableAt && !snapshot.context.canResend); + const canResend = resendAvailableAt === undefined || now >= resendAvailableAt; + const countingDown = !canResend; useEffect(() => { if (!countingDown) { @@ -492,7 +468,7 @@ export function useReverificationController(model: ReverificationModel): Reverif onSelectMethod: id => send({ type: 'SELECT_METHOD', id }), otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, onResend: () => send({ type: 'RESEND' }), - canResend: context.canResend, + canResend, // We clamp this to 30s because the first render after locking resend // will have the old `now` state set, which would result in a value above // 30s. There are other solutions like reading Date.now() in render and From 8f62bde4003a74d4717c669fe809ae8e94f2e615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 16:52:34 +0200 Subject: [PATCH 30/33] Fix infinite rerender on reset during submit --- .../reverification.controller.test.tsx | 34 +++++++++++++++++++ .../reverification.controller.ts | 13 ++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx index 18a28d6dc23..0265eb693c6 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -389,6 +389,40 @@ describe('useReverificationController', () => { }); }); + 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(() => diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts index da107902702..98beda9cf65 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -273,7 +273,10 @@ export const reverificationMachine = createMachine({ submitting: { on: { - RESET: { actions: assign(() => ({ abortRequested: true })) }, + RESET: { + guard: ctx => !ctx.abortRequested, + actions: assign(() => ({ abortRequested: true })), + }, }, invoke: fromPromise(submit, { onDone: [abortAfterInvoke, ...afterResult], @@ -413,13 +416,15 @@ 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'; useEffect(() => { - if (model.isActive && ready && snapshot.value === 'inactive') { + if (needsStart) { send({ type: 'START' }); - } else if (!model.isActive && snapshot.value !== 'inactive') { + } else if (needsReset) { send({ type: 'RESET' }); } - }); + }, [needsStart, needsReset, send]); if (!model.isActive) { return { status: 'idle' }; From 3438c230b33c19aa1b939a8980913a907ae720b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 16:56:38 +0200 Subject: [PATCH 31/33] Set level default to second_factor --- .../__tests__/reverification.model.test.tsx | 9 +++++++++ .../features/reverification/reverification.model.tsx | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx index cd7d18a447c..7403bc26b16 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx @@ -124,6 +124,15 @@ describe('useReverificationModel', () => { 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( diff --git a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx index 560c86878d9..76b76a73bd0 100644 --- a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx +++ b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx @@ -137,7 +137,7 @@ export function useReverificationModel(props: ReverificationProps): Reverificati supportEmail, start: async () => { try { - return handleResponse(await session.startVerification({ level: level ?? 'first_factor' })); + return handleResponse(await session.startVerification({ level: level ?? 'second_factor' })); } catch (error) { throw toError(error); } From efef8f37d5cc49ecfc42b05f6d3bd413cb45bd5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 10 Sep 2026 17:02:35 +0200 Subject: [PATCH 32/33] Add extra safeguard for double-invoke --- .../use-reverification-with-state.test.tsx | 50 +++++++++++++++++++ .../use-reverification-with-state.ts | 37 +++++++++----- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx index 9fe9f970948..162709d54a7 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -95,6 +95,56 @@ describe('useReverificationWithState', () => { expect(result.current[1].isActive).toBe(false); }); + it('cancels an overlapping challenge and preserves the active challenge', () => { + const { result } = renderHook(() => useReverificationWithState(fetcher)); + const firstComplete = vi.fn(); + const firstCancel = vi.fn(); + const secondComplete = vi.fn(); + const secondCancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete: firstComplete, cancel: firstCancel, level: 'first_factor' }); + capturedOnNeeds?.({ complete: secondComplete, cancel: secondCancel, level: 'multi_factor' }); + }); + + expect(secondCancel).toHaveBeenCalledOnce(); + expect(result.current[1]).toMatchObject({ isActive: true, level: 'first_factor' }); + + act(() => { + const [, state] = result.current; + if (state.isActive) { + state.complete(); + } + }); + + expect(firstComplete).toHaveBeenCalledOnce(); + expect(firstCancel).not.toHaveBeenCalled(); + expect(secondComplete).not.toHaveBeenCalled(); + }); + + it('allows another challenge after the active challenge settles', () => { + const { result } = renderHook(() => useReverificationWithState(fetcher)); + const firstComplete = vi.fn(); + const secondCancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete: firstComplete, cancel: vi.fn(), level: 'first_factor' }); + }); + act(() => { + const [, state] = result.current; + if (state.isActive) { + state.complete(); + } + }); + act(() => { + capturedOnNeeds?.({ complete: vi.fn(), cancel: secondCancel, level: 'multi_factor' }); + }); + + expect(firstComplete).toHaveBeenCalledOnce(); + expect(secondCancel).not.toHaveBeenCalled(); + expect(result.current[1]).toMatchObject({ isActive: true, level: 'multi_factor' }); + }); + it('cancels when the session changes after reverification opens', async () => { const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); const cancel = vi.fn(); diff --git a/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts b/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts index f289b4d434b..927cd262e46 100644 --- a/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts +++ b/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts @@ -25,23 +25,36 @@ export function useReverificationWithState( const { session } = useSession(); const [reverificationState, setReverificationState] = useState({ isActive: false }); const openedSessionId = useRef(null); - const cancelOnUnmountRef = useRef<(() => void) | undefined>(undefined); + const activeCancelRef = useRef<(() => void) | undefined>(undefined); 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) { + 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(); + }; + setReverificationState({ isActive: true, level, - complete: () => { - setReverificationState({ isActive: false }); - complete(); - }, - cancel: () => { - setReverificationState({ isActive: false }); - cancel(); - }, + complete: () => settle(complete), + cancel: () => settle(cancel), }); }, }); @@ -49,8 +62,6 @@ export function useReverificationWithState( // Cancel if the session changes mid-flight const { isActive, cancel } = reverificationState; useEffect(() => { - cancelOnUnmountRef.current = isActive ? cancel : undefined; - if (!isActive) { openedSessionId.current = null; return; @@ -74,7 +85,9 @@ export function useReverificationWithState( useEffect(() => { return () => { - cancelOnUnmountRef.current?.(); + const cancel = activeCancelRef.current; + activeCancelRef.current = undefined; + cancel?.(); }; }, []); From a4aa3a03698eca8199725f36be590b594d7aea92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 14 Sep 2026 10:48:54 +0200 Subject: [PATCH 33/33] Fix duplicate export from rebase --- .../reverification/panels/reverification-method-picker.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx index 71991474ad6..150b0c14739 100644 --- a/packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx +++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx @@ -5,7 +5,6 @@ import { Icon, IconFrame } from '../../../components/icon'; import { Item } from '../../../components/item'; import { Spinner } from '../../../components/spinner'; import { Text } from '../../../components/text'; -import type { IconProps } from '../../components/icon'; export interface ReverificationMethod { id: string;