From c61f7e49f1e471035bde9e7b09b9c5ec2aca7a26 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 25 Aug 2026 17:26:42 -0600 Subject: [PATCH 01/12] feat(ui): add standalone reverification dialog block --- .changeset/reverification-dialog-block.md | 2 + .../reverification-dialog.machine.test.ts | 167 +++++++ .../reverification-dialog.view.test.tsx | 115 +++++ .../reverification-dialog.machine.ts | 394 ++++++++++++++++ .../reverification-dialog.messages.tsx | 30 ++ .../reverification-dialog.types.ts | 90 ++++ .../reverification-dialog.view.tsx | 445 ++++++++++++++++++ 7 files changed, 1243 insertions(+) create mode 100644 .changeset/reverification-dialog-block.md create mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.machine.test.ts create mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.view.test.tsx create mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts create mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.tsx create mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts create mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx diff --git a/.changeset/reverification-dialog-block.md b/.changeset/reverification-dialog-block.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/reverification-dialog-block.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.machine.test.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.machine.test.ts new file mode 100644 index 00000000000..a24b5366bf4 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.machine.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../../machine/createActor'; +import { + createReverificationDialogMachine, + getReverificationDialogActions, + getReverificationDialogState, + getReverificationDialogViewProps, +} from '../reverification-dialog.machine'; +import type { + ReverificationDialogMachineDependencies, + ReverificationDialogState, +} from '../reverification-dialog.types'; + +const idleResend = { isResending: false, secondsRemaining: 0 }; + +const passwordState: ReverificationDialogState = { + strategy: 'password', + value: '', + status: 'idle', + errors: {}, + resend: idleResend, +}; + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +function createDependencies( + overrides: Partial = {}, +): ReverificationDialogMachineDependencies { + return { + initialState: passwordState, + prepare: vi.fn().mockResolvedValue(undefined), + submit: vi.fn().mockResolvedValue({ status: 'complete' }), + resend: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('reverification dialog machine', () => { + it('drives the controlled view contract from machine events', () => { + const actor = createActor(createReverificationDialogMachine(createDependencies())); + actor.start(); + const actions = getReverificationDialogActions(actor.send); + + actions.onValueChange('secret'); + + expect(getReverificationDialogState(actor.getSnapshot())).toMatchObject({ + step: 'verify', + strategy: 'password', + value: 'secret', + status: 'idle', + }); + }); + + it('adapts a snapshot to flat, prop-driven view inputs', () => { + const actor = createActor(createReverificationDialogMachine(createDependencies())); + actor.start(); + + const props = getReverificationDialogViewProps(actor.getSnapshot(), actor.send); + + expect(props).toMatchObject({ + open: true, + strategy: 'password', + value: '', + isVerifying: false, + }); + expect(props).not.toHaveProperty('state'); + }); + + it('prepares delivered-code factors before showing verification', async () => { + const prepare = vi.fn().mockResolvedValue(undefined); + const actor = createActor( + createReverificationDialogMachine( + createDependencies({ + prepare, + initialState: { + ...passwordState, + step: 'select-first-factor', + availableFactors: [ + { + id: 'email', + strategy: 'email_code', + label: 'Email code', + identifier: 'i••••@clerk.dev', + }, + ], + }, + }), + ), + ); + actor.start(); + + actor.send({ type: 'SELECT_FACTOR', factorId: 'email' }); + expect(getReverificationDialogState(actor.getSnapshot())).toMatchObject({ + step: 'prepare', + preparationStatus: 'preparing', + strategy: 'email_code', + }); + + await tick(); + + expect(prepare).toHaveBeenCalledWith({ + strategy: 'email_code', + stage: 'first', + identifier: 'i••••@clerk.dev', + }); + expect(getReverificationDialogState(actor.getSnapshot()).step).toBe('verify'); + }); + + it('routes a successful first factor to second-factor selection', async () => { + const actor = createActor( + createReverificationDialogMachine( + createDependencies({ + submit: vi.fn().mockResolvedValue({ + status: 'needs_second_factor', + factors: [{ id: 'totp', strategy: 'totp', label: 'Authenticator app' }], + }), + }), + ), + ); + actor.start(); + actor.send({ type: 'CHANGE_VALUE', value: 'secret' }); + actor.send({ type: 'SUBMIT' }); + + await tick(); + + expect(getReverificationDialogState(actor.getSnapshot())).toMatchObject({ + step: 'select-second-factor', + stage: 'second', + availableFactors: [{ id: 'totp', strategy: 'totp' }], + }); + }); + + it('maps submission errors back into the verification view', async () => { + const actor = createActor( + createReverificationDialogMachine( + createDependencies({ + submit: vi.fn().mockRejectedValue(new Error('Incorrect password.')), + mapError: () => ({ field: 'Incorrect password.' }), + }), + ), + ); + actor.start(); + actor.send({ type: 'CHANGE_VALUE', value: 'wrong' }); + actor.send({ type: 'SUBMIT' }); + + await tick(); + + expect(getReverificationDialogState(actor.getSnapshot())).toMatchObject({ + step: 'verify', + value: '', + status: 'error', + errors: { field: 'Incorrect password.' }, + }); + }); + + it('reports cancellation and terminates the flow', () => { + const onCancel = vi.fn(); + const actor = createActor(createReverificationDialogMachine(createDependencies({ onCancel }))); + actor.start(); + + actor.send({ type: 'CANCEL' }); + + expect(onCancel).toHaveBeenCalledOnce(); + expect(actor.getSnapshot()).toMatchObject({ value: 'cancelled', status: 'done' }); + }); +}); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.view.test.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.view.test.tsx new file mode 100644 index 00000000000..b3ecd7395a1 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.view.test.tsx @@ -0,0 +1,115 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ReverificationDialogViewProps } from '../reverification-dialog.types'; +import { ReverificationDialogView } from '../reverification-dialog.view'; + +afterEach(() => cleanup()); + +const createProps = (overrides: Partial = {}): ReverificationDialogViewProps => ({ + open: true, + strategy: 'password', + value: '', + onOpenChange: vi.fn(), + onValueChange: vi.fn(), + onSubmit: vi.fn(), + onResend: vi.fn(), + ...overrides, +}); + +describe('ReverificationDialogView', () => { + it('is controlled by open and onOpenChange props', async () => { + const onOpenChange = vi.fn(); + const { rerender } = render(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + rerender(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('forwards password input and form submission through flat callbacks', async () => { + const onSubmit = vi.fn(); + const onValueChange = vi.fn(); + const { rerender } = render(); + + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } }); + expect(onValueChange).toHaveBeenCalledWith('secret'); + + rerender(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Continue' })); + + expect(onSubmit).toHaveBeenCalledOnce(); + }); + + it('normalizes a delivered code and submits when six digits are controlled back in', async () => { + const onSubmit = vi.fn(); + + function ControlledCodeDialog() { + const [value, setValue] = React.useState(''); + return ( + + ); + } + + render(); + await userEvent.setup().type(screen.getByLabelText('Verification code'), '12a3456'); + + expect(screen.getByLabelText('Verification code')).toHaveValue('123456'); + expect(onSubmit).toHaveBeenCalledWith('123456'); + }); + + it('renders factor selection as prop-driven actions', async () => { + const onSelectFactor = vi.fn(); + render( + , + ); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Passkey' })); + + expect(onSelectFactor).toHaveBeenCalledWith('passkey'); + }); + + it('exposes verification progress and errors accessibly', () => { + render( + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Verification failed.'); + expect(screen.getByText('Incorrect password.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Continue' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('progressbar', { name: 'Verifying identity' })).toBeInTheDocument(); + }); + + it('renders passkey verification without an input', () => { + render(); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Verify with passkey' })).toBeEnabled(); + }); +}); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts new file mode 100644 index 00000000000..15ceca2ef46 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts @@ -0,0 +1,394 @@ +import { setup } from '../../machine/setup'; +import type { Snapshot } from '../../machine/types'; +import type { + ReverificationDialogActions, + ReverificationDialogErrors, + ReverificationDialogMachineDependencies, + ReverificationDialogState, + ReverificationDialogSubmissionResult, + ReverificationDialogViewProps, + ReverificationFactor, + ReverificationStage, + ReverificationStrategy, +} from './reverification-dialog.types'; + +interface ReverificationDialogMachineContext extends ReverificationDialogState { + pendingValue: string; + prepare: ReverificationDialogMachineDependencies['prepare']; + submit: ReverificationDialogMachineDependencies['submit']; + resendAction: ReverificationDialogMachineDependencies['resend']; + cancel: () => void; + mapError: (error: unknown) => ReverificationDialogErrors; +} + +export type ReverificationDialogMachineEvent = + | { type: 'CHANGE_VALUE'; value: string } + | { type: 'SUBMIT'; value?: string } + | { type: 'RESEND' } + | { type: 'CANCEL' } + | { type: 'SELECT_FACTOR'; factorId: string } + | { type: 'BACK' } + | { type: 'RETRY_PREPARE' } + | { type: 'SHOW_HELP' }; + +const { createMachine, assign, fromPromise } = setup< + ReverificationDialogMachineContext, + ReverificationDialogMachineEvent +>(); + +const requiresPreparation = (strategy: ReverificationStrategy) => + strategy === 'email_code' || strategy === 'phone_code'; + +const stageForStep = (step: ReverificationDialogState['step']): ReverificationStage => + step === 'select-second-factor' ? 'second' : 'first'; + +const machineStateFor = (state: ReverificationDialogState) => { + if (state.step === 'prepare') { + return state.preparationStatus === 'error' ? 'preparationFailed' : 'preparing'; + } + + switch (state.step) { + case 'select-first-factor': + return 'selectingFirstFactor'; + case 'select-second-factor': + return 'selectingSecondFactor'; + case 'unavailable': + return 'unavailable'; + case 'help': + return 'help'; + default: + return 'verifying'; + } +}; + +const operationFrom = (context: ReverificationDialogMachineContext) => ({ + strategy: context.strategy, + stage: context.stage ?? stageForStep(context.step), + identifier: context.identifier, +}); + +const selectFactor = ( + context: ReverificationDialogMachineContext, + factorId: string, +): ReverificationFactor | undefined => context.availableFactors?.find(factor => factor.id === factorId); + +const defaultMapError = (error: unknown): ReverificationDialogErrors => ({ + form: error instanceof Error ? error.message : String(error), +}); + +export function createReverificationDialogMachine(dependencies: ReverificationDialogMachineDependencies) { + return createMachine({ + id: 'reverificationDialog', + initial: context => machineStateFor(context), + context: { + ...dependencies.initialState, + stage: dependencies.initialState.stage ?? stageForStep(dependencies.initialState.step), + pendingValue: '', + prepare: dependencies.prepare, + submit: dependencies.submit, + resendAction: dependencies.resend, + cancel: dependencies.onCancel ?? (() => undefined), + mapError: dependencies.mapError ?? defaultMapError, + }, + states: { + selectingFirstFactor: { + on: { + SELECT_FACTOR: { + target: 'routingFactor', + guard: (context, event) => Boolean(selectFactor(context, event.factorId)), + actions: assign((context, event) => { + const factor = selectFactor(context, event.factorId); + if (!factor) { + return {}; + } + return { + strategy: factor.strategy, + stage: 'first', + identifier: factor.identifier, + value: '', + status: 'idle', + errors: {}, + }; + }), + }, + SHOW_HELP: 'help', + CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + }, + }, + + selectingSecondFactor: { + on: { + SELECT_FACTOR: { + target: 'routingFactor', + guard: (context, event) => Boolean(selectFactor(context, event.factorId)), + actions: assign((context, event) => { + const factor = selectFactor(context, event.factorId); + if (!factor) { + return {}; + } + return { + strategy: factor.strategy, + stage: 'second', + identifier: factor.identifier, + value: '', + status: 'idle', + errors: {}, + }; + }), + }, + BACK: 'selectingFirstFactor', + SHOW_HELP: 'help', + CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + }, + }, + + routingFactor: { + always: [ + { target: 'preparing', guard: context => requiresPreparation(context.strategy) }, + { target: 'verifying' }, + ], + }, + + preparing: { + invoke: fromPromise(context => context.prepare(operationFrom(context)), { + onDone: { + target: 'verifying', + actions: assign(() => ({ preparationStatus: undefined, errors: {} })), + }, + onError: { + target: 'preparationFailed', + actions: assign((context, event) => ({ + preparationStatus: 'error', + errors: context.mapError(event.error), + })), + }, + }), + on: { + BACK: [ + { target: 'selectingSecondFactor', guard: context => context.stage === 'second' }, + { target: 'selectingFirstFactor' }, + ], + CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + }, + }, + + preparationFailed: { + on: { + RETRY_PREPARE: { + target: 'preparing', + actions: assign(() => ({ preparationStatus: 'preparing', errors: {} })), + }, + BACK: [ + { target: 'selectingSecondFactor', guard: context => context.stage === 'second' }, + { target: 'selectingFirstFactor' }, + ], + CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + }, + }, + + verifying: { + on: { + CHANGE_VALUE: { + actions: assign((_, event) => ({ value: event.value, status: 'idle', errors: {} })), + }, + SUBMIT: { + target: 'submitting', + guard: (context, event) => context.strategy === 'passkey' || (event.value ?? context.value).length > 0, + actions: assign((context, event) => ({ + pendingValue: event.value ?? context.value, + status: 'verifying', + errors: {}, + })), + }, + RESEND: { + target: 'resending', + guard: context => requiresPreparation(context.strategy) && !context.resend.isResending, + }, + BACK: [ + { target: 'selectingSecondFactor', guard: context => context.stage === 'second' }, + { target: 'selectingFirstFactor' }, + ], + SHOW_HELP: 'help', + CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + }, + }, + + submitting: { + invoke: fromPromise( + context => + context.submit({ + ...operationFrom(context), + value: context.pendingValue, + }), + { + onDone: [ + { + target: 'unavailable', + guard: (_, event) => event.output.status === 'needs_second_factor' && event.output.factors.length === 0, + actions: assign(() => ({ status: 'idle', availableFactors: [] })), + }, + { + target: 'selectingSecondFactor', + guard: (_, event) => event.output.status === 'needs_second_factor', + actions: assign((_, event) => ({ + stage: 'second', + value: '', + status: 'idle', + errors: {}, + availableFactors: ( + event.output as Extract + ).factors, + })), + }, + { target: 'complete' }, + ], + onError: { + target: 'verifying', + actions: assign((context, event) => ({ + value: '', + status: 'error', + errors: context.mapError(event.error), + })), + }, + }, + ), + on: { + CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + }, + }, + + resending: { + entry: assign(context => ({ resend: { ...context.resend, isResending: true } })), + invoke: fromPromise(context => context.resendAction(operationFrom(context)), { + onDone: { + target: 'verifying', + actions: assign(context => ({ + value: '', + resend: { ...context.resend, isResending: false }, + })), + }, + onError: { + target: 'verifying', + actions: assign((context, event) => ({ + resend: { ...context.resend, isResending: false }, + errors: context.mapError(event.error), + })), + }, + }), + on: { + CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + }, + }, + + unavailable: { + on: { + BACK: [ + { target: 'selectingSecondFactor', guard: context => context.stage === 'second' }, + { target: 'selectingFirstFactor' }, + ], + CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + }, + }, + + help: { + on: { + BACK: [ + { target: 'selectingSecondFactor', guard: context => context.stage === 'second' }, + { target: 'selectingFirstFactor' }, + ], + CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + }, + }, + + complete: { type: 'final' }, + cancelled: { type: 'final' }, + }, + }); +} + +export type ReverificationDialogMachineSnapshot = Snapshot; + +export function getReverificationDialogState(snapshot: ReverificationDialogMachineSnapshot): ReverificationDialogState { + const { context } = snapshot; + const step = (() => { + switch (snapshot.value) { + case 'selectingFirstFactor': + return 'select-first-factor'; + case 'selectingSecondFactor': + return 'select-second-factor'; + case 'preparing': + case 'preparationFailed': + return 'prepare'; + case 'unavailable': + return 'unavailable'; + case 'help': + return 'help'; + default: + return 'verify'; + } + })() satisfies ReverificationDialogState['step']; + + return { + strategy: context.strategy, + step, + stage: context.stage, + availableFactors: context.availableFactors, + preparationStatus: + snapshot.value === 'preparationFailed' ? 'error' : snapshot.value === 'preparing' ? 'preparing' : undefined, + identifier: context.identifier, + value: context.value, + status: snapshot.value === 'submitting' ? 'verifying' : context.status, + errors: context.errors, + resend: context.resend, + }; +} + +export function getReverificationDialogActions( + send: (event: ReverificationDialogMachineEvent) => void, +): ReverificationDialogActions { + return { + onValueChange: value => send({ type: 'CHANGE_VALUE', value }), + onSubmit: value => send({ type: 'SUBMIT', value }), + onResend: () => send({ type: 'RESEND' }), + onCancel: () => send({ type: 'CANCEL' }), + onSelectFactor: factorId => send({ type: 'SELECT_FACTOR', factorId }), + onBack: () => send({ type: 'BACK' }), + onPrepare: () => send({ type: 'RETRY_PREPARE' }), + onShowHelp: () => send({ type: 'SHOW_HELP' }), + }; +} + +export function getReverificationDialogViewProps( + snapshot: ReverificationDialogMachineSnapshot, + send: (event: ReverificationDialogMachineEvent) => void, +): ReverificationDialogViewProps { + const state = getReverificationDialogState(snapshot); + const actions = getReverificationDialogActions(send); + + return { + open: snapshot.status === 'active', + strategy: state.strategy, + step: state.step, + availableFactors: state.availableFactors, + preparationStatus: state.preparationStatus, + identifier: state.identifier, + value: state.value, + isVerifying: state.status === 'verifying', + fieldError: state.errors.field, + formError: state.errors.form, + isResending: state.resend.isResending, + resendSecondsRemaining: state.resend.secondsRemaining, + onOpenChange: open => { + if (!open) { + actions.onCancel(); + } + }, + onValueChange: actions.onValueChange, + onSubmit: actions.onSubmit, + onResend: actions.onResend, + onSelectFactor: actions.onSelectFactor, + onBack: actions.onBack, + onPrepare: actions.onPrepare, + onShowHelp: actions.onShowHelp, + }; +} diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.tsx new file mode 100644 index 00000000000..952ed6d36e8 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.tsx @@ -0,0 +1,30 @@ +export const reverificationDialogMessages = { + title: 'Verify it’s you', + chooseFirst: 'Choose how to verify your identity.', + chooseSecond: 'Choose a second verification method to continue.', + havingTrouble: 'Having trouble?', + preparingDescription: 'Preparing your verification method.', + preparing: 'Preparing verification…', + prepareError: 'Could not prepare verification.', + unavailableTitle: 'Unable to verify', + unavailableDescription: 'No verification methods are available for this account.', + helpDescription: 'Contact support if you cannot access any verification method.', + deliveredCode: 'Enter the verification code sent to', + totp: 'Enter the code from your authenticator app.', + backupCode: 'Enter one of your backup codes.', + passkey: 'Use your passkey to verify your identity.', + password: 'Enter your password to continue.', + passwordLabel: 'Password', + backupCodeLabel: 'Backup code', + verificationCode: 'Verification code', + anotherMethod: 'Use another method', + pending: 'Verifying identity', + withPasskey: 'Verify with passkey', + didNotReceiveCode: "Didn't receive a code?", + resend: 'Resend', + back: 'Back', + cancel: 'Cancel', + close: 'Close', + continue: 'Continue', + tryAgain: 'Try again', +}; diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts new file mode 100644 index 00000000000..42ff4419365 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts @@ -0,0 +1,90 @@ +export type ReverificationStrategy = 'password' | 'email_code' | 'phone_code' | 'passkey' | 'totp' | 'backup_code'; + +export type ReverificationStage = 'first' | 'second'; + +export interface ReverificationFactor { + id: string; + strategy: ReverificationStrategy; + label: string; + identifier?: string; +} + +export interface ReverificationDialogErrors { + field?: string; + form?: string; +} + +export interface ReverificationDialogResendState { + isResending: boolean; + secondsRemaining: number; +} + +export interface ReverificationDialogState { + strategy: ReverificationStrategy; + step?: 'select-first-factor' | 'prepare' | 'verify' | 'select-second-factor' | 'unavailable' | 'help'; + stage?: ReverificationStage; + availableFactors?: ReverificationFactor[]; + preparationStatus?: 'preparing' | 'error'; + identifier?: string; + value: string; + status: 'idle' | 'verifying' | 'error'; + errors: ReverificationDialogErrors; + resend: ReverificationDialogResendState; +} + +export interface ReverificationDialogActions { + onValueChange: (value: string) => void; + onSubmit: (completedValue?: string) => void; + onResend: () => void; + onCancel: () => void; + onSelectFactor?: (factorId: string) => void; + onBack?: () => void; + onPrepare?: () => void; + onShowHelp?: () => void; +} + +export interface ReverificationDialogViewProps { + open: boolean; + strategy: ReverificationStrategy; + step?: ReverificationDialogState['step']; + availableFactors?: ReverificationFactor[]; + preparationStatus?: ReverificationDialogState['preparationStatus']; + identifier?: string; + value: string; + isVerifying?: boolean; + fieldError?: string; + formError?: string; + isResending?: boolean; + resendSecondsRemaining?: number; + onOpenChange: (open: boolean) => void; + onValueChange: (value: string) => void; + onSubmit: (completedValue?: string) => void; + onResend: () => void; + onSelectFactor?: (factorId: string) => void; + onBack?: () => void; + onPrepare?: () => void; + onShowHelp?: () => void; +} + +export interface ReverificationDialogOperation { + strategy: ReverificationStrategy; + stage: ReverificationStage; + identifier?: string; +} + +export interface ReverificationDialogAttempt extends ReverificationDialogOperation { + value: string; +} + +export type ReverificationDialogSubmissionResult = + | { status: 'complete' } + | { status: 'needs_second_factor'; factors: ReverificationFactor[] }; + +export interface ReverificationDialogMachineDependencies { + initialState: ReverificationDialogState; + prepare: (operation: ReverificationDialogOperation) => Promise; + submit: (attempt: ReverificationDialogAttempt) => Promise; + resend: (operation: ReverificationDialogOperation) => Promise; + onCancel?: () => void; + mapError?: (error: unknown) => ReverificationDialogErrors; +} diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx new file mode 100644 index 00000000000..ec8eb9604e7 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx @@ -0,0 +1,445 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { Button, SubmitButton } from '../../components/button'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Input } from '../../components/input'; +import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; +import { reverificationDialogMessages as m } from './reverification-dialog.messages'; +import type { ReverificationDialogResendState, ReverificationDialogViewProps } from './reverification-dialog.types'; + +const styles = stylex.create({ + header: { + gap: space['1'], + display: 'flex', + flexDirection: 'column', + }, + body: { + margin: '-0.25rem', + padding: '0.25rem', + gap: space['4'], + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + minHeight: 0, + overflowY: 'auto', + }, + footer: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + justifyContent: 'flex-end', + }, + footerSpread: { + justifyContent: 'space-between', + }, + footerButton: { + flexGrow: 1, + }, + form: { + gap: space['5'], + display: 'flex', + flexDirection: 'column', + minHeight: 0, + }, + alert: { + borderColor: colorVars['--cl-color-negative'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + paddingBlock: space['2'], + paddingInline: space['3'], + backgroundColor: colorVars['--cl-color-negative-faded'], + color: colorVars['--cl-color-negative'], + fontSize: typeScaleVars['--cl-text-sm-size'], + }, + codeInput: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + paddingBlock: space['3'], + paddingInline: space['4'], + backgroundColor: colorVars['--cl-color-input'], + fontFamily: 'monospace', + fontSize: typeScaleVars['--cl-text-xl-size'], + letterSpacing: '0.5em', + textAlign: 'center', + width: '100%', + }, + codeInputInvalid: { + borderColor: colorVars['--cl-color-negative'], + }, + muted: { + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-sm-size'], + }, + identifier: { + color: colorVars['--cl-color-neutral'], + fontWeight: 600, + overflowWrap: 'anywhere', + }, + resendRow: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + }, +}); + +function DialogHeader({ title, description }: { title: React.ReactNode; description?: React.ReactNode }) { + return ( +
+ {title} + {description ? {description} : null} +
+ ); +} + +function DialogBody({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function DialogFooter({ children, spread = false }: { children: React.ReactNode; spread?: boolean }) { + return
{children}
; +} + +function DialogForm({ children, onSubmit }: { children: React.ReactNode; onSubmit: () => void }) { + return ( +
{ + event.preventDefault(); + onSubmit(); + }} + > + {children} +
+ ); +} + +function FormAlert({ children }: { children?: React.ReactNode }) { + return children ? ( +

+ {children} +

+ ) : null; +} + +function MutedText({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +function Identifier({ children }: { children?: React.ReactNode }) { + return {children}; +} + +function ResendButton({ + disabled = false, + label, + resend, + onResend, +}: { + disabled?: boolean; + label: string; + resend: ReverificationDialogResendState; + onResend: () => void; +}) { + const waiting = resend.secondsRemaining > 0; + + return ( + + ); +} + +function CodeInput({ + id, + status, + value, + onChange, + onComplete, +}: { + id: string; + status: 'idle' | 'verifying' | 'error'; + value: string; + onChange: (value: string) => void; + onComplete: (value: string) => void; +}) { + const completedValueRef = React.useRef(undefined); + + React.useEffect(() => { + if (value.length === 6 && completedValueRef.current !== value) { + completedValueRef.current = value; + onComplete(value); + } + + if (value.length < 6) { + completedValueRef.current = undefined; + } + }, [onComplete, value]); + + return ( + onChange(event.target.value.replace(/\D/g, '').slice(0, 6))} + {...stylex.props(styles.codeInput, status === 'error' && styles.codeInputInvalid)} + /> + ); +} + +type ReverificationDialogContentProps = Omit & { + onCancel: () => void; +}; + +function ReverificationDialogContent({ + strategy, + step = 'verify', + availableFactors, + preparationStatus, + identifier, + value, + isVerifying = false, + fieldError, + formError, + isResending = false, + resendSecondsRemaining = 0, + onValueChange, + onSubmit, + onResend, + onCancel, + onSelectFactor, + onBack, + onPrepare, + onShowHelp, +}: ReverificationDialogContentProps) { + const fieldId = React.useId(); + + if (step === 'select-first-factor' || step === 'select-second-factor') { + return ( + <> + + + + {formError} + {availableFactors?.map(factor => ( + + ))} + + + + {onShowHelp ? ( + + ) : null} + + + ); + } + + if (step === 'prepare') { + const failed = preparationStatus === 'error'; + return ( + <> + + + + {failed ? {formError ?? m.prepareError} : null} + {!failed ? {m.preparing} : null} + + + + {failed ? : null} + + + ); + } + + if (step === 'unavailable' || step === 'help') { + return ( + <> + + + + {formError} + + + + + + ); + } + + const isDeliveredCode = strategy === 'email_code' || strategy === 'phone_code'; + const isCode = isDeliveredCode || strategy === 'totp'; + const isPasskey = strategy === 'passkey'; + const isPassword = strategy === 'password'; + const canSubmit = isPasskey || value.length > 0; + + const description = (() => { + if (isDeliveredCode) { + return ( + <> + {m.deliveredCode} {identifier} + + ); + } + if (strategy === 'totp') { + return m.totp; + } + if (strategy === 'backup_code') { + return m.backupCode; + } + if (isPasskey) { + return m.passkey; + } + return m.password; + })(); + + const fieldLabel = isPassword ? m.passwordLabel : strategy === 'backup_code' ? m.backupCodeLabel : m.verificationCode; + + return ( + <> + + + + + {formError} + {!isPasskey ? ( + + {fieldLabel} + {isCode ? ( + + ) : ( + onValueChange(event.target.value)} + /> + )} + {fieldError ? {fieldError} : null} + + ) : null} + {isDeliveredCode ? ( +
+ {m.didNotReceiveCode} + +
+ ) : null} +
+ + {onBack ? ( + + ) : null} + + + {isPasskey ? m.withPasskey : m.continue} + + +
+ + ); +} + +export function ReverificationDialogView({ open, onOpenChange, ...props }: ReverificationDialogViewProps) { + return ( + + onOpenChange(false)} + /> + + ); +} From 3de01793e22d59100a7649ca05f0a57e58524346 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 26 Aug 2026 08:50:32 -0600 Subject: [PATCH 02/12] refactor(ui): drive reverification dialog with a state machine --- packages/swingset/CLAUDE.md | 5 +- .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 10 + .../src/stories/reverification-dialog.mdx | 53 ++ .../stories/reverification-dialog.stories.tsx | 180 +++++ .../reverification-dialog.machine.test.ts | 467 +++++++++--- .../reverification-dialog.view.test.tsx | 151 ++-- .../reverification-dialog.machine.ts | 703 ++++++++++-------- .../reverification-dialog.messages.tsx | 5 +- .../reverification-dialog.types.ts | 181 +++-- .../reverification-dialog.view.tsx | 574 ++++++-------- .../src/mosaic/components/card/card.styles.ts | 3 + 12 files changed, 1455 insertions(+), 878 deletions(-) create mode 100644 packages/swingset/src/stories/reverification-dialog.mdx create mode 100644 packages/swingset/src/stories/reverification-dialog.stories.tsx diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md index 32550e15031..8ab3dd52311 100644 --- a/packages/swingset/CLAUDE.md +++ b/packages/swingset/CLAUDE.md @@ -61,12 +61,13 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f | ------------ | -------------------------------------------------------------- | --------- | | `User Button` | Composed flow UI (e.g. `UserButton`) | C | | `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C | +| `Blocks` | Reusable prop-driven flows (e.g. `ReverificationDialog`) | C | | `Components` | Styled Mosaic components — simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A | | `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B | | `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) | | `Hooks` | Headless hooks (`useDataTable`) | B (adapted) | -`User Button` / `User Profile` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B). +`User Button` / `User Profile` / `Blocks` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B). `Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow archetype B's shape (Example → Usage → Parts → Styling) with `Props` replaced by whatever the export @@ -240,7 +241,7 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th **Document the default value for every prop in a dedicated Default column.** Every props table — auto and hand-written — has a **Default** column; the `Type` stays a plain union/enum and the default is named in its own column (the convention every component-doc site and TypeDoc's `@default` tag follow), never inlined into the type. The auto `` renders `Prop | Type | Default | Value` and fills Default from `meta.styles._defaultVariants` (the **Value** column is the live knob seeded with that default); hand-written tables render `Prop | Type | Default | Description` and fill it by hand. Name the default member (`'base'`, `'multiple'`, `'bottom-start'`); use `—` when there is no default (a controlled-only or required prop) and append `(required)` for required props; when the default is behavioral rather than a literal, state it in words (`inherits Root`, `falls back to value`). -### Archetype C — composed layer (`User Button`, `User Profile`) +### Archetype C — composed layer (`User Button`, `User Profile`, `Blocks`) These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX: diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index c144dfd18db..7912339b031 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -39,6 +39,7 @@ const docModules: Record> = { }, blocks: { destructive: dynamic(() => import('../stories/destructive.mdx')), + 'reverification-dialog': dynamic(() => import('../stories/reverification-dialog.mdx')), }, components: { avatar: dynamic(() => import('../stories/avatar.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 966e912370b..84ab5629e6a 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -74,6 +74,10 @@ import { Placement as PopoverComponentPlacement, } from '../stories/popover.component.stories'; import { meta as popoverMeta } from '../stories/popover.stories'; +import { + Default as ReverificationDialogDefault, + meta as reverificationDialogMeta, +} from '../stories/reverification-dialog.stories'; import { Default as ScrollAreaDefault, Gutter as ScrollAreaGutter, @@ -288,6 +292,11 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const reverificationDialogModule: StoryModule = { + meta: reverificationDialogMeta, + Default: ReverificationDialogDefault, +}; + const userProfileApiKeysPanelModule: StoryModule = { meta: userProfileApiKeysPanelMeta, Default: UserProfileApiKeysPanelDefault, @@ -391,6 +400,7 @@ export const registry: StoryModule[] = [ userProfileDeleteSectionModule, // Blocks — flows assembled from components, wired by the caller's machine. destructiveModule, + reverificationDialogModule, // Components avatarModule, badgeModule, diff --git a/packages/swingset/src/stories/reverification-dialog.mdx b/packages/swingset/src/stories/reverification-dialog.mdx new file mode 100644 index 00000000000..0431199953a --- /dev/null +++ b/packages/swingset/src/stories/reverification-dialog.mdx @@ -0,0 +1,53 @@ +import * as ReverificationDialogStories from './reverification-dialog.stories'; + +# ReverificationDialog + +A triggerless identity-verification block with a prop-driven view and an external workflow machine. +The machine accepts a normalized first- or second-factor challenge; a future controller can translate +Clerk resources and operations into that interface. + +## Example + +The launch buttons are showcase controls, not part of the block. Each scenario creates a fresh actor. +Once open, factor selection, preparation, input, automatic code submission, errors, resend cooldown, +completion, and cancellation are all machine-driven. + + + +## Usage + +The view is controlled through flat props. Until controllers exist, a caller can inject normalized +operations into the machine and project its snapshot into view props. + +```tsx +import { + getReverificationDialogViewProps, + reverificationDialogMachine, +} from '@clerk/ui/mosaic/blocks/reverification-dialog/reverification-dialog.machine'; +import { ReverificationDialogView } from '@clerk/ui/mosaic/blocks/reverification-dialog/reverification-dialog.view'; +import { useMachine } from '@clerk/ui/mosaic/machine/useMachine'; + +const [snapshot, send] = useMachine(reverificationDialogMachine, { + context: { + initialChallenge: challenge, + prepare, + attempt, + complete, + cancel, + }, +}); + +return ; +``` diff --git a/packages/swingset/src/stories/reverification-dialog.stories.tsx b/packages/swingset/src/stories/reverification-dialog.stories.tsx new file mode 100644 index 00000000000..30fd57cb46d --- /dev/null +++ b/packages/swingset/src/stories/reverification-dialog.stories.tsx @@ -0,0 +1,180 @@ +import { + getReverificationDialogViewProps, + reverificationDialogMachine, +} from '@clerk/ui/mosaic/blocks/reverification-dialog/reverification-dialog.machine'; +import type { + ReverificationAttempt, + ReverificationAttemptResult, + ReverificationChallenge, + ReverificationEmailCodeFactor, + ReverificationFirstFactor, + ReverificationFirstFactorPhoneCodeFactor, + ReverificationPasskeyFactor, + ReverificationPasswordFactor, + ReverificationPreparationFactor, + ReverificationSecondFactor, + ReverificationSecondFactorPhoneCodeFactor, +} from '@clerk/ui/mosaic/blocks/reverification-dialog/reverification-dialog.types'; +import { ReverificationDialogView } from '@clerk/ui/mosaic/blocks/reverification-dialog/reverification-dialog.view'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import { useMachine } from '@clerk/ui/mosaic/machine/useMachine'; +import React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './reverification-dialog.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + title: 'ReverificationDialog', + source: 'packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx', +}; + +const passwordFactor: ReverificationPasswordFactor = { + id: 'password', + label: 'Password', + stage: 'first', + strategy: 'password', +}; + +const emailFactor: ReverificationEmailCodeFactor = { + id: 'email_1', + label: 'Email code to a••••@clerk.dev', + stage: 'first', + strategy: 'email_code', + emailAddressId: 'email_1', + safeIdentifier: 'a••••@clerk.dev', +}; + +const firstPhoneFactor: ReverificationFirstFactorPhoneCodeFactor = { + id: 'phone_1', + label: 'SMS code to ••••4242', + stage: 'first', + strategy: 'phone_code', + phoneNumberId: 'phone_1', + safeIdentifier: '••••4242', +}; + +const passkeyFactor: ReverificationPasskeyFactor = { + id: 'passkey', + label: 'Passkey', + stage: 'first', + strategy: 'passkey', +}; + +const secondPhoneFactor: ReverificationSecondFactorPhoneCodeFactor = { + id: 'phone_2', + label: 'SMS code to ••••8675', + stage: 'second', + strategy: 'phone_code', + phoneNumberId: 'phone_2', + safeIdentifier: '••••8675', +}; + +const secondFactors: ReverificationSecondFactor[] = [ + secondPhoneFactor, + { id: 'totp', label: 'Authenticator app', stage: 'second', strategy: 'totp' }, + { id: 'backup_code', label: 'Backup code', stage: 'second', strategy: 'backup_code' }, +]; + +const firstFactors: ReverificationFirstFactor[] = [passwordFactor, emailFactor, firstPhoneFactor, passkeyFactor]; + +interface Scenario { + id: string; + label: string; + challenge: ReverificationChallenge; + continuesToSecondFactor?: boolean; +} + +const scenarios: Scenario[] = [ + { + id: 'choose-first', + label: 'First factor — choose method', + challenge: { status: 'needs_first_factor', factors: firstFactors }, + }, + ...firstFactors.map(factor => ({ + id: `first-${factor.id}`, + label: `First factor — ${factor.label}`, + challenge: { status: 'needs_first_factor' as const, factors: firstFactors, initialFactorId: factor.id }, + })), + { + id: 'first-then-second', + label: 'First factor → second factor', + challenge: { status: 'needs_first_factor', factors: firstFactors, initialFactorId: passwordFactor.id }, + continuesToSecondFactor: true, + }, + { + id: 'choose-second', + label: 'Second factor — choose method', + challenge: { status: 'needs_second_factor', factors: secondFactors }, + }, + ...secondFactors.map(factor => ({ + id: `second-${factor.id}`, + label: `Second factor — ${factor.label}`, + challenge: { status: 'needs_second_factor' as const, factors: secondFactors, initialFactorId: factor.id }, + })), +]; + +const settleAfter = (ms: number) => new Promise(resolve => window.setTimeout(resolve, ms)); + +function MachineDrivenDialog({ scenario, onFinished }: { scenario: Scenario; onFinished: () => void }) { + const prepare = React.useCallback(async (_factor: ReverificationPreparationFactor) => { + await settleAfter(600); + }, []); + const attempt = React.useCallback( + async (attemptValue: ReverificationAttempt): Promise => { + await settleAfter(800); + if (scenario.continuesToSecondFactor && attemptValue.factor.stage === 'first') { + return { status: 'needs_second_factor', factors: secondFactors }; + } + return { status: 'complete' }; + }, + [scenario.continuesToSecondFactor], + ); + const finish = React.useCallback(() => window.setTimeout(onFinished, 0), [onFinished]); + const [snapshot, send] = useMachine(reverificationDialogMachine, { + context: { + initialChallenge: scenario.challenge, + prepare, + attempt, + complete: finish, + cancel: finish, + }, + }); + + return ; +} + +export function Default() { + const [active, setActive] = React.useState<{ scenario: Scenario; runId: number } | null>(null); + const runIdRef = React.useRef(0); + + const openScenario = (scenario: Scenario) => { + runIdRef.current += 1; + setActive({ scenario, runId: runIdRef.current }); + }; + + return ( + <> +
+ {scenarios.map(scenario => ( + + ))} +
+ {active ? ( + setActive(null)} + /> + ) : null} + + ); +} diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.machine.test.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.machine.test.ts index a24b5366bf4..28568bd50ec 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.machine.test.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.machine.test.ts @@ -1,167 +1,386 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createActor } from '../../../machine/createActor'; -import { - createReverificationDialogMachine, - getReverificationDialogActions, - getReverificationDialogState, - getReverificationDialogViewProps, -} from '../reverification-dialog.machine'; +import { getReverificationDialogViewProps, reverificationDialogMachine } from '../reverification-dialog.machine'; import type { - ReverificationDialogMachineDependencies, - ReverificationDialogState, + ReverificationAttempt, + ReverificationAttemptResult, + ReverificationChallenge, + ReverificationEmailCodeFactor, + ReverificationFirstFactorPhoneCodeFactor, + ReverificationPasswordFactor, + ReverificationPreparationFactor, + ReverificationSecondFactorPhoneCodeFactor, + ReverificationTOTPFactor, } from '../reverification-dialog.types'; -const idleResend = { isResending: false, secondsRemaining: 0 }; - -const passwordState: ReverificationDialogState = { +const passwordFactor: ReverificationPasswordFactor = { + id: 'password', + label: 'Password', + stage: 'first', strategy: 'password', - value: '', - status: 'idle', - errors: {}, - resend: idleResend, }; -const tick = () => new Promise(resolve => setTimeout(resolve, 0)); - -function createDependencies( - overrides: Partial = {}, -): ReverificationDialogMachineDependencies { - return { - initialState: passwordState, - prepare: vi.fn().mockResolvedValue(undefined), - submit: vi.fn().mockResolvedValue({ status: 'complete' }), - resend: vi.fn().mockResolvedValue(undefined), - ...overrides, - }; +const emailFactor: ReverificationEmailCodeFactor = { + id: 'email_1', + label: 'Email code to a••••@clerk.dev', + stage: 'first', + strategy: 'email_code', + emailAddressId: 'email_1', + safeIdentifier: 'a••••@clerk.dev', +}; + +const phoneFactor: ReverificationFirstFactorPhoneCodeFactor = { + id: 'phone_1', + label: 'SMS code to ••••1234', + stage: 'first', + strategy: 'phone_code', + phoneNumberId: 'phone_1', + safeIdentifier: '••••1234', +}; + +const totpFactor: ReverificationTOTPFactor = { + id: 'totp', + label: 'Authenticator app', + stage: 'second', + strategy: 'totp', +}; + +const secondPhoneFactor: ReverificationSecondFactorPhoneCodeFactor = { + id: 'phone_2', + label: 'SMS code to ••••5678', + stage: 'second', + strategy: 'phone_code', + phoneNumberId: 'phone_2', + safeIdentifier: '••••5678', +}; + +const firstFactorChallenge = ( + overrides: Partial> = {}, +): ReverificationChallenge => ({ + status: 'needs_first_factor', + factors: [passwordFactor, emailFactor, phoneFactor], + ...overrides, +}); + +function start({ + challenge = firstFactorChallenge({ initialFactorId: passwordFactor.id }), + prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined), + attempt = vi + .fn<(attempt: ReverificationAttempt) => Promise>() + .mockResolvedValue({ status: 'complete' }), + complete = vi.fn(), + cancel = vi.fn(), +}: { + challenge?: ReverificationChallenge; + prepare?: (factor: ReverificationPreparationFactor) => Promise; + attempt?: (attempt: ReverificationAttempt) => Promise; + complete?: () => void; + cancel?: () => void; +} = {}) { + const actor = createActor(reverificationDialogMachine, { + context: { initialChallenge: challenge, prepare, attempt, complete, cancel }, + }).start(); + return { actor, prepare, attempt, complete, cancel }; } -describe('reverification dialog machine', () => { - it('drives the controlled view contract from machine events', () => { - const actor = createActor(createReverificationDialogMachine(createDependencies())); - actor.start(); - const actions = getReverificationDialogActions(actor.send); +afterEach(() => { + vi.useRealTimers(); +}); - actions.onValueChange('secret'); +describe('reverificationDialogMachine', () => { + it('starts at factor selection when no initial factor is provided', () => { + const { actor } = start({ challenge: firstFactorChallenge() }); - expect(getReverificationDialogState(actor.getSnapshot())).toMatchObject({ - step: 'verify', - strategy: 'password', - value: 'secret', - status: 'idle', + expect(actor.getSnapshot().value).toBe('selectingFactor'); + expect(getReverificationDialogViewProps(actor.getSnapshot(), actor.send)).toMatchObject({ + step: 'select-factor', + stage: 'first', + availableFactors: [passwordFactor, emailFactor, phoneFactor], + onBack: undefined, + }); + + actor.send({ type: 'SELECT_FACTOR', factorId: passwordFactor.id }); + expect(actor.getSnapshot()).toMatchObject({ + value: 'verifying', + context: { currentFactor: passwordFactor }, }); }); - it('adapts a snapshot to flat, prop-driven view inputs', () => { - const actor = createActor(createReverificationDialogMachine(createDependencies())); - actor.start(); + it('treats an invalid initial factor as no selection', () => { + const { actor } = start({ challenge: firstFactorChallenge({ initialFactorId: 'missing' }) }); - const props = getReverificationDialogViewProps(actor.getSnapshot(), actor.send); + expect(actor.getSnapshot().value).toBe('selectingFactor'); + expect(actor.getSnapshot().context.currentFactor).toBeNull(); + }); - expect(props).toMatchObject({ - open: true, - strategy: 'password', - value: '', - isVerifying: false, - }); - expect(props).not.toHaveProperty('state'); + it('submits the selected password and completes the attempt', async () => { + const attempt = vi + .fn<(attempt: ReverificationAttempt) => Promise>() + .mockResolvedValue({ status: 'complete' }); + const complete = vi.fn(); + const { actor } = start({ attempt, complete }); + + actor.send({ type: 'CHANGE_VALUE', value: 'secret' }); + actor.send({ type: 'SUBMIT' }); + + expect(actor.getSnapshot().value).toBe('submitting'); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('completed')); + expect(attempt).toHaveBeenCalledWith({ factor: passwordFactor, password: 'secret' }); + expect(complete).toHaveBeenCalledOnce(); + expect(actor.getSnapshot().status).toBe('done'); }); - it('prepares delivered-code factors before showing verification', async () => { - const prepare = vi.fn().mockResolvedValue(undefined); - const actor = createActor( - createReverificationDialogMachine( - createDependencies({ - prepare, - initialState: { - ...passwordState, - step: 'select-first-factor', - availableFactors: [ - { - id: 'email', - strategy: 'email_code', - label: 'Email code', - identifier: 'i••••@clerk.dev', - }, - ], - }, - }), - ), - ); - actor.start(); - - actor.send({ type: 'SELECT_FACTOR', factorId: 'email' }); - expect(getReverificationDialogState(actor.getSnapshot())).toMatchObject({ - step: 'prepare', - preparationStatus: 'preparing', - strategy: 'email_code', - }); - - await tick(); - - expect(prepare).toHaveBeenCalledWith({ - strategy: 'email_code', - stage: 'first', - identifier: 'i••••@clerk.dev', + it('prepares a delivered-code factor and automatically submits six normalized digits', async () => { + const prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined); + const attempt = vi + .fn<(attempt: ReverificationAttempt) => Promise>() + .mockResolvedValue({ status: 'complete' }); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactorId: emailFactor.id }), + prepare, + attempt, }); - expect(getReverificationDialogState(actor.getSnapshot()).step).toBe('verify'); + + expect(actor.getSnapshot().value).toBe('preparing'); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); + expect(prepare).toHaveBeenCalledWith(emailFactor); + + actor.send({ type: 'CHANGE_VALUE', value: '12a3456' }); + expect(actor.getSnapshot().value).toBe('submitting'); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('completed')); + expect(attempt).toHaveBeenCalledWith({ factor: emailFactor, code: '123456' }); }); - it('routes a successful first factor to second-factor selection', async () => { - const actor = createActor( - createReverificationDialogMachine( - createDependencies({ - submit: vi.fn().mockResolvedValue({ - status: 'needs_second_factor', - factors: [{ id: 'totp', strategy: 'totp', label: 'Authenticator app' }], - }), - }), - ), - ); - actor.start(); + it('continues from first-factor success into a normalized second-factor challenge', async () => { + const initialChallenge = firstFactorChallenge({ initialFactorId: passwordFactor.id }); + const attempt = vi + .fn<(attempt: ReverificationAttempt) => Promise>() + .mockResolvedValue({ + status: 'needs_second_factor', + factors: [totpFactor, secondPhoneFactor], + initialFactorId: totpFactor.id, + }); + const { actor } = start({ challenge: initialChallenge, attempt }); + actor.send({ type: 'CHANGE_VALUE', value: 'secret' }); actor.send({ type: 'SUBMIT' }); - await tick(); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifying')); + expect(actor.getSnapshot().context).toMatchObject({ + challenge: { status: 'needs_second_factor' }, + currentFactor: totpFactor, + value: '', + }); + + actor.setContext({ initialChallenge }); + actor.send({ type: 'SHOW_ALTERNATIVES' }); + actor.send({ type: 'SELECT_FACTOR', factorId: secondPhoneFactor.id }); + expect(actor.getSnapshot()).toMatchObject({ + value: 'preparing', + context: { + challenge: { status: 'needs_second_factor' }, + currentFactor: secondPhoneFactor, + }, + }); + }); - expect(getReverificationDialogState(actor.getSnapshot())).toMatchObject({ - step: 'select-second-factor', + it('can begin directly at second-factor selection', () => { + const { actor } = start({ + challenge: { + status: 'needs_second_factor', + factors: [totpFactor, secondPhoneFactor], + }, + }); + + expect(getReverificationDialogViewProps(actor.getSnapshot(), actor.send)).toMatchObject({ + step: 'select-factor', stage: 'second', - availableFactors: [{ id: 'totp', strategy: 'totp' }], + availableFactors: [totpFactor, secondPhoneFactor], }); }); - it('maps submission errors back into the verification view', async () => { - const actor = createActor( - createReverificationDialogMachine( - createDependencies({ - submit: vi.fn().mockRejectedValue(new Error('Incorrect password.')), - mapError: () => ({ field: 'Incorrect password.' }), - }), - ), - ); - actor.start(); - actor.send({ type: 'CHANGE_VALUE', value: 'wrong' }); - actor.send({ type: 'SUBMIT' }); + it('matches legacy help visibility outside factor selection', async () => { + const { actor: passwordActor } = start({ + challenge: firstFactorChallenge({ factors: [passwordFactor], initialFactorId: passwordFactor.id }), + }); + expect(getReverificationDialogViewProps(passwordActor.getSnapshot(), passwordActor.send)).toMatchObject({ + step: 'verify', + onShowHelp: expect.any(Function), + }); + + const { actor: emailActor } = start({ + challenge: firstFactorChallenge({ factors: [emailFactor], initialFactorId: emailFactor.id }), + }); + await vi.waitFor(() => expect(emailActor.getSnapshot().value).toBe('verifyingCooldown')); + expect(getReverificationDialogViewProps(emailActor.getSnapshot(), emailActor.send)).toMatchObject({ + step: 'verify', + onShowHelp: undefined, + onShowAlternatives: undefined, + }); - await tick(); + emailActor.send({ type: 'SHOW_HELP' }); + expect(emailActor.getSnapshot().value).toBe('verifyingCooldown'); + }); + + it('returns from alternatives without preparing the unchanged factor again', async () => { + const prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactorId: emailFactor.id }), + prepare, + }); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); - expect(getReverificationDialogState(actor.getSnapshot())).toMatchObject({ + actor.send({ type: 'SHOW_ALTERNATIVES' }); + expect(getReverificationDialogViewProps(actor.getSnapshot(), actor.send)).toMatchObject({ + step: 'select-factor', + availableFactors: [passwordFactor, phoneFactor], + }); + + actor.send({ type: 'BACK' }); + expect(actor.getSnapshot().value).toBe('verifyingCooldown'); + expect(prepare).toHaveBeenCalledOnce(); + }); + + it('prepares a code factor again after it was replaced and selected again', async () => { + const prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactorId: emailFactor.id }), + prepare, + }); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); + + actor.send({ type: 'SHOW_ALTERNATIVES' }); + actor.send({ type: 'SELECT_FACTOR', factorId: phoneFactor.id }); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); + actor.send({ type: 'SHOW_ALTERNATIVES' }); + actor.send({ type: 'SELECT_FACTOR', factorId: emailFactor.id }); + await vi.waitFor(() => expect(prepare).toHaveBeenCalledTimes(3)); + + expect(prepare).toHaveBeenNthCalledWith(1, emailFactor); + expect(prepare).toHaveBeenNthCalledWith(2, phoneFactor); + expect(prepare).toHaveBeenNthCalledWith(3, emailFactor); + }); + + it('keeps preparation and its retry inside the code-verification view', async () => { + const prepare = vi + .fn<(factor: ReverificationPreparationFactor) => Promise>() + .mockRejectedValueOnce(new Error('Could not send the code.')) + .mockResolvedValue(undefined); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactorId: emailFactor.id }), + prepare, + }); + + expect(getReverificationDialogViewProps(actor.getSnapshot(), actor.send)).toMatchObject({ step: 'verify', + factor: emailFactor, + isInputDisabled: true, + resend: { isResending: true, secondsRemaining: 0 }, + onResend: undefined, + onShowAlternatives: expect.any(Function), + }); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('preparationFailed')); + expect(getReverificationDialogViewProps(actor.getSnapshot(), actor.send)).toMatchObject({ + step: 'verify', + factor: emailFactor, + isInputDisabled: true, + formError: 'Could not send the code.', + resend: { isResending: false, secondsRemaining: 0 }, + onResend: expect.any(Function), + onShowAlternatives: expect.any(Function), + }); + + actor.send({ type: 'RESEND' }); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); + expect(prepare).toHaveBeenCalledTimes(2); + }); + + it('returns verification failures to the field and clears them on input', async () => { + const attempt = vi + .fn<(attempt: ReverificationAttempt) => Promise>() + .mockRejectedValue(new Error('Incorrect password.')); + const { actor } = start({ attempt }); + + actor.send({ type: 'CHANGE_VALUE', value: 'wrong' }); + actor.send({ type: 'SUBMIT' }); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifying')); + + expect(actor.getSnapshot().context).toMatchObject({ value: '', - status: 'error', - errors: { field: 'Incorrect password.' }, + error: { location: 'field', message: 'Incorrect password.' }, + }); + actor.send({ type: 'CHANGE_VALUE', value: 'new value' }); + expect(actor.getSnapshot().context.error).toBeNull(); + }); + + it('owns resend cooldown and only retries after it expires', async () => { + vi.useFakeTimers(); + const prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactorId: emailFactor.id }), + prepare, }); + await vi.runAllTicks(); + + expect(actor.getSnapshot()).toMatchObject({ + value: 'verifyingCooldown', + context: { resendSecondsRemaining: 30 }, + }); + actor.send({ type: 'RESEND' }); + expect(prepare).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(30_000); + expect(actor.getSnapshot().value).toBe('verifying'); + actor.send({ type: 'RESEND' }); + expect(actor.getSnapshot().value).toBe('resending'); + await vi.runAllTicks(); + expect(actor.getSnapshot()).toMatchObject({ + value: 'verifyingCooldown', + context: { resendSecondsRemaining: 30 }, + }); + expect(prepare).toHaveBeenCalledTimes(2); + }); + + it('allows immediate resend retry after a resend failure', async () => { + vi.useFakeTimers(); + const prepare = vi + .fn<(factor: ReverificationPreparationFactor) => Promise>() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Rate limited.')) + .mockResolvedValue(undefined); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactorId: emailFactor.id }), + prepare, + }); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(30_000); + + actor.send({ type: 'RESEND' }); + await vi.runAllTicks(); + expect(actor.getSnapshot()).toMatchObject({ + value: 'verifying', + context: { + resendSecondsRemaining: 0, + error: { location: 'form', message: 'Rate limited.' }, + }, + }); + + actor.send({ type: 'RESEND' }); + expect(actor.getSnapshot().value).toBe('resending'); + expect(prepare).toHaveBeenCalledTimes(3); }); - it('reports cancellation and terminates the flow', () => { - const onCancel = vi.fn(); - const actor = createActor(createReverificationDialogMachine(createDependencies({ onCancel }))); - actor.start(); + it('finishes cancellation and reports it once', () => { + const cancel = vi.fn(); + const { actor } = start({ cancel }); actor.send({ type: 'CANCEL' }); - expect(onCancel).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); expect(actor.getSnapshot()).toMatchObject({ value: 'cancelled', status: 'done' }); + expect(getReverificationDialogViewProps(actor.getSnapshot(), actor.send).open).toBe(false); }); }); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.view.test.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.view.test.tsx index b3ecd7395a1..11c35899d6b 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.view.test.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.view.test.tsx @@ -1,32 +1,65 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { ReverificationDialogViewProps } from '../reverification-dialog.types'; +import type { + ReverificationDialogVerifyViewProps, + ReverificationDialogViewProps, + ReverificationEmailCodeFactor, + ReverificationPasskeyFactor, + ReverificationPasswordFactor, +} from '../reverification-dialog.types'; import { ReverificationDialogView } from '../reverification-dialog.view'; -afterEach(() => cleanup()); - -const createProps = (overrides: Partial = {}): ReverificationDialogViewProps => ({ - open: true, +const passwordFactor: ReverificationPasswordFactor = { + id: 'password', + label: 'Password', + stage: 'first', strategy: 'password', +}; + +const emailFactor: ReverificationEmailCodeFactor = { + id: 'email_1', + label: 'Email code to a••••@clerk.dev', + stage: 'first', + strategy: 'email_code', + emailAddressId: 'email_1', + safeIdentifier: 'a••••@clerk.dev', +}; + +const passkeyFactor: ReverificationPasskeyFactor = { + id: 'passkey', + label: 'Passkey', + stage: 'first', + strategy: 'passkey', +}; + +const verifyProps = ( + overrides: Partial = {}, +): ReverificationDialogVerifyViewProps => ({ + open: true, + step: 'verify', + factor: passwordFactor, value: '', + canSubmit: false, + isInputDisabled: false, + isVerifying: false, onOpenChange: vi.fn(), onValueChange: vi.fn(), onSubmit: vi.fn(), - onResend: vi.fn(), ...overrides, }); +afterEach(() => cleanup()); + describe('ReverificationDialogView', () => { - it('is controlled by open and onOpenChange props', async () => { + it('is controlled by open and onOpenChange', async () => { const onOpenChange = vi.fn(); - const { rerender } = render(); + const view = render(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - rerender(); + view.rerender(); await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' })); expect(onOpenChange).toHaveBeenCalledWith(false); @@ -35,64 +68,80 @@ describe('ReverificationDialogView', () => { it('forwards password input and form submission through flat callbacks', async () => { const onSubmit = vi.fn(); const onValueChange = vi.fn(); - const { rerender } = render(); + const view = render(); fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } }); expect(onValueChange).toHaveBeenCalledWith('secret'); - rerender(); + view.rerender( + , + ); await userEvent.setup().click(screen.getByRole('button', { name: 'Continue' })); expect(onSubmit).toHaveBeenCalledOnce(); }); - it('normalizes a delivered code and submits when six digits are controlled back in', async () => { + it('reports delivered-code input without owning normalization or completion', () => { const onSubmit = vi.fn(); + const onValueChange = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Verification code'), { target: { value: '12a3456' } }); - function ControlledCodeDialog() { - const [value, setValue] = React.useState(''); - return ( - - ); - } - - render(); - await userEvent.setup().type(screen.getByLabelText('Verification code'), '12a3456'); - - expect(screen.getByLabelText('Verification code')).toHaveValue('123456'); - expect(onSubmit).toHaveBeenCalledWith('123456'); + expect(onValueChange).toHaveBeenCalledWith('12a3456'); + expect(onSubmit).not.toHaveBeenCalled(); }); - it('renders factor selection as prop-driven actions', async () => { - const onSelectFactor = vi.fn(); + it('keeps the code-entry modal visible while preparation disables its input', () => { render( , ); + expect(screen.getByLabelText('Verification code')).toBeDisabled(); + expect(screen.getByText(/Enter the verification code sent to/)).toBeInTheDocument(); + expect(screen.queryByText(/Preparing verification/)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Resend' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('renders factor selection as prop-driven actions', async () => { + const onSelectFactor = vi.fn(); + const props: ReverificationDialogViewProps = { + open: true, + step: 'select-factor', + stage: 'first', + availableFactors: [passkeyFactor], + onOpenChange: vi.fn(), + onSelectFactor, + onShowHelp: vi.fn(), + }; + render(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Passkey' })); - expect(onSelectFactor).toHaveBeenCalledWith('passkey'); + expect(onSelectFactor).toHaveBeenCalledWith(passkeyFactor.id); + }); + + it('renders only valid navigation actions supplied by the machine', async () => { + const onShowHelp = vi.fn(); + render(); + + expect(screen.queryByRole('button', { name: 'Use another method' })).not.toBeInTheDocument(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Having trouble?' })); + expect(onShowHelp).toHaveBeenCalledOnce(); }); it('exposes verification progress and errors accessibly', () => { render( { }); it('renders passkey verification without an input', () => { - render(); + render(); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Verify with passkey' })).toBeEnabled(); }); + + it('renders a machine-owned resend countdown as inert', async () => { + const onResend = vi.fn(); + render( + , + ); + + const resend = screen.getByRole('button', { name: 'Resend (30s)' }); + expect(resend).toHaveAttribute('aria-disabled', 'true'); + await userEvent.setup().click(resend); + expect(onResend).not.toHaveBeenCalled(); + }); }); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts index 15ceca2ef46..1d0445ca096 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts @@ -1,394 +1,459 @@ import { setup } from '../../machine/setup'; import type { Snapshot } from '../../machine/types'; +import { reverificationDialogMessages as m } from './reverification-dialog.messages'; import type { - ReverificationDialogActions, - ReverificationDialogErrors, - ReverificationDialogMachineDependencies, - ReverificationDialogState, - ReverificationDialogSubmissionResult, + ReverificationAttempt, + ReverificationAttemptResult, + ReverificationChallenge, + ReverificationDialogError, ReverificationDialogViewProps, ReverificationFactor, - ReverificationStage, - ReverificationStrategy, + ReverificationPreparationFactor, } from './reverification-dialog.types'; -interface ReverificationDialogMachineContext extends ReverificationDialogState { - pendingValue: string; - prepare: ReverificationDialogMachineDependencies['prepare']; - submit: ReverificationDialogMachineDependencies['submit']; - resendAction: ReverificationDialogMachineDependencies['resend']; +const RESEND_COOLDOWN_SECONDS = 30; + +const emptyChallenge: ReverificationChallenge = { + status: 'needs_first_factor', + factors: [], +}; + +type ReverificationDialogReturnState = 'selectingFactor' | 'routingFactor'; + +export interface ReverificationDialogMachineContext { + initialChallenge: ReverificationChallenge; + challenge: ReverificationChallenge; + currentFactor: ReverificationFactor | null; + value: string; + error: ReverificationDialogError | null; + preparedFactorId: string | null; + resendSecondsRemaining: number; + returnState: ReverificationDialogReturnState; + prepare: (factor: ReverificationPreparationFactor) => Promise; + attempt: (attempt: ReverificationAttempt) => Promise; + complete: () => void; cancel: () => void; - mapError: (error: unknown) => ReverificationDialogErrors; } export type ReverificationDialogMachineEvent = | { type: 'CHANGE_VALUE'; value: string } - | { type: 'SUBMIT'; value?: string } + | { type: 'SUBMIT' } | { type: 'RESEND' } | { type: 'CANCEL' } | { type: 'SELECT_FACTOR'; factorId: string } - | { type: 'BACK' } - | { type: 'RETRY_PREPARE' } - | { type: 'SHOW_HELP' }; + | { type: 'SHOW_ALTERNATIVES' } + | { type: 'SHOW_HELP' } + | { type: 'BACK' }; const { createMachine, assign, fromPromise } = setup< ReverificationDialogMachineContext, ReverificationDialogMachineEvent >(); -const requiresPreparation = (strategy: ReverificationStrategy) => - strategy === 'email_code' || strategy === 'phone_code'; +const factorsFrom = (context: ReverificationDialogMachineContext): ReverificationFactor[] => context.challenge.factors; + +const factorFrom = (context: ReverificationDialogMachineContext, factorId: string) => + factorsFrom(context).find(factor => factor.id === factorId); + +const initialFactorFrom = (challenge: ReverificationChallenge): ReverificationFactor | null => + challenge.initialFactorId + ? (challenge.factors.find(factor => factor.id === challenge.initialFactorId) ?? null) + : null; + +const alternativesFrom = (context: ReverificationDialogMachineContext) => + factorsFrom(context).filter(factor => factor.id !== context.currentFactor?.id); + +const hasAlternatives = (context: ReverificationDialogMachineContext) => alternativesFrom(context).length > 0; -const stageForStep = (step: ReverificationDialogState['step']): ReverificationStage => - step === 'select-second-factor' ? 'second' : 'first'; +const requiresPreparation = (factor: ReverificationFactor | null): factor is ReverificationPreparationFactor => + factor?.strategy === 'email_code' || factor?.strategy === 'phone_code'; -const machineStateFor = (state: ReverificationDialogState) => { - if (state.step === 'prepare') { - return state.preparationStatus === 'error' ? 'preparationFailed' : 'preparing'; +const isFixedLengthCode = (factor: ReverificationFactor | null) => + factor?.strategy === 'email_code' || factor?.strategy === 'phone_code' || factor?.strategy === 'totp'; + +const normalizeValue = (factor: ReverificationFactor | null, value: string) => + isFixedLengthCode(factor) ? value.replace(/\D/g, '').slice(0, 6) : value; + +const canSubmit = (context: ReverificationDialogMachineContext) => { + const factor = context.currentFactor; + if (!factor) { + return false; + } + if (factor.strategy === 'passkey') { + return true; + } + if (isFixedLengthCode(factor)) { + return context.value.length === 6; } + return context.value.trim().length > 0; +}; - switch (state.step) { - case 'select-first-factor': - return 'selectingFirstFactor'; - case 'select-second-factor': - return 'selectingSecondFactor'; - case 'unavailable': - return 'unavailable'; - case 'help': - return 'help'; - default: - return 'verifying'; +const attemptFrom = (context: ReverificationDialogMachineContext): ReverificationAttempt => { + const factor = context.currentFactor; + if (!factor) { + throw new Error(m.genericError); + } + if (factor.strategy === 'password') { + return { factor, password: context.value }; } + if (factor.strategy === 'passkey') { + return { factor }; + } + return { factor, code: context.value }; }; -const operationFrom = (context: ReverificationDialogMachineContext) => ({ - strategy: context.strategy, - stage: context.stage ?? stageForStep(context.step), - identifier: context.identifier, +const errorFrom = (error: unknown, location: ReverificationDialogError['location']): ReverificationDialogError => ({ + location, + message: error instanceof Error ? error.message : m.genericError, }); -const selectFactor = ( - context: ReverificationDialogMachineContext, - factorId: string, -): ReverificationFactor | undefined => context.availableFactors?.find(factor => factor.id === factorId); +const attemptErrorLocation = (context: ReverificationDialogMachineContext): ReverificationDialogError['location'] => + context.currentFactor?.strategy === 'passkey' ? 'form' : 'field'; -const defaultMapError = (error: unknown): ReverificationDialogErrors => ({ - form: error instanceof Error ? error.message : String(error), -}); +const changeValue = ({ + context, + event, +}: { + context: ReverificationDialogMachineContext; + event: Extract; +}) => { + const value = normalizeValue(context.currentFactor, event.value); + return { + target: isFixedLengthCode(context.currentFactor) && value.length === 6 ? 'submitting' : undefined, + context: { value, error: null }, + }; +}; -export function createReverificationDialogMachine(dependencies: ReverificationDialogMachineDependencies) { - return createMachine({ - id: 'reverificationDialog', - initial: context => machineStateFor(context), - context: { - ...dependencies.initialState, - stage: dependencies.initialState.stage ?? stageForStep(dependencies.initialState.step), - pendingValue: '', - prepare: dependencies.prepare, - submit: dependencies.submit, - resendAction: dependencies.resend, - cancel: dependencies.onCancel ?? (() => undefined), - mapError: dependencies.mapError ?? defaultMapError, +export const reverificationDialogMachine = createMachine({ + id: 'reverificationDialog', + initial: 'initializing', + context: { + initialChallenge: emptyChallenge, + challenge: emptyChallenge, + currentFactor: null, + value: '', + error: null, + preparedFactorId: null, + resendSecondsRemaining: 0, + returnState: 'selectingFactor', + prepare: () => Promise.resolve(), + attempt: () => Promise.resolve({ status: 'complete' }), + complete: () => {}, + cancel: () => {}, + }, + states: { + initializing: { + entry: assign(context => ({ challenge: context.initialChallenge })), + always: 'starting', }, - states: { - selectingFirstFactor: { - on: { - SELECT_FACTOR: { - target: 'routingFactor', - guard: (context, event) => Boolean(selectFactor(context, event.factorId)), - actions: assign((context, event) => { - const factor = selectFactor(context, event.factorId); - if (!factor) { - return {}; - } - return { - strategy: factor.strategy, - stage: 'first', - identifier: factor.identifier, - value: '', - status: 'idle', - errors: {}, - }; - }), - }, - SHOW_HELP: 'help', - CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + starting: { + entry: assign(context => ({ + currentFactor: initialFactorFrom(context.challenge), + value: '', + error: null, + preparedFactorId: null, + resendSecondsRemaining: 0, + returnState: 'selectingFactor', + })), + always: [ + { target: 'unavailable', guard: context => factorsFrom(context).length === 0 }, + { target: 'routingFactor', guard: context => Boolean(context.currentFactor) }, + { target: 'selectingFactor' }, + ], + }, + selectingFactor: { + on: { + SELECT_FACTOR: { + target: 'routingFactor', + guard: (context, event) => Boolean(factorFrom(context, event.factorId)), + actions: assign((context, event) => ({ + currentFactor: factorFrom(context, event.factorId) ?? context.currentFactor, + value: '', + error: null, + preparedFactorId: null, + resendSecondsRemaining: 0, + })), }, - }, - - selectingSecondFactor: { - on: { - SELECT_FACTOR: { - target: 'routingFactor', - guard: (context, event) => Boolean(selectFactor(context, event.factorId)), - actions: assign((context, event) => { - const factor = selectFactor(context, event.factorId); - if (!factor) { - return {}; - } - return { - strategy: factor.strategy, - stage: 'second', - identifier: factor.identifier, - value: '', - status: 'idle', - errors: {}, - }; - }), - }, - BACK: 'selectingFirstFactor', - SHOW_HELP: 'help', - CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + BACK: { + target: 'routingFactor', + guard: context => Boolean(context.currentFactor), }, + SHOW_HELP: { + target: 'help', + actions: assign(() => ({ returnState: 'selectingFactor' })), + }, + CANCEL: 'cancelled', }, - - routingFactor: { - always: [ - { target: 'preparing', guard: context => requiresPreparation(context.strategy) }, - { target: 'verifying' }, - ], - }, - - preparing: { - invoke: fromPromise(context => context.prepare(operationFrom(context)), { + }, + routingFactor: { + always: [ + { target: 'unavailable', guard: context => !context.currentFactor }, + { + target: 'preparing', + guard: context => + requiresPreparation(context.currentFactor) && context.preparedFactorId !== context.currentFactor.id, + }, + { + target: 'verifyingCooldown', + guard: context => context.resendSecondsRemaining > 0, + }, + { target: 'verifying' }, + ], + }, + preparing: { + invoke: fromPromise( + context => { + if (!requiresPreparation(context.currentFactor)) { + return Promise.reject(new Error(m.genericError)); + } + return context.prepare(context.currentFactor); + }, + { onDone: { - target: 'verifying', - actions: assign(() => ({ preparationStatus: undefined, errors: {} })), + target: 'verifyingCooldown', + actions: assign(context => ({ + preparedFactorId: context.currentFactor?.id ?? null, + resendSecondsRemaining: RESEND_COOLDOWN_SECONDS, + error: null, + })), }, onError: { target: 'preparationFailed', - actions: assign((context, event) => ({ - preparationStatus: 'error', - errors: context.mapError(event.error), - })), + actions: assign((_, event) => ({ error: errorFrom(event.error, 'form') })), }, - }), - on: { - BACK: [ - { target: 'selectingSecondFactor', guard: context => context.stage === 'second' }, - { target: 'selectingFirstFactor' }, - ], - CANCEL: { target: 'cancelled', actions: context => context.cancel() }, }, + ), + on: { + SHOW_ALTERNATIVES: { target: 'selectingFactor', guard: hasAlternatives }, + CANCEL: 'cancelled', }, - - preparationFailed: { - on: { - RETRY_PREPARE: { - target: 'preparing', - actions: assign(() => ({ preparationStatus: 'preparing', errors: {} })), - }, - BACK: [ - { target: 'selectingSecondFactor', guard: context => context.stage === 'second' }, - { target: 'selectingFirstFactor' }, - ], - CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + }, + preparationFailed: { + on: { + RESEND: 'preparing', + SHOW_ALTERNATIVES: { + target: 'selectingFactor', + guard: hasAlternatives, }, + CANCEL: 'cancelled', }, - - verifying: { - on: { - CHANGE_VALUE: { - actions: assign((_, event) => ({ value: event.value, status: 'idle', errors: {} })), - }, - SUBMIT: { - target: 'submitting', - guard: (context, event) => context.strategy === 'passkey' || (event.value ?? context.value).length > 0, - actions: assign((context, event) => ({ - pendingValue: event.value ?? context.value, - status: 'verifying', - errors: {}, + }, + verifying: { + on: { + CHANGE_VALUE: changeValue, + SUBMIT: { target: 'submitting', guard: canSubmit }, + RESEND: { target: 'resending', guard: context => requiresPreparation(context.currentFactor) }, + SHOW_ALTERNATIVES: { target: 'selectingFactor', guard: hasAlternatives }, + SHOW_HELP: { + target: 'help', + guard: context => context.currentFactor?.strategy === 'password' && !hasAlternatives(context), + actions: assign(() => ({ returnState: 'routingFactor' })), + }, + CANCEL: 'cancelled', + }, + }, + verifyingCooldown: { + on: { + CHANGE_VALUE: changeValue, + SUBMIT: { target: 'submitting', guard: canSubmit }, + SHOW_ALTERNATIVES: { target: 'selectingFactor', guard: hasAlternatives }, + SHOW_HELP: { + target: 'help', + guard: context => context.currentFactor?.strategy === 'password' && !hasAlternatives(context), + actions: assign(() => ({ returnState: 'routingFactor' })), + }, + CANCEL: 'cancelled', + }, + after: { + 1000: [ + { + target: 'verifyingCooldown', + guard: context => context.resendSecondsRemaining > 1, + actions: assign(context => ({ + resendSecondsRemaining: context.resendSecondsRemaining - 1, })), }, - RESEND: { - target: 'resending', - guard: context => requiresPreparation(context.strategy) && !context.resend.isResending, + { + target: 'verifying', + actions: assign(() => ({ resendSecondsRemaining: 0 })), }, - BACK: [ - { target: 'selectingSecondFactor', guard: context => context.stage === 'second' }, - { target: 'selectingFirstFactor' }, - ], - SHOW_HELP: 'help', - CANCEL: { target: 'cancelled', actions: context => context.cancel() }, - }, + ], }, - - submitting: { - invoke: fromPromise( - context => - context.submit({ - ...operationFrom(context), - value: context.pendingValue, - }), + }, + submitting: { + invoke: fromPromise(context => context.attempt(attemptFrom(context)), { + onDone: [ + { + target: 'completed', + guard: (_, event) => event.output.status === 'complete', + }, { - onDone: [ - { - target: 'unavailable', - guard: (_, event) => event.output.status === 'needs_second_factor' && event.output.factors.length === 0, - actions: assign(() => ({ status: 'idle', availableFactors: [] })), - }, - { - target: 'selectingSecondFactor', - guard: (_, event) => event.output.status === 'needs_second_factor', - actions: assign((_, event) => ({ - stage: 'second', - value: '', - status: 'idle', - errors: {}, - availableFactors: ( - event.output as Extract - ).factors, - })), - }, - { target: 'complete' }, - ], - onError: { - target: 'verifying', - actions: assign((context, event) => ({ - value: '', - status: 'error', - errors: context.mapError(event.error), - })), - }, + target: 'starting', + guard: (_, event) => event.output.status === 'needs_second_factor', + actions: assign((_, event) => { + if (event.output.status !== 'needs_second_factor') { + return {}; + } + return { + challenge: { + status: 'needs_second_factor', + factors: event.output.factors, + initialFactorId: event.output.initialFactorId, + }, + }; + }), + }, + ], + onError: ({ context, event }) => ({ + target: context.resendSecondsRemaining > 0 ? 'verifyingCooldown' : 'verifying', + context: { + value: '', + error: errorFrom(event.error, attemptErrorLocation(context)), }, - ), - on: { - CANCEL: { target: 'cancelled', actions: context => context.cancel() }, + }), + }), + on: { CANCEL: 'cancelled' }, + }, + resending: { + invoke: fromPromise( + context => { + if (!requiresPreparation(context.currentFactor)) { + return Promise.reject(new Error(m.genericError)); + } + return context.prepare(context.currentFactor); }, - }, - - resending: { - entry: assign(context => ({ resend: { ...context.resend, isResending: true } })), - invoke: fromPromise(context => context.resendAction(operationFrom(context)), { + { onDone: { - target: 'verifying', + target: 'verifyingCooldown', actions: assign(context => ({ - value: '', - resend: { ...context.resend, isResending: false }, + preparedFactorId: context.currentFactor?.id ?? null, + resendSecondsRemaining: RESEND_COOLDOWN_SECONDS, + error: null, })), }, onError: { target: 'verifying', - actions: assign((context, event) => ({ - resend: { ...context.resend, isResending: false }, - errors: context.mapError(event.error), + actions: assign((_, event) => ({ + resendSecondsRemaining: 0, + error: errorFrom(event.error, 'form'), })), }, - }), - on: { - CANCEL: { target: 'cancelled', actions: context => context.cancel() }, - }, - }, - - unavailable: { - on: { - BACK: [ - { target: 'selectingSecondFactor', guard: context => context.stage === 'second' }, - { target: 'selectingFirstFactor' }, - ], - CANCEL: { target: 'cancelled', actions: context => context.cancel() }, }, + ), + on: { + SHOW_ALTERNATIVES: { target: 'selectingFactor', guard: hasAlternatives }, + CANCEL: 'cancelled', }, - - help: { - on: { - BACK: [ - { target: 'selectingSecondFactor', guard: context => context.stage === 'second' }, - { target: 'selectingFirstFactor' }, - ], - CANCEL: { target: 'cancelled', actions: context => context.cancel() }, - }, + }, + help: { + on: { + BACK: ({ context }) => ({ target: context.returnState }), + CANCEL: 'cancelled', }, - - complete: { type: 'final' }, - cancelled: { type: 'final' }, }, - }); -} + unavailable: { on: { CANCEL: 'cancelled' } }, + completed: { + type: 'final', + entry: context => context.complete(), + }, + cancelled: { + type: 'final', + entry: context => context.cancel(), + }, + }, +}); export type ReverificationDialogMachineSnapshot = Snapshot; -export function getReverificationDialogState(snapshot: ReverificationDialogMachineSnapshot): ReverificationDialogState { - const { context } = snapshot; - const step = (() => { - switch (snapshot.value) { - case 'selectingFirstFactor': - return 'select-first-factor'; - case 'selectingSecondFactor': - return 'select-second-factor'; - case 'preparing': - case 'preparationFailed': - return 'prepare'; - case 'unavailable': - return 'unavailable'; - case 'help': - return 'help'; - default: - return 'verify'; - } - })() satisfies ReverificationDialogState['step']; - - return { - strategy: context.strategy, - step, - stage: context.stage, - availableFactors: context.availableFactors, - preparationStatus: - snapshot.value === 'preparationFailed' ? 'error' : snapshot.value === 'preparing' ? 'preparing' : undefined, - identifier: context.identifier, - value: context.value, - status: snapshot.value === 'submitting' ? 'verifying' : context.status, - errors: context.errors, - resend: context.resend, - }; -} - -export function getReverificationDialogActions( - send: (event: ReverificationDialogMachineEvent) => void, -): ReverificationDialogActions { - return { - onValueChange: value => send({ type: 'CHANGE_VALUE', value }), - onSubmit: value => send({ type: 'SUBMIT', value }), - onResend: () => send({ type: 'RESEND' }), - onCancel: () => send({ type: 'CANCEL' }), - onSelectFactor: factorId => send({ type: 'SELECT_FACTOR', factorId }), - onBack: () => send({ type: 'BACK' }), - onPrepare: () => send({ type: 'RETRY_PREPARE' }), - onShowHelp: () => send({ type: 'SHOW_HELP' }), - }; -} - export function getReverificationDialogViewProps( snapshot: ReverificationDialogMachineSnapshot, send: (event: ReverificationDialogMachineEvent) => void, ): ReverificationDialogViewProps { - const state = getReverificationDialogState(snapshot); - const actions = getReverificationDialogActions(send); - - return { - open: snapshot.status === 'active', - strategy: state.strategy, - step: state.step, - availableFactors: state.availableFactors, - preparationStatus: state.preparationStatus, - identifier: state.identifier, - value: state.value, - isVerifying: state.status === 'verifying', - fieldError: state.errors.field, - formError: state.errors.form, - isResending: state.resend.isResending, - resendSecondsRemaining: state.resend.secondsRemaining, - onOpenChange: open => { - if (!open) { - actions.onCancel(); + const context = + snapshot.value === 'initializing' + ? { ...snapshot.context, challenge: snapshot.context.initialChallenge } + : snapshot.context; + const open = snapshot.status === 'active'; + const base = { + open, + onOpenChange: (nextOpen: boolean) => { + if (!nextOpen) { + send({ type: 'CANCEL' }); } }, - onValueChange: actions.onValueChange, - onSubmit: actions.onSubmit, - onResend: actions.onResend, - onSelectFactor: actions.onSelectFactor, - onBack: actions.onBack, - onPrepare: actions.onPrepare, - onShowHelp: actions.onShowHelp, + }; + const formError = context.error?.location === 'form' ? context.error.message : undefined; + + if (snapshot.value === 'unavailable') { + return { ...base, step: 'unavailable' }; + } + + if (snapshot.value === 'help') { + return { + ...base, + step: 'help', + onBack: () => send({ type: 'BACK' }), + }; + } + + if (snapshot.value === 'selectingFactor') { + return { + ...base, + step: 'select-factor', + stage: context.challenge.status === 'needs_first_factor' ? 'first' : 'second', + availableFactors: context.currentFactor ? alternativesFrom(context) : factorsFrom(context), + formError, + onSelectFactor: factorId => send({ type: 'SELECT_FACTOR', factorId }), + onBack: context.currentFactor ? () => send({ type: 'BACK' }) : undefined, + onShowHelp: () => send({ type: 'SHOW_HELP' }), + }; + } + + const factor = context.currentFactor ?? initialFactorFrom(context.challenge); + if (!factor) { + if (factorsFrom(context).length > 0) { + return { + ...base, + step: 'select-factor', + stage: context.challenge.status === 'needs_first_factor' ? 'first' : 'second', + availableFactors: factorsFrom(context), + onSelectFactor: factorId => send({ type: 'SELECT_FACTOR', factorId }), + onShowHelp: () => send({ type: 'SHOW_HELP' }), + }; + } + return { ...base, step: 'unavailable' }; + } + + const isVerifying = snapshot.value === 'submitting'; + const isResending = snapshot.value === 'resending'; + const isInteractive = snapshot.value === 'verifying' || snapshot.value === 'verifyingCooldown'; + const isPreparing = snapshot.value === 'preparing'; + const preparationFailed = snapshot.value === 'preparationFailed'; + const canResend = (snapshot.value === 'verifying' || preparationFailed) && requiresPreparation(factor); + const canShowAlternatives = + (isInteractive || isPreparing || preparationFailed || isResending) && hasAlternatives(context); + const canShowHelp = isInteractive && factor.strategy === 'password' && !hasAlternatives(context); + + return { + ...base, + step: 'verify', + factor, + value: context.value, + canSubmit: isInteractive && canSubmit(context), + isInputDisabled: !isInteractive, + isVerifying, + fieldError: context.error?.location === 'field' ? context.error.message : undefined, + formError, + resend: requiresPreparation(factor) + ? { + isResending: isResending || isPreparing, + secondsRemaining: context.resendSecondsRemaining, + } + : undefined, + onValueChange: value => send({ type: 'CHANGE_VALUE', value }), + onSubmit: () => send({ type: 'SUBMIT' }), + onResend: canResend ? () => send({ type: 'RESEND' }) : undefined, + onShowAlternatives: canShowAlternatives ? () => send({ type: 'SHOW_ALTERNATIVES' }) : undefined, + onShowHelp: canShowHelp ? () => send({ type: 'SHOW_HELP' }) : undefined, }; } diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.tsx index 952ed6d36e8..f3afd6e52bf 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.tsx @@ -3,9 +3,7 @@ export const reverificationDialogMessages = { chooseFirst: 'Choose how to verify your identity.', chooseSecond: 'Choose a second verification method to continue.', havingTrouble: 'Having trouble?', - preparingDescription: 'Preparing your verification method.', - preparing: 'Preparing verification…', - prepareError: 'Could not prepare verification.', + genericError: 'Something went wrong. Please try again.', unavailableTitle: 'Unable to verify', unavailableDescription: 'No verification methods are available for this account.', helpDescription: 'Contact support if you cannot access any verification method.', @@ -26,5 +24,4 @@ export const reverificationDialogMessages = { cancel: 'Cancel', close: 'Close', continue: 'Continue', - tryAgain: 'Try again', }; diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts index 42ff4419365..31b8f070e54 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts @@ -1,17 +1,99 @@ -export type ReverificationStrategy = 'password' | 'email_code' | 'phone_code' | 'passkey' | 'totp' | 'backup_code'; - export type ReverificationStage = 'first' | 'second'; -export interface ReverificationFactor { +interface ReverificationFactorBase { id: string; - strategy: ReverificationStrategy; label: string; - identifier?: string; } -export interface ReverificationDialogErrors { - field?: string; - form?: string; +export interface ReverificationPasswordFactor extends ReverificationFactorBase { + stage: 'first'; + strategy: 'password'; +} + +export interface ReverificationEmailCodeFactor extends ReverificationFactorBase { + stage: 'first'; + strategy: 'email_code'; + emailAddressId: string; + safeIdentifier: string; +} + +export interface ReverificationFirstFactorPhoneCodeFactor extends ReverificationFactorBase { + stage: 'first'; + strategy: 'phone_code'; + phoneNumberId: string; + safeIdentifier: string; +} + +export interface ReverificationPasskeyFactor extends ReverificationFactorBase { + stage: 'first'; + strategy: 'passkey'; +} + +export interface ReverificationSecondFactorPhoneCodeFactor extends ReverificationFactorBase { + stage: 'second'; + strategy: 'phone_code'; + phoneNumberId: string; + safeIdentifier: string; +} + +export interface ReverificationTOTPFactor extends ReverificationFactorBase { + stage: 'second'; + strategy: 'totp'; +} + +export interface ReverificationBackupCodeFactor extends ReverificationFactorBase { + stage: 'second'; + strategy: 'backup_code'; +} + +export type ReverificationFirstFactor = + | ReverificationPasswordFactor + | ReverificationEmailCodeFactor + | ReverificationFirstFactorPhoneCodeFactor + | ReverificationPasskeyFactor; + +export type ReverificationSecondFactor = + | ReverificationSecondFactorPhoneCodeFactor + | ReverificationTOTPFactor + | ReverificationBackupCodeFactor; + +export type ReverificationFactor = ReverificationFirstFactor | ReverificationSecondFactor; + +export type ReverificationStrategy = ReverificationFactor['strategy']; + +export type ReverificationChallenge = + | { + status: 'needs_first_factor'; + factors: ReverificationFirstFactor[]; + initialFactorId?: string; + } + | { + status: 'needs_second_factor'; + factors: ReverificationSecondFactor[]; + initialFactorId?: string; + }; + +export type ReverificationPreparationFactor = Extract; + +export type ReverificationAttempt = + | { factor: ReverificationPasswordFactor; password: string } + | { + factor: Exclude; + code: string; + } + | { factor: ReverificationPasskeyFactor }; + +export type ReverificationAttemptResult = + | { status: 'complete' } + | { + status: 'needs_second_factor'; + factors: ReverificationSecondFactor[]; + initialFactorId?: string; + }; + +export interface ReverificationDialogError { + location: 'field' | 'form'; + message: string; } export interface ReverificationDialogResendState { @@ -19,72 +101,49 @@ export interface ReverificationDialogResendState { secondsRemaining: number; } -export interface ReverificationDialogState { - strategy: ReverificationStrategy; - step?: 'select-first-factor' | 'prepare' | 'verify' | 'select-second-factor' | 'unavailable' | 'help'; - stage?: ReverificationStage; - availableFactors?: ReverificationFactor[]; - preparationStatus?: 'preparing' | 'error'; - identifier?: string; - value: string; - status: 'idle' | 'verifying' | 'error'; - errors: ReverificationDialogErrors; - resend: ReverificationDialogResendState; +interface ReverificationDialogViewBaseProps { + open: boolean; + onOpenChange: (open: boolean) => void; } -export interface ReverificationDialogActions { - onValueChange: (value: string) => void; - onSubmit: (completedValue?: string) => void; - onResend: () => void; - onCancel: () => void; - onSelectFactor?: (factorId: string) => void; +export interface ReverificationDialogSelectViewProps extends ReverificationDialogViewBaseProps { + step: 'select-factor'; + stage: ReverificationStage; + availableFactors: ReverificationFactor[]; + formError?: string; + onSelectFactor: (factorId: string) => void; onBack?: () => void; - onPrepare?: () => void; - onShowHelp?: () => void; + onShowHelp: () => void; } -export interface ReverificationDialogViewProps { - open: boolean; - strategy: ReverificationStrategy; - step?: ReverificationDialogState['step']; - availableFactors?: ReverificationFactor[]; - preparationStatus?: ReverificationDialogState['preparationStatus']; - identifier?: string; +export interface ReverificationDialogVerifyViewProps extends ReverificationDialogViewBaseProps { + step: 'verify'; + factor: ReverificationFactor; value: string; - isVerifying?: boolean; + canSubmit: boolean; + isInputDisabled: boolean; + isVerifying: boolean; fieldError?: string; formError?: string; - isResending?: boolean; - resendSecondsRemaining?: number; - onOpenChange: (open: boolean) => void; + resend?: ReverificationDialogResendState; onValueChange: (value: string) => void; - onSubmit: (completedValue?: string) => void; - onResend: () => void; - onSelectFactor?: (factorId: string) => void; - onBack?: () => void; - onPrepare?: () => void; + onSubmit: () => void; + onResend?: () => void; + onShowAlternatives?: () => void; onShowHelp?: () => void; } -export interface ReverificationDialogOperation { - strategy: ReverificationStrategy; - stage: ReverificationStage; - identifier?: string; +export interface ReverificationDialogUnavailableViewProps extends ReverificationDialogViewBaseProps { + step: 'unavailable'; } -export interface ReverificationDialogAttempt extends ReverificationDialogOperation { - value: string; +export interface ReverificationDialogHelpViewProps extends ReverificationDialogViewBaseProps { + step: 'help'; + onBack: () => void; } -export type ReverificationDialogSubmissionResult = - | { status: 'complete' } - | { status: 'needs_second_factor'; factors: ReverificationFactor[] }; - -export interface ReverificationDialogMachineDependencies { - initialState: ReverificationDialogState; - prepare: (operation: ReverificationDialogOperation) => Promise; - submit: (attempt: ReverificationDialogAttempt) => Promise; - resend: (operation: ReverificationDialogOperation) => Promise; - onCancel?: () => void; - mapError?: (error: unknown) => ReverificationDialogErrors; -} +export type ReverificationDialogViewProps = + | ReverificationDialogSelectViewProps + | ReverificationDialogVerifyViewProps + | ReverificationDialogUnavailableViewProps + | ReverificationDialogHelpViewProps; diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx index ec8eb9604e7..8d8cb2419c1 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx @@ -1,344 +1,194 @@ -import * as stylex from '@stylexjs/stylex'; import React from 'react'; import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; import { Dialog } from '../../components/dialog'; import { Field } from '../../components/field'; +import { Heading } from '../../components/heading'; import { Input } from '../../components/input'; -import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; +import { Text } from '../../components/text'; import { reverificationDialogMessages as m } from './reverification-dialog.messages'; -import type { ReverificationDialogResendState, ReverificationDialogViewProps } from './reverification-dialog.types'; - -const styles = stylex.create({ - header: { - gap: space['1'], - display: 'flex', - flexDirection: 'column', - }, - body: { - margin: '-0.25rem', - padding: '0.25rem', - gap: space['4'], - display: 'flex', - flexDirection: 'column', - flexGrow: 1, - minHeight: 0, - overflowY: 'auto', - }, - footer: { - gap: space['2'], - alignItems: 'center', - display: 'flex', - justifyContent: 'flex-end', - }, - footerSpread: { - justifyContent: 'space-between', - }, - footerButton: { - flexGrow: 1, - }, - form: { - gap: space['5'], - display: 'flex', - flexDirection: 'column', - minHeight: 0, - }, - alert: { - borderColor: colorVars['--cl-color-negative'], - borderRadius: radiusVars['--cl-radius-md'], - borderStyle: 'solid', - borderWidth: '1px', - paddingBlock: space['2'], - paddingInline: space['3'], - backgroundColor: colorVars['--cl-color-negative-faded'], - color: colorVars['--cl-color-negative'], - fontSize: typeScaleVars['--cl-text-sm-size'], - }, - codeInput: { - borderColor: colorVars['--cl-color-border'], - borderRadius: radiusVars['--cl-radius-md'], - borderStyle: 'solid', - borderWidth: '1px', - paddingBlock: space['3'], - paddingInline: space['4'], - backgroundColor: colorVars['--cl-color-input'], - fontFamily: 'monospace', - fontSize: typeScaleVars['--cl-text-xl-size'], - letterSpacing: '0.5em', - textAlign: 'center', - width: '100%', - }, - codeInputInvalid: { - borderColor: colorVars['--cl-color-negative'], - }, - muted: { - color: colorVars['--cl-color-neutral-faded'], - fontSize: typeScaleVars['--cl-text-sm-size'], - }, - identifier: { - color: colorVars['--cl-color-neutral'], - fontWeight: 600, - overflowWrap: 'anywhere', - }, - resendRow: { - gap: space['2'], - alignItems: 'center', - display: 'flex', - justifyContent: 'space-between', - }, -}); +import type { + ReverificationDialogResendState, + ReverificationDialogSelectViewProps, + ReverificationDialogVerifyViewProps, + ReverificationDialogViewProps, + ReverificationFactor, +} from './reverification-dialog.types'; function DialogHeader({ title, description }: { title: React.ReactNode; description?: React.ReactNode }) { return ( -
- {title} - {description ? {description} : null} -
- ); -} - -function DialogBody({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -function DialogFooter({ children, spread = false }: { children: React.ReactNode; spread?: boolean }) { - return
{children}
; -} - -function DialogForm({ children, onSubmit }: { children: React.ReactNode; onSubmit: () => void }) { - return ( -
{ - event.preventDefault(); - onSubmit(); - }} - > - {children} -
+ + }>{title} + {description ? }>{description} : null} + ); } -function FormAlert({ children }: { children?: React.ReactNode }) { +function FormError({ children }: { children?: React.ReactNode }) { return children ? ( -

{children} -

+ ) : null; } -function MutedText({ children }: { children: React.ReactNode }) { - return

{children}

; -} - -function Identifier({ children }: { children?: React.ReactNode }) { - return {children}; +function CancelButton({ label = m.cancel }: { label?: string }) { + return ( + ( + ); } function CodeInput({ id, - status, + disabled, value, onChange, - onComplete, }: { id: string; - status: 'idle' | 'verifying' | 'error'; + disabled: boolean; value: string; onChange: (value: string) => void; - onComplete: (value: string) => void; }) { - const completedValueRef = React.useRef(undefined); - - React.useEffect(() => { - if (value.length === 6 && completedValueRef.current !== value) { - completedValueRef.current = value; - onComplete(value); - } - - if (value.length < 6) { - completedValueRef.current = undefined; - } - }, [onComplete, value]); - return ( onChange(event.target.value.replace(/\D/g, '').slice(0, 6))} - {...stylex.props(styles.codeInput, status === 'error' && styles.codeInputInvalid)} + onChange={event => onChange(event.target.value)} /> ); } -type ReverificationDialogContentProps = Omit & { - onCancel: () => void; -}; - -function ReverificationDialogContent({ - strategy, - step = 'verify', +function SelectFactorContent({ + stage, availableFactors, - preparationStatus, - identifier, - value, - isVerifying = false, - fieldError, formError, - isResending = false, - resendSecondsRemaining = 0, - onValueChange, - onSubmit, - onResend, - onCancel, onSelectFactor, onBack, - onPrepare, onShowHelp, -}: ReverificationDialogContentProps) { - const fieldId = React.useId(); - - if (step === 'select-first-factor' || step === 'select-second-factor') { - return ( - <> - - - - {formError} - {availableFactors?.map(factor => ( - - ))} - - +}: ReverificationDialogSelectViewProps) { + return ( + <> + + + + {formError} + {availableFactors.map(factor => ( - {onShowHelp ? ( - - ) : null} - - - ); - } - - if (step === 'prepare') { - const failed = preparationStatus === 'error'; - return ( - <> - - - - {failed ? {formError ?? m.prepareError} : null} - {!failed ? {m.preparing} : null} - - + ))} + + + {onBack ? ( - {failed ? : null} - - - ); - } - - if (step === 'unavailable' || step === 'help') { - return ( - <> - - - - {formError} - - - - - - ); - } + ) : ( + + )} + + + + ); +} - const isDeliveredCode = strategy === 'email_code' || strategy === 'phone_code'; - const isCode = isDeliveredCode || strategy === 'totp'; - const isPasskey = strategy === 'passkey'; - const isPassword = strategy === 'password'; - const canSubmit = isPasskey || value.length > 0; +const safeIdentifierFrom = (factor: ReverificationFactor) => + 'safeIdentifier' in factor ? factor.safeIdentifier : undefined; +function VerifyContent({ + factor, + value, + canSubmit, + isInputDisabled, + isVerifying, + fieldError, + formError, + resend, + onValueChange, + onResend, + onShowAlternatives, + onShowHelp, +}: ReverificationDialogVerifyViewProps) { + const fieldId = React.useId(); + const isDeliveredCode = factor.strategy === 'email_code' || factor.strategy === 'phone_code'; + const isCode = isDeliveredCode || factor.strategy === 'totp'; + const isPasskey = factor.strategy === 'passkey'; + const isPassword = factor.strategy === 'password'; const description = (() => { if (isDeliveredCode) { return ( <> - {m.deliveredCode} {identifier} + {m.deliveredCode} {safeIdentifierFrom(factor)} ); } - if (strategy === 'totp') { + if (factor.strategy === 'totp') { return m.totp; } - if (strategy === 'backup_code') { + if (factor.strategy === 'backup_code') { return m.backupCode; } if (isPasskey) { @@ -346,100 +196,172 @@ function ReverificationDialogContent({ } return m.password; })(); - - const fieldLabel = isPassword ? m.passwordLabel : strategy === 'backup_code' ? m.backupCodeLabel : m.verificationCode; + const fieldLabel = isPassword + ? m.passwordLabel + : factor.strategy === 'backup_code' + ? m.backupCodeLabel + : m.verificationCode; return ( <> - + - - - {formError} - {!isPasskey ? ( - - {fieldLabel} - {isCode ? ( - - ) : ( - onValueChange(event.target.value)} - /> - )} - {fieldError ? {fieldError} : null} - - ) : null} - {isDeliveredCode ? ( -
- {m.didNotReceiveCode} - + {formError} + {!isPasskey ? ( + + {fieldLabel} + {isCode ? ( + -
- ) : null} -
- - {onBack ? ( - - ) : null} + resend={resend} + onResend={onResend} + /> + + ) : null} + + + {onShowAlternatives ? ( - - {isPasskey ? m.withPasskey : m.continue} - - -
+ {m.havingTrouble} + + ) : null} + ( + + + + ); + case 'unavailable': + return ( + <> + + + + + + + ); + } +} + +export function ReverificationDialogView(props: ReverificationDialogViewProps) { + const handleSubmit = props.step === 'verify' ? props.onSubmit : undefined; return ( - props.onOpenChange(nextOpen)} > - onOpenChange(false)} - /> - + + + + { + event.preventDefault(); + handleSubmit(); + }} + /> + ) : undefined + } + renderBranding={false} + /> + } + > + + + + + ); } diff --git a/packages/ui/src/mosaic/components/card/card.styles.ts b/packages/ui/src/mosaic/components/card/card.styles.ts index 7bf03fa4126..e278a2c39ee 100644 --- a/packages/ui/src/mosaic/components/card/card.styles.ts +++ b/packages/ui/src/mosaic/components/card/card.styles.ts @@ -17,8 +17,11 @@ export const styles = stylex.create({ paddingBlockStart: space['5'], }, content: { + gap: space['4'], paddingInline: space['4'], + display: 'flex', flexBasis: 'auto', + flexDirection: 'column', flexGrow: '1', flexShrink: '1', paddingBlockEnd: space['5'], From 395add6d3ae1bbf0f3c46ed4d0ecbd9106b869e7 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 26 Aug 2026 09:46:26 -0600 Subject: [PATCH 03/12] refactor(ui): split reverification block into renderer and view --- .../src/stories/reverification-dialog.mdx | 113 +++- .../stories/reverification-dialog.stories.tsx | 30 +- .../reverification-dialog.view.test.tsx | 182 ------ .../blocks/reverification-dialog/index.ts | 20 + .../reverification-dialog.machine.test.ts | 70 +-- .../reverification-dialog.machine.ts | 115 +--- .../reverification-dialog.messages.ts | 97 ++++ .../reverification-dialog.messages.tsx | 27 - .../reverification-dialog.test.tsx | 204 +++++++ .../reverification-dialog.tsx | 381 +++++++++++++ .../reverification-dialog.types.ts | 61 -- .../reverification-dialog.view.test.tsx | 231 ++++++++ .../reverification-dialog.view.tsx | 533 +++++++----------- 13 files changed, 1263 insertions(+), 801 deletions(-) delete mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.view.test.tsx create mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/index.ts rename packages/ui/src/mosaic/blocks/reverification-dialog/{__tests__ => }/reverification-dialog.machine.test.ts (85%) create mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts delete mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.tsx create mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx create mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx create mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx diff --git a/packages/swingset/src/stories/reverification-dialog.mdx b/packages/swingset/src/stories/reverification-dialog.mdx index 0431199953a..f1e6cc1b2b4 100644 --- a/packages/swingset/src/stories/reverification-dialog.mdx +++ b/packages/swingset/src/stories/reverification-dialog.mdx @@ -1,20 +1,16 @@ -import * as ReverificationDialogStories from './reverification-dialog.stories'; +import * as Stories from './reverification-dialog.stories'; # ReverificationDialog -A triggerless identity-verification block with a prop-driven view and an external workflow machine. -The machine accepts a normalized first- or second-factor challenge; a future controller can translate -Clerk resources and operations into that interface. +The dialog that asks a user to prove who they are before a sensitive action. It renders one of three steps — pick a method, satisfy it, or a dead end — and owns none of the flow between them. ## Example -The launch buttons are showcase controls, not part of the block. Each scenario creates a fresh actor. -Once open, factor selection, preparation, input, automatic code submission, errors, resend cooldown, -completion, and cancellation are all machine-driven. +The launch buttons are showcase controls, not part of the block. Each one mounts `ReverificationDialogView`, which drives the block from a state machine, so method selection, code delivery, automatic submission, errors, the resend cooldown, completion, and cancellation all run for real against stubbed operations. ; +import { ReverificationDialog } from '@clerk/ui/mosaic/blocks/reverification-dialog'; + + 0} + isPending={isPending} + onSubmit={submit} + cancelLabel='Cancel' +/>; +``` + +The action sits in the footer, outside the field's form, so pressing Enter in the field submits the same way the button does. + +## Props + +Shared by every step: + +| Prop | Type | Description | +| -------------- | ----------------------------------- | -------------------------------------------------------------------------------------- | +| `step` | `'choose' \| 'verify' \| 'message'` | Which step is showing. Picks the rest of the props. | +| `open` | `boolean` | Whether the dialog is showing. Controlled, the way any dialog is. | +| `onOpenChange` | `(open: boolean) => void` | Asks to open or close. Fired by Cancel, Escape, and the backdrop. | +| `title` | `string` | Names what is being asked. | +| `description` | `string` | Spells out what the user has to do. | +| `closeLabel` | `string` | Accessible name for the corner close button. | +| `error` | `string` | Optional. A failure that belongs to the step rather than to a field. Read as an alert. | + +`step='choose'` — pick a method: + +| Prop | Type | Description | +| ---------------- | ---------------------- | -------------------------------------------------------------------- | +| `methods` | `{ id, label }[]` | The methods to offer. `id` is opaque and handed straight back. | +| `onSelectMethod` | `(id: string) => void` | Asks the caller to switch to that method. | +| `back` | `{ label, onClick }` | Optional. Returns to the method the user came from. Replaces Cancel. | +| `cancelLabel` | `string` | Label for Cancel, shown when there is nothing to go back to. | +| `help` | `{ label, onClick }` | The way out for a user who has none of these methods. | + +`step='verify'` — satisfy one method: + +| Prop | Type | Description | +| -------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `identifier` | `string` | Optional. Where the code went, e.g. a redacted phone number. | +| `field` | `{ label, kind, value, disabled, error?, onChange }` | Optional. Omit for a method with nothing to type, such as a passkey. `kind` is `'code' \| 'password' \| 'text'`. | +| `resend` | `{ label, disabled, onResend }` | Optional. The caller composes the label, countdown included. | +| `submitLabel` | `string` | The primary button's label. | +| `pendingLabel` | `string` | Accessible name for the pending indicator on that button. | +| `canSubmit` | `boolean` | Holds the action inert until the caller says the answer is submittable. | +| `isPending` | `boolean` | Renders the action pending and blocks a second submit. | +| `onSubmit` | `() => void` | Asks the caller to check the answer. Reached by the button or by Enter. | +| `cancelLabel` | `string` | The dismiss button's label. | +| `secondary` | `{ label, onClick }` | Optional. The one escape this step offers — another method, or help. | + +`step='message'` — a dead end: + +| Prop | Type | Description | +| -------- | -------------------- | ----------------------------------- | +| `action` | `{ label, onClick }` | The single way out: back, or close. | + +## Driving it from a machine + +`ReverificationDialogView` wires the block to `reverificationDialogMachine`, which holds every rule about what happens next: which method starts, when a code is sent, when six digits submit on their own, how long resend stays inert, and where a first-factor success leads. The Clerk work arrives as `prepare` and `attempt`, so the whole flow runs against plain promises in a test or a story. + +```tsx +import { ReverificationDialogView } from '@clerk/ui/mosaic/blocks/reverification-dialog'; + + session.prepareFirstFactorVerification(factor)} + attempt={attempt => session.attemptFirstFactorVerification(attempt)} + onComplete={afterVerification} + onCancel={closeModal} +/>; ``` diff --git a/packages/swingset/src/stories/reverification-dialog.stories.tsx b/packages/swingset/src/stories/reverification-dialog.stories.tsx index 30fd57cb46d..0a68bc71b40 100644 --- a/packages/swingset/src/stories/reverification-dialog.stories.tsx +++ b/packages/swingset/src/stories/reverification-dialog.stories.tsx @@ -1,7 +1,3 @@ -import { - getReverificationDialogViewProps, - reverificationDialogMachine, -} from '@clerk/ui/mosaic/blocks/reverification-dialog/reverification-dialog.machine'; import type { ReverificationAttempt, ReverificationAttemptResult, @@ -14,10 +10,9 @@ import type { ReverificationPreparationFactor, ReverificationSecondFactor, ReverificationSecondFactorPhoneCodeFactor, -} from '@clerk/ui/mosaic/blocks/reverification-dialog/reverification-dialog.types'; -import { ReverificationDialogView } from '@clerk/ui/mosaic/blocks/reverification-dialog/reverification-dialog.view'; +} from '@clerk/ui/mosaic/blocks/reverification-dialog'; +import { ReverificationDialogView } from '@clerk/ui/mosaic/blocks/reverification-dialog'; import { Button } from '@clerk/ui/mosaic/components/button'; -import { useMachine } from '@clerk/ui/mosaic/machine/useMachine'; import React from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -131,18 +126,19 @@ function MachineDrivenDialog({ scenario, onFinished }: { scenario: Scenario; onF }, [scenario.continuesToSecondFactor], ); + // The view finishes in a final state, so the story unmounts it to make the demo repeatable. + // Deferred a tick because the machine reports completion from inside its own transition. const finish = React.useCallback(() => window.setTimeout(onFinished, 0), [onFinished]); - const [snapshot, send] = useMachine(reverificationDialogMachine, { - context: { - initialChallenge: scenario.challenge, - prepare, - attempt, - complete: finish, - cancel: finish, - }, - }); - return ; + return ( + + ); } export function Default() { diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.view.test.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.view.test.tsx deleted file mode 100644 index 11c35899d6b..00000000000 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.view.test.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import type { - ReverificationDialogVerifyViewProps, - ReverificationDialogViewProps, - ReverificationEmailCodeFactor, - ReverificationPasskeyFactor, - ReverificationPasswordFactor, -} from '../reverification-dialog.types'; -import { ReverificationDialogView } from '../reverification-dialog.view'; - -const passwordFactor: ReverificationPasswordFactor = { - id: 'password', - label: 'Password', - stage: 'first', - strategy: 'password', -}; - -const emailFactor: ReverificationEmailCodeFactor = { - id: 'email_1', - label: 'Email code to a••••@clerk.dev', - stage: 'first', - strategy: 'email_code', - emailAddressId: 'email_1', - safeIdentifier: 'a••••@clerk.dev', -}; - -const passkeyFactor: ReverificationPasskeyFactor = { - id: 'passkey', - label: 'Passkey', - stage: 'first', - strategy: 'passkey', -}; - -const verifyProps = ( - overrides: Partial = {}, -): ReverificationDialogVerifyViewProps => ({ - open: true, - step: 'verify', - factor: passwordFactor, - value: '', - canSubmit: false, - isInputDisabled: false, - isVerifying: false, - onOpenChange: vi.fn(), - onValueChange: vi.fn(), - onSubmit: vi.fn(), - ...overrides, -}); - -afterEach(() => cleanup()); - -describe('ReverificationDialogView', () => { - it('is controlled by open and onOpenChange', async () => { - const onOpenChange = vi.fn(); - const view = render(); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - - view.rerender(); - await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' })); - - expect(onOpenChange).toHaveBeenCalledWith(false); - }); - - it('forwards password input and form submission through flat callbacks', async () => { - const onSubmit = vi.fn(); - const onValueChange = vi.fn(); - const view = render(); - - fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } }); - expect(onValueChange).toHaveBeenCalledWith('secret'); - - view.rerender( - , - ); - await userEvent.setup().click(screen.getByRole('button', { name: 'Continue' })); - - expect(onSubmit).toHaveBeenCalledOnce(); - }); - - it('reports delivered-code input without owning normalization or completion', () => { - const onSubmit = vi.fn(); - const onValueChange = vi.fn(); - render(); - - fireEvent.change(screen.getByLabelText('Verification code'), { target: { value: '12a3456' } }); - - expect(onValueChange).toHaveBeenCalledWith('12a3456'); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('keeps the code-entry modal visible while preparation disables its input', () => { - render( - , - ); - - expect(screen.getByLabelText('Verification code')).toBeDisabled(); - expect(screen.getByText(/Enter the verification code sent to/)).toBeInTheDocument(); - expect(screen.queryByText(/Preparing verification/)).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Resend' })).toHaveAttribute('aria-disabled', 'true'); - }); - - it('renders factor selection as prop-driven actions', async () => { - const onSelectFactor = vi.fn(); - const props: ReverificationDialogViewProps = { - open: true, - step: 'select-factor', - stage: 'first', - availableFactors: [passkeyFactor], - onOpenChange: vi.fn(), - onSelectFactor, - onShowHelp: vi.fn(), - }; - render(); - - await userEvent.setup().click(screen.getByRole('button', { name: 'Passkey' })); - - expect(onSelectFactor).toHaveBeenCalledWith(passkeyFactor.id); - }); - - it('renders only valid navigation actions supplied by the machine', async () => { - const onShowHelp = vi.fn(); - render(); - - expect(screen.queryByRole('button', { name: 'Use another method' })).not.toBeInTheDocument(); - await userEvent.setup().click(screen.getByRole('button', { name: 'Having trouble?' })); - expect(onShowHelp).toHaveBeenCalledOnce(); - }); - - it('exposes verification progress and errors accessibly', () => { - render( - , - ); - - expect(screen.getByRole('alert')).toHaveTextContent('Verification failed.'); - expect(screen.getByText('Incorrect password.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Continue' })).toHaveAttribute('aria-busy', 'true'); - expect(screen.getByRole('progressbar', { name: 'Verifying identity' })).toBeInTheDocument(); - }); - - it('renders passkey verification without an input', () => { - render(); - - expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Verify with passkey' })).toBeEnabled(); - }); - - it('renders a machine-owned resend countdown as inert', async () => { - const onResend = vi.fn(); - render( - , - ); - - const resend = screen.getByRole('button', { name: 'Resend (30s)' }); - expect(resend).toHaveAttribute('aria-disabled', 'true'); - await userEvent.setup().click(resend); - expect(onResend).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts new file mode 100644 index 00000000000..90e2db4fff7 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts @@ -0,0 +1,20 @@ +export { ReverificationDialog } from './reverification-dialog'; +export type { + ReverificationDialogAction, + ReverificationDialogChooseProps, + ReverificationDialogField, + ReverificationDialogMessageProps, + ReverificationDialogMethod, + ReverificationDialogProps, + ReverificationDialogResend, + ReverificationDialogVerifyProps, +} from './reverification-dialog'; +export { reverificationDialogMachine } from './reverification-dialog.machine'; +export type { + ReverificationDialogMachineContext, + ReverificationDialogMachineEvent, + ReverificationDialogMachineSnapshot, +} from './reverification-dialog.machine'; +export type * from './reverification-dialog.types'; +export { ReverificationDialogView } from './reverification-dialog.view'; +export type { ReverificationDialogViewProps } from './reverification-dialog.view'; diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.machine.test.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts similarity index 85% rename from packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.machine.test.ts rename to packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts index 28568bd50ec..1f9722a1a90 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/__tests__/reverification-dialog.machine.test.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createActor } from '../../../machine/createActor'; -import { getReverificationDialogViewProps, reverificationDialogMachine } from '../reverification-dialog.machine'; +import { createActor } from '../../machine/createActor'; +import { reverificationDialogMachine } from './reverification-dialog.machine'; import type { ReverificationAttempt, ReverificationAttemptResult, @@ -12,7 +12,7 @@ import type { ReverificationPreparationFactor, ReverificationSecondFactorPhoneCodeFactor, ReverificationTOTPFactor, -} from '../reverification-dialog.types'; +} from './reverification-dialog.types'; const passwordFactor: ReverificationPasswordFactor = { id: 'password', @@ -93,12 +93,8 @@ describe('reverificationDialogMachine', () => { const { actor } = start({ challenge: firstFactorChallenge() }); expect(actor.getSnapshot().value).toBe('selectingFactor'); - expect(getReverificationDialogViewProps(actor.getSnapshot(), actor.send)).toMatchObject({ - step: 'select-factor', - stage: 'first', - availableFactors: [passwordFactor, emailFactor, phoneFactor], - onBack: undefined, - }); + expect(actor.getSnapshot().context.challenge.factors).toEqual([passwordFactor, emailFactor, phoneFactor]); + expect(actor.can({ type: 'BACK' })).toBe(false); actor.send({ type: 'SELECT_FACTOR', factorId: passwordFactor.id }); expect(actor.getSnapshot()).toMatchObject({ @@ -194,10 +190,9 @@ describe('reverificationDialogMachine', () => { }, }); - expect(getReverificationDialogViewProps(actor.getSnapshot(), actor.send)).toMatchObject({ - step: 'select-factor', - stage: 'second', - availableFactors: [totpFactor, secondPhoneFactor], + expect(actor.getSnapshot()).toMatchObject({ + value: 'selectingFactor', + context: { challenge: { status: 'needs_second_factor', factors: [totpFactor, secondPhoneFactor] } }, }); }); @@ -205,20 +200,15 @@ describe('reverificationDialogMachine', () => { const { actor: passwordActor } = start({ challenge: firstFactorChallenge({ factors: [passwordFactor], initialFactorId: passwordFactor.id }), }); - expect(getReverificationDialogViewProps(passwordActor.getSnapshot(), passwordActor.send)).toMatchObject({ - step: 'verify', - onShowHelp: expect.any(Function), - }); + expect(passwordActor.getSnapshot().value).toBe('verifying'); + expect(passwordActor.can({ type: 'SHOW_HELP' })).toBe(true); const { actor: emailActor } = start({ challenge: firstFactorChallenge({ factors: [emailFactor], initialFactorId: emailFactor.id }), }); await vi.waitFor(() => expect(emailActor.getSnapshot().value).toBe('verifyingCooldown')); - expect(getReverificationDialogViewProps(emailActor.getSnapshot(), emailActor.send)).toMatchObject({ - step: 'verify', - onShowHelp: undefined, - onShowAlternatives: undefined, - }); + expect(emailActor.can({ type: 'SHOW_HELP' })).toBe(false); + expect(emailActor.can({ type: 'SHOW_ALTERNATIVES' })).toBe(false); emailActor.send({ type: 'SHOW_HELP' }); expect(emailActor.getSnapshot().value).toBe('verifyingCooldown'); @@ -233,9 +223,9 @@ describe('reverificationDialogMachine', () => { await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); actor.send({ type: 'SHOW_ALTERNATIVES' }); - expect(getReverificationDialogViewProps(actor.getSnapshot(), actor.send)).toMatchObject({ - step: 'select-factor', - availableFactors: [passwordFactor, phoneFactor], + expect(actor.getSnapshot()).toMatchObject({ + value: 'selectingFactor', + context: { currentFactor: emailFactor }, }); actor.send({ type: 'BACK' }); @@ -263,7 +253,7 @@ describe('reverificationDialogMachine', () => { expect(prepare).toHaveBeenNthCalledWith(3, emailFactor); }); - it('keeps preparation and its retry inside the code-verification view', async () => { + it('stays on the current factor when preparation fails, and retries through resend', async () => { const prepare = vi .fn<(factor: ReverificationPreparationFactor) => Promise>() .mockRejectedValueOnce(new Error('Could not send the code.')) @@ -273,25 +263,21 @@ describe('reverificationDialogMachine', () => { prepare, }); - expect(getReverificationDialogViewProps(actor.getSnapshot(), actor.send)).toMatchObject({ - step: 'verify', - factor: emailFactor, - isInputDisabled: true, - resend: { isResending: true, secondsRemaining: 0 }, - onResend: undefined, - onShowAlternatives: expect.any(Function), + expect(actor.getSnapshot()).toMatchObject({ + value: 'preparing', + context: { currentFactor: emailFactor, resendSecondsRemaining: 0 }, }); + expect(actor.can({ type: 'RESEND' })).toBe(false); + expect(actor.can({ type: 'SHOW_ALTERNATIVES' })).toBe(true); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('preparationFailed')); - expect(getReverificationDialogViewProps(actor.getSnapshot(), actor.send)).toMatchObject({ - step: 'verify', - factor: emailFactor, - isInputDisabled: true, - formError: 'Could not send the code.', - resend: { isResending: false, secondsRemaining: 0 }, - onResend: expect.any(Function), - onShowAlternatives: expect.any(Function), + expect(actor.getSnapshot().context).toMatchObject({ + currentFactor: emailFactor, + error: { location: 'form', message: 'Could not send the code.' }, + resendSecondsRemaining: 0, }); + expect(actor.can({ type: 'RESEND' })).toBe(true); + expect(actor.can({ type: 'SHOW_ALTERNATIVES' })).toBe(true); actor.send({ type: 'RESEND' }); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); @@ -381,6 +367,6 @@ describe('reverificationDialogMachine', () => { expect(cancel).toHaveBeenCalledOnce(); expect(actor.getSnapshot()).toMatchObject({ value: 'cancelled', status: 'done' }); - expect(getReverificationDialogViewProps(actor.getSnapshot(), actor.send).open).toBe(false); + expect(actor.getSnapshot().status).toBe('done'); }); }); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts index 1d0445ca096..e114002210d 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts @@ -1,16 +1,24 @@ import { setup } from '../../machine/setup'; import type { Snapshot } from '../../machine/types'; -import { reverificationDialogMessages as m } from './reverification-dialog.messages'; +import { reverificationDialogBase as m } from './reverification-dialog.messages'; import type { ReverificationAttempt, ReverificationAttemptResult, ReverificationChallenge, - ReverificationDialogError, - ReverificationDialogViewProps, ReverificationFactor, ReverificationPreparationFactor, } from './reverification-dialog.types'; +/** + * A failed attempt, and where the message belongs. `location` is the machine deciding a + * rendering question, which is why it is keyed off the strategy rather than off the error — + * legacy's `handleError` read the error's own shape to choose between the field and the card. + */ +export interface ReverificationDialogError { + location: 'field' | 'form'; + message: string; +} + const RESEND_COOLDOWN_SECONDS = 30; const emptyChallenge: ReverificationChallenge = { @@ -91,7 +99,7 @@ const canSubmit = (context: ReverificationDialogMachineContext) => { const attemptFrom = (context: ReverificationDialogMachineContext): ReverificationAttempt => { const factor = context.currentFactor; if (!factor) { - throw new Error(m.genericError); + throw new Error(m.unstable__errors__generic); } if (factor.strategy === 'password') { return { factor, password: context.value }; @@ -104,7 +112,7 @@ const attemptFrom = (context: ReverificationDialogMachineContext): Reverificatio const errorFrom = (error: unknown, location: ReverificationDialogError['location']): ReverificationDialogError => ({ location, - message: error instanceof Error ? error.message : m.genericError, + message: error instanceof Error ? error.message : m.unstable__errors__generic, }); const attemptErrorLocation = (context: ReverificationDialogMachineContext): ReverificationDialogError['location'] => @@ -204,7 +212,7 @@ export const reverificationDialogMachine = createMachine({ invoke: fromPromise( context => { if (!requiresPreparation(context.currentFactor)) { - return Promise.reject(new Error(m.genericError)); + return Promise.reject(new Error(m.unstable__errors__generic)); } return context.prepare(context.currentFactor); }, @@ -318,7 +326,7 @@ export const reverificationDialogMachine = createMachine({ invoke: fromPromise( context => { if (!requiresPreparation(context.currentFactor)) { - return Promise.reject(new Error(m.genericError)); + return Promise.reject(new Error(m.unstable__errors__generic)); } return context.prepare(context.currentFactor); }, @@ -364,96 +372,3 @@ export const reverificationDialogMachine = createMachine({ }); export type ReverificationDialogMachineSnapshot = Snapshot; - -export function getReverificationDialogViewProps( - snapshot: ReverificationDialogMachineSnapshot, - send: (event: ReverificationDialogMachineEvent) => void, -): ReverificationDialogViewProps { - const context = - snapshot.value === 'initializing' - ? { ...snapshot.context, challenge: snapshot.context.initialChallenge } - : snapshot.context; - const open = snapshot.status === 'active'; - const base = { - open, - onOpenChange: (nextOpen: boolean) => { - if (!nextOpen) { - send({ type: 'CANCEL' }); - } - }, - }; - const formError = context.error?.location === 'form' ? context.error.message : undefined; - - if (snapshot.value === 'unavailable') { - return { ...base, step: 'unavailable' }; - } - - if (snapshot.value === 'help') { - return { - ...base, - step: 'help', - onBack: () => send({ type: 'BACK' }), - }; - } - - if (snapshot.value === 'selectingFactor') { - return { - ...base, - step: 'select-factor', - stage: context.challenge.status === 'needs_first_factor' ? 'first' : 'second', - availableFactors: context.currentFactor ? alternativesFrom(context) : factorsFrom(context), - formError, - onSelectFactor: factorId => send({ type: 'SELECT_FACTOR', factorId }), - onBack: context.currentFactor ? () => send({ type: 'BACK' }) : undefined, - onShowHelp: () => send({ type: 'SHOW_HELP' }), - }; - } - - const factor = context.currentFactor ?? initialFactorFrom(context.challenge); - if (!factor) { - if (factorsFrom(context).length > 0) { - return { - ...base, - step: 'select-factor', - stage: context.challenge.status === 'needs_first_factor' ? 'first' : 'second', - availableFactors: factorsFrom(context), - onSelectFactor: factorId => send({ type: 'SELECT_FACTOR', factorId }), - onShowHelp: () => send({ type: 'SHOW_HELP' }), - }; - } - return { ...base, step: 'unavailable' }; - } - - const isVerifying = snapshot.value === 'submitting'; - const isResending = snapshot.value === 'resending'; - const isInteractive = snapshot.value === 'verifying' || snapshot.value === 'verifyingCooldown'; - const isPreparing = snapshot.value === 'preparing'; - const preparationFailed = snapshot.value === 'preparationFailed'; - const canResend = (snapshot.value === 'verifying' || preparationFailed) && requiresPreparation(factor); - const canShowAlternatives = - (isInteractive || isPreparing || preparationFailed || isResending) && hasAlternatives(context); - const canShowHelp = isInteractive && factor.strategy === 'password' && !hasAlternatives(context); - - return { - ...base, - step: 'verify', - factor, - value: context.value, - canSubmit: isInteractive && canSubmit(context), - isInputDisabled: !isInteractive, - isVerifying, - fieldError: context.error?.location === 'field' ? context.error.message : undefined, - formError, - resend: requiresPreparation(factor) - ? { - isResending: isResending || isPreparing, - secondsRemaining: context.resendSecondsRemaining, - } - : undefined, - onValueChange: value => send({ type: 'CHANGE_VALUE', value }), - onSubmit: () => send({ type: 'SUBMIT' }), - onResend: canResend ? () => send({ type: 'RESEND' }) : undefined, - onShowAlternatives: canShowAlternatives ? () => send({ type: 'SHOW_ALTERNATIVES' }) : undefined, - onShowHelp: canShowHelp ? () => send({ type: 'SHOW_HELP' }) : undefined, - }; -} diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts new file mode 100644 index 00000000000..c5973899825 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts @@ -0,0 +1,97 @@ +/** + * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so + * localizing this component is a matter of registering the namespace and swapping the reads for + * `useMessages('reverification', reverificationDialogBase)`, not of hunting the literals down first. + * + * The `reverification.*` keys mirror the namespace already shipping in `@clerk/localizations`, and + * the flat keys below them mirror the root-level keys the legacy flow shares with the rest of the + * UI. Keeping both sets verbatim is what makes the eventual swap a rename rather than a retranslation. + * + * A plural message is its forms, the way `count()` takes them; a parameterized one is its template, + * the way `params()` takes it. `plural` and `fill` below resolve them until that layer lands. + */ +export const reverificationDialogBase = { + alternativeMethods: { + actionLink: 'Get help', + actionText: 'Don’t have any of these?', + blockButton__backupCode: 'Use a backup code', + blockButton__emailCode: 'Email code to {identifier}', + blockButton__passkey: 'Use your passkey', + blockButton__password: 'Continue with your password', + blockButton__phoneCode: 'Send SMS code to {identifier}', + blockButton__totp: 'Use your authenticator app', + getHelp: { + content: + 'If you have trouble verifying your account, email us and we will work with you to restore access as soon as possible.', + title: 'Get help', + }, + subtitle: 'Facing issues? You can use any of these methods for verification.', + title: 'Use another method', + }, + backupCodeMfa: { + subtitle: 'Enter the backup code you received when setting up two-step authentication', + title: 'Enter a backup code', + }, + emailCode: { + formTitle: 'Verification code', + resendButton: 'Didn’t receive a code? Resend', + subtitle: 'Enter the code sent to your email to continue', + title: 'Verification required', + }, + noAvailableMethods: { + message: 'Cannot proceed with verification. No suitable authentication factor is configured', + subtitle: 'An error occurred', + title: 'Cannot verify your account', + }, + passkey: { + blockButton__passkey: 'Use your passkey', + subtitle: + 'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.', + title: 'Use your passkey', + }, + password: { + actionLink: 'Use another method', + subtitle: 'Enter your current password to continue', + title: 'Verification required', + }, + phoneCode: { + formTitle: 'Verification code', + resendButton: 'Didn’t receive a code? Resend', + subtitle: 'Enter the code sent to your phone to continue', + title: 'Verification required', + }, + phoneCodeMfa: { + formTitle: 'Verification code', + resendButton: 'Didn’t receive a code? Resend', + subtitle: 'Enter the code sent to your phone to continue', + title: 'Verification required', + }, + totpMfa: { + formTitle: 'Verification code', + subtitle: 'Enter the code generated by your authenticator app to continue', + title: 'Verification required', + }, + backButton: 'Back', + closeButton: 'Close', + footerActionLink__useAnotherMethod: 'Use another method', + formButtonPrimary: 'Continue', + formButtonReset: 'Cancel', + formFieldLabel__backupCode: 'Backup code', + formFieldLabel__password: 'Password', + unstable__errors__generic: 'Something went wrong. Please try again.', + /** Announced while an attempt is in flight; the button's own label stays visible. */ + verifying: 'Verifying', +}; + +/** Substitutes `{name}`-style placeholders. Replaced by the localization layer's own formatter. */ +export function fill(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => String(values[key] ?? match)); +} + +/** + * Picks a plural form and fills `{count}`. English has the two forms below; the localization layer + * selects across all six categories with `Intl.PluralRules`. + */ +export function plural(forms: { one: string; other: string }, count: number): string { + return fill(count === 1 ? forms.one : forms.other, { count }); +} diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.tsx deleted file mode 100644 index f3afd6e52bf..00000000000 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.tsx +++ /dev/null @@ -1,27 +0,0 @@ -export const reverificationDialogMessages = { - title: 'Verify it’s you', - chooseFirst: 'Choose how to verify your identity.', - chooseSecond: 'Choose a second verification method to continue.', - havingTrouble: 'Having trouble?', - genericError: 'Something went wrong. Please try again.', - unavailableTitle: 'Unable to verify', - unavailableDescription: 'No verification methods are available for this account.', - helpDescription: 'Contact support if you cannot access any verification method.', - deliveredCode: 'Enter the verification code sent to', - totp: 'Enter the code from your authenticator app.', - backupCode: 'Enter one of your backup codes.', - passkey: 'Use your passkey to verify your identity.', - password: 'Enter your password to continue.', - passwordLabel: 'Password', - backupCodeLabel: 'Backup code', - verificationCode: 'Verification code', - anotherMethod: 'Use another method', - pending: 'Verifying identity', - withPasskey: 'Verify with passkey', - didNotReceiveCode: "Didn't receive a code?", - resend: 'Resend', - back: 'Back', - cancel: 'Cancel', - close: 'Close', - continue: 'Continue', -}; diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx new file mode 100644 index 00000000000..22e6759913d --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx @@ -0,0 +1,204 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { + ReverificationDialogChooseProps, + ReverificationDialogMessageProps, + ReverificationDialogVerifyProps, +} from './reverification-dialog'; +import { ReverificationDialog } from './reverification-dialog'; + +const base = { + open: true as const, + onOpenChange: vi.fn(), + closeLabel: 'Close', +}; + +function renderBlock(props: Parameters[0]) { + return render( + + + , + ); +} + +const chooseProps = (overrides: Partial = {}): ReverificationDialogChooseProps => ({ + ...base, + step: 'choose', + title: 'Use another method', + description: 'Facing issues? You can use any of these methods for verification.', + methods: [ + { id: 'password', label: 'Continue with your password' }, + { id: 'email_1', label: 'Email code to a••••@clerk.dev' }, + ], + onSelectMethod: vi.fn(), + cancelLabel: 'Cancel', + help: { label: 'Get help', onClick: vi.fn() }, + ...overrides, +}); + +const verifyProps = (overrides: Partial = {}): ReverificationDialogVerifyProps => ({ + ...base, + step: 'verify', + title: 'Verification required', + description: 'Enter your current password to continue', + field: { label: 'Password', kind: 'password', value: '', disabled: false, onChange: vi.fn() }, + submitLabel: 'Continue', + pendingLabel: 'Verifying', + canSubmit: false, + isPending: false, + onSubmit: vi.fn(), + cancelLabel: 'Cancel', + ...overrides, +}); + +const messageProps = (overrides: Partial = {}): ReverificationDialogMessageProps => ({ + ...base, + step: 'message', + title: 'Get help', + description: 'Email us and we will work with you to restore access.', + action: { label: 'Back', onClick: vi.fn() }, + ...overrides, +}); + +describe('ReverificationDialog', () => { + it('renders nothing until the caller opens it', () => { + renderBlock(verifyProps({ open: false })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('hands back the id of the chosen method', async () => { + const onSelectMethod = vi.fn(); + renderBlock(chooseProps({ onSelectMethod })); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Email code to a••••@clerk.dev' })); + + expect(onSelectMethod).toHaveBeenCalledWith('email_1'); + }); + + it('offers back in place of cancel only when the caller supplies it', () => { + const { rerender } = renderBlock(chooseProps()); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); + }); + + it('submits the field with Enter, since the action sits outside the form', async () => { + const onSubmit = vi.fn(); + renderBlock(verifyProps({ canSubmit: true, onSubmit })); + + await userEvent.setup().type(screen.getByLabelText('Password'), '{Enter}'); + + expect(onSubmit).toHaveBeenCalledOnce(); + }); + + it('holds the action while the caller says it cannot submit', async () => { + const onSubmit = vi.fn(); + renderBlock(verifyProps({ canSubmit: false, onSubmit })); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Continue' })); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('renders a code as per-character slots that take digits only', async () => { + const onChange = vi.fn(); + renderBlock( + verifyProps({ + field: { label: 'Verification code', kind: 'code', value: '', disabled: false, onChange }, + }), + ); + const user = userEvent.setup(); + + const slots = within(screen.getByRole('group', { name: 'Verification code' })).getAllByRole('textbox'); + expect(slots).toHaveLength(6); + + await user.type(slots[0], '1'); + expect(onChange).toHaveBeenCalledWith('1'); + + onChange.mockClear(); + await user.type(slots[0], 'a'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('points the code label at the first slot, so clicking it starts the caret there', async () => { + renderBlock( + verifyProps({ + field: { label: 'Verification code', kind: 'code', value: '', disabled: false, onChange: vi.fn() }, + }), + ); + + await userEvent.setup().click(screen.getByText('Verification code')); + + expect(screen.getByRole('textbox', { name: 'Character 1 of 6' })).toHaveFocus(); + }); + + it('renders a method with nothing to type as a bare action', () => { + renderBlock(verifyProps({ field: undefined, submitLabel: 'Use your passkey', canSubmit: true })); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Use your passkey' })).toBeEnabled(); + }); + + it('announces the pending action and blocks a second submit', async () => { + const onSubmit = vi.fn(); + renderBlock(verifyProps({ canSubmit: true, isPending: true, onSubmit })); + + const submit = screen.getByRole('button', { name: 'Continue' }); + expect(submit).toHaveAttribute('aria-busy', 'true'); + + await userEvent.setup().click(submit); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('shows a field error against the field and a step error as an alert', () => { + renderBlock( + verifyProps({ + error: 'Too many attempts. Try again later.', + field: { + label: 'Password', + kind: 'password', + value: 'wrong', + disabled: false, + error: 'Incorrect password.', + onChange: vi.fn(), + }, + }), + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Too many attempts. Try again later.'); + expect(screen.getByText('Incorrect password.')).toBeInTheDocument(); + }); + + it('renders the resend label the caller composed, inert while it says so', async () => { + const onResend = vi.fn(); + renderBlock(verifyProps({ resend: { label: 'Didn’t receive a code? Resend (29)', disabled: true, onResend } })); + + const resend = screen.getByRole('button', { name: 'Didn’t receive a code? Resend (29)' }); + expect(resend).toHaveAttribute('aria-disabled', 'true'); + + await userEvent.setup().click(resend); + expect(onResend).not.toHaveBeenCalled(); + }); + + it('gives a dead end exactly one way out', async () => { + const onClick = vi.fn(); + renderBlock(messageProps({ action: { label: 'Back', onClick } })); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Back' })); + + expect(onClick).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx new file mode 100644 index 00000000000..65f947fb5c7 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx @@ -0,0 +1,381 @@ +import { Otp } from '@clerk/headless/otp'; +import type { FormEvent } from 'react'; +import { useId } from 'react'; + +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Heading } from '../../components/heading'; +import { Input } from '../../components/input'; +import { Text } from '../../components/text'; + +/** One selectable verification method. `id` is opaque to the block and handed straight back. */ +export interface ReverificationDialogMethod { + id: string; + label: string; +} + +/** A labelled callback the caller decides to offer — rendered only when supplied. */ +export interface ReverificationDialogAction { + label: string; + onClick: () => void; +} + +export interface ReverificationDialogField { + label: string; + /** `code` renders per-character slots; `password` masks the value; `text` is a plain field. */ + kind: 'code' | 'password' | 'text'; + value: string; + disabled: boolean; + /** Why this field's value was rejected. Renders under the field and marks it invalid. */ + error?: string; + onChange: (value: string) => void; +} + +export interface ReverificationDialogResend { + label: string; + disabled: boolean; + onResend: () => void; +} + +interface ReverificationDialogBaseProps { + /** Whether the dialog is open */ + open: boolean; + /** Callback when open state changes */ + onOpenChange: (open: boolean) => void; + /** Dialog heading */ + title: string; + /** What is being asked of the user */ + description: string; + /** Accessible name for the corner close button */ + closeLabel: string; + /** A failure that belongs to the step rather than to a field. Announced as an alert. */ + error?: string; +} + +/** Pick a verification method from a list. */ +export interface ReverificationDialogChooseProps extends ReverificationDialogBaseProps { + step: 'choose'; + methods: ReverificationDialogMethod[]; + onSelectMethod: (id: string) => void; + /** Returns to the method the user came from. Absent when there is nothing to go back to. */ + back?: ReverificationDialogAction; + cancelLabel: string; + help: ReverificationDialogAction; +} + +/** Satisfy one method: type a code or password, or present a passkey. */ +export interface ReverificationDialogVerifyProps extends ReverificationDialogBaseProps { + step: 'verify'; + /** The identity the code went to, e.g. a redacted phone number. */ + identifier?: string; + /** Absent for a method with nothing to type, such as a passkey. */ + field?: ReverificationDialogField; + resend?: ReverificationDialogResend; + submitLabel: string; + /** Accessible name for the pending indicator on the submit button */ + pendingLabel: string; + canSubmit: boolean; + isPending: boolean; + onSubmit: () => void; + cancelLabel: string; + /** The one escape this step offers — another method, or help. */ + secondary?: ReverificationDialogAction; +} + +/** A dead end with one way out: help, or no methods to offer. */ +export interface ReverificationDialogMessageProps extends ReverificationDialogBaseProps { + step: 'message'; + action: ReverificationDialogAction; +} + +export type ReverificationDialogProps = + | ReverificationDialogChooseProps + | ReverificationDialogVerifyProps + | ReverificationDialogMessageProps; + +/** Every code this dialog asks for is six characters, the length the flow's machine normalizes to. */ +const CODE_LENGTH = 6; + +/** + * The code slots, straight off the headless primitive and unstyled for now — Mosaic has no + * styled OTP component yet. Typing advances, `Backspace` walks back, and a pasted code spreads + * across the slots. + */ +function CodeSlots({ baseId, invalid }: { baseId: string; invalid: boolean }) { + const { slots } = Otp.useOtp(); + + return slots.map(slot => ( + + )); +} + +/** + * The dialog that asks a user to prove who they are before a sensitive action. Renders one of + * three steps — pick a method, satisfy it, or a dead end — and owns none of the flow between + * them. + * + * Controlled and stateless: every label, every enabled/disabled decision, and `open` itself + * belong to the caller. The block holds nothing, so a step renders identically whether it was + * reached from a machine or from a story. + * + * @example + * !open && send({ type: 'CANCEL' })} + * title='Verification required' + * description='Enter the code sent to your email to continue' + * closeLabel='Close' + * field={{ label: 'Verification code', kind: 'code', value, disabled: false, onChange }} + * submitLabel='Continue' + * pendingLabel='Verifying' + * canSubmit={canSubmit} + * isPending={snapshot.value === 'submitting'} + * onSubmit={() => send({ type: 'SUBMIT' })} + * cancelLabel='Cancel' + * /> + */ +export function ReverificationDialog(props: ReverificationDialogProps) { + const { open, onOpenChange, title, description, closeLabel, error } = props; + + return ( + + + + + + } + > + + + }>{title} + }>{description} + + {error ? ( + + + {error} + + + ) : null} + + + + + + ); +} + +function StepContent(props: ReverificationDialogProps) { + switch (props.step) { + case 'choose': + return ; + case 'verify': + return ; + case 'message': + return ; + } +} + +function ChooseStep({ methods, onSelectMethod, back, cancelLabel, help }: ReverificationDialogChooseProps) { + return ( + <> + + {methods.map(method => ( + + ))} + + + {back ? ( + + ) : ( + + } + > + {cancelLabel} + + )} + + + + ); +} + +function VerifyStep({ + identifier, + field, + resend, + submitLabel, + pendingLabel, + canSubmit, + isPending, + onSubmit, + cancelLabel, + secondary, +}: ReverificationDialogVerifyProps) { + const formId = useId(); + const fieldId = useId(); + + // The action sits in the footer, outside the form, so `form={formId}` associates the two. + // That is what makes Enter in the field submit. Both guards are re-checked here because + // neither spelling stops a native submit: `focusableWhenDisabled` only marks the button + // `aria-disabled`, and `isPending` only cancels the press. + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (canSubmit && !isPending) { + onSubmit(); + } + }; + + return ( + <> + + {identifier ? {identifier} : null} +
+ {field ? ( + + {/* A ` + ) : null} +
+ {resend ? ( + + ) : null} +
+ + {secondary ? ( + + ) : null} + + } + > + {cancelLabel} + + + {submitLabel} + + + + ); +} + +function MessageStep({ action }: ReverificationDialogMessageProps) { + return ( + + + + ); +} diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts index 31b8f070e54..771c24a00aa 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts @@ -1,5 +1,3 @@ -export type ReverificationStage = 'first' | 'second'; - interface ReverificationFactorBase { id: string; label: string; @@ -59,8 +57,6 @@ export type ReverificationSecondFactor = export type ReverificationFactor = ReverificationFirstFactor | ReverificationSecondFactor; -export type ReverificationStrategy = ReverificationFactor['strategy']; - export type ReverificationChallenge = | { status: 'needs_first_factor'; @@ -90,60 +86,3 @@ export type ReverificationAttemptResult = factors: ReverificationSecondFactor[]; initialFactorId?: string; }; - -export interface ReverificationDialogError { - location: 'field' | 'form'; - message: string; -} - -export interface ReverificationDialogResendState { - isResending: boolean; - secondsRemaining: number; -} - -interface ReverificationDialogViewBaseProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export interface ReverificationDialogSelectViewProps extends ReverificationDialogViewBaseProps { - step: 'select-factor'; - stage: ReverificationStage; - availableFactors: ReverificationFactor[]; - formError?: string; - onSelectFactor: (factorId: string) => void; - onBack?: () => void; - onShowHelp: () => void; -} - -export interface ReverificationDialogVerifyViewProps extends ReverificationDialogViewBaseProps { - step: 'verify'; - factor: ReverificationFactor; - value: string; - canSubmit: boolean; - isInputDisabled: boolean; - isVerifying: boolean; - fieldError?: string; - formError?: string; - resend?: ReverificationDialogResendState; - onValueChange: (value: string) => void; - onSubmit: () => void; - onResend?: () => void; - onShowAlternatives?: () => void; - onShowHelp?: () => void; -} - -export interface ReverificationDialogUnavailableViewProps extends ReverificationDialogViewBaseProps { - step: 'unavailable'; -} - -export interface ReverificationDialogHelpViewProps extends ReverificationDialogViewBaseProps { - step: 'help'; - onBack: () => void; -} - -export type ReverificationDialogViewProps = - | ReverificationDialogSelectViewProps - | ReverificationDialogVerifyViewProps - | ReverificationDialogUnavailableViewProps - | ReverificationDialogHelpViewProps; diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx new file mode 100644 index 00000000000..73e614e0d6f --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx @@ -0,0 +1,231 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { + ReverificationAttempt, + ReverificationAttemptResult, + ReverificationChallenge, + ReverificationEmailCodeFactor, + ReverificationPasskeyFactor, + ReverificationPasswordFactor, + ReverificationPreparationFactor, +} from './reverification-dialog.types'; +import { ReverificationDialogView } from './reverification-dialog.view'; + +const passwordFactor: ReverificationPasswordFactor = { + id: 'password', + label: 'Password', + stage: 'first', + strategy: 'password', +}; + +const emailFactor: ReverificationEmailCodeFactor = { + id: 'email_1', + label: 'Email code', + stage: 'first', + strategy: 'email_code', + emailAddressId: 'email_1', + safeIdentifier: 'a••••@clerk.dev', +}; + +const passkeyFactor: ReverificationPasskeyFactor = { + id: 'passkey', + label: 'Passkey', + stage: 'first', + strategy: 'passkey', +}; + +function renderView({ + challenge = { + status: 'needs_first_factor', + factors: [passwordFactor, emailFactor], + initialFactorId: passwordFactor.id, + } as ReverificationChallenge, + prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined), + attempt = vi + .fn<(attempt: ReverificationAttempt) => Promise>() + .mockResolvedValue({ status: 'complete' }), + onComplete = vi.fn(), + onCancel = vi.fn(), +} = {}) { + render( + + + , + ); + return { prepare, attempt, onComplete, onCancel }; +} + +/** The code field is a group of single-character slots, not one input. */ +const codeSlots = () => within(screen.getByRole('group', { name: 'Verification code' })).getAllByRole('textbox'); + +describe('ReverificationDialogView', () => { + it('opens on the starting method and carries its answer to the attempt', async () => { + const { attempt, onComplete } = renderView(); + const user = userEvent.setup(); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await user.type(screen.getByLabelText('Password'), 'secret'); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => expect(attempt).toHaveBeenCalledWith({ factor: passwordFactor, password: 'secret' })); + expect(onComplete).toHaveBeenCalledOnce(); + }); + + it('sends the code behind the code step and submits six digits without a press', async () => { + const { prepare, attempt } = renderView({ + challenge: { + status: 'needs_first_factor', + factors: [passwordFactor, emailFactor], + initialFactorId: emailFactor.id, + }, + }); + + await waitFor(() => expect(prepare).toHaveBeenCalledWith(emailFactor)); + expect(await screen.findByText('Enter the code sent to your email to continue')).toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(codeSlots()[0]); + // Typed as keystrokes rather than into one slot: the primitive walks focus along as it fills. + await user.keyboard('123456'); + + await waitFor(() => expect(attempt).toHaveBeenCalledWith({ factor: emailFactor, code: '123456' })); + }); + + it('holds the code field inert while the code is still being sent', async () => { + let release = () => {}; + const prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockReturnValue( + new Promise(resolve => { + release = resolve; + }), + ); + renderView({ + challenge: { + status: 'needs_first_factor', + factors: [passwordFactor, emailFactor], + initialFactorId: emailFactor.id, + }, + prepare, + }); + + // The machine takes no keystroke until the code is out, so an editable-looking field would + // swallow one. + await waitFor(() => expect(codeSlots()[0]).toBeDisabled()); + + release(); + await waitFor(() => expect(codeSlots()[0]).toBeEnabled()); + }); + + it('lists the other methods by their localized labels, current one excluded', async () => { + renderView(); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Use another method' })); + + expect(screen.getByRole('button', { name: 'Email code to a••••@clerk.dev' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Continue with your password' })).not.toBeInTheDocument(); + }); + + it('switches to the method the user picks', async () => { + const { prepare } = renderView(); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Use another method' })); + await user.click(screen.getByRole('button', { name: 'Email code to a••••@clerk.dev' })); + + await waitFor(() => expect(prepare).toHaveBeenCalledWith(emailFactor)); + }); + + it('keeps the code step when the code could not be sent, and resends from there', async () => { + const prepare = vi + .fn<(factor: ReverificationPreparationFactor) => Promise>() + .mockRejectedValueOnce(new Error('Could not send the code.')) + .mockResolvedValue(undefined); + renderView({ + challenge: { + status: 'needs_first_factor', + factors: [passwordFactor, emailFactor], + initialFactorId: emailFactor.id, + }, + prepare, + }); + + expect(await screen.findByRole('alert')).toHaveTextContent('Could not send the code.'); + expect(codeSlots()).toHaveLength(6); + + await userEvent.setup().click(screen.getByRole('button', { name: /Resend/ })); + + await waitFor(() => expect(prepare).toHaveBeenCalledTimes(2)); + }); + + it('counts the resend cooldown down in the label and holds the button inert', async () => { + renderView({ + challenge: { + status: 'needs_first_factor', + factors: [passwordFactor, emailFactor], + initialFactorId: emailFactor.id, + }, + }); + + const resend = await screen.findByRole('button', { name: 'Didn’t receive a code? Resend (30)' }); + expect(resend).toHaveAttribute('aria-disabled', 'true'); + }); + + it('renders a passkey as an action with nothing to type', async () => { + renderView({ + challenge: { status: 'needs_first_factor', factors: [passkeyFactor], initialFactorId: passkeyFactor.id }, + }); + + expect(await screen.findByRole('button', { name: 'Use your passkey' })).toBeEnabled(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('offers help instead of alternatives when there is only one method', async () => { + renderView({ + challenge: { status: 'needs_first_factor', factors: [passwordFactor], initialFactorId: passwordFactor.id }, + }); + const user = userEvent.setup(); + + expect(screen.queryByRole('button', { name: 'Use another method' })).not.toBeInTheDocument(); + await user.click(await screen.findByRole('button', { name: 'Get help' })); + + expect(screen.getByText(/email us and we will work with you/i)).toBeInTheDocument(); + }); + + it('says so when the account has no method to offer', async () => { + renderView({ challenge: { status: 'needs_first_factor', factors: [] } }); + + expect(await screen.findByText('Cannot verify your account')).toBeInTheDocument(); + }); + + it('reports the failure against the field and lets the user try again', async () => { + const attempt = vi + .fn<(attempt: ReverificationAttempt) => Promise>() + .mockRejectedValue(new Error('Incorrect password.')); + renderView({ attempt }); + const user = userEvent.setup(); + + await user.type(await screen.findByLabelText('Password'), 'nope'); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + expect(await screen.findByText('Incorrect password.')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeEnabled(); + }); + + it('closes and reports cancellation when the user backs out', async () => { + const { onCancel } = renderView(); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Cancel' })); + + expect(onCancel).toHaveBeenCalledOnce(); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); +}); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx index 8d8cb2419c1..07eb55d1cbe 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx @@ -1,367 +1,212 @@ -import React from 'react'; - -import { Button, SubmitButton } from '../../components/button'; -import { Card } from '../../components/card'; -import { Dialog } from '../../components/dialog'; -import { Field } from '../../components/field'; -import { Heading } from '../../components/heading'; -import { Input } from '../../components/input'; -import { Text } from '../../components/text'; -import { reverificationDialogMessages as m } from './reverification-dialog.messages'; +import { useMachine } from '../../machine/useMachine'; +import type { ReverificationDialogMethod, ReverificationDialogProps } from './reverification-dialog'; +import { ReverificationDialog } from './reverification-dialog'; +import type { ReverificationDialogMachineContext } from './reverification-dialog.machine'; +import { reverificationDialogMachine } from './reverification-dialog.machine'; +import { fill, reverificationDialogBase as m } from './reverification-dialog.messages'; import type { - ReverificationDialogResendState, - ReverificationDialogSelectViewProps, - ReverificationDialogVerifyViewProps, - ReverificationDialogViewProps, + ReverificationAttempt, + ReverificationAttemptResult, + ReverificationChallenge, ReverificationFactor, + ReverificationPreparationFactor, } from './reverification-dialog.types'; -function DialogHeader({ title, description }: { title: React.ReactNode; description?: React.ReactNode }) { - return ( - - }>{title} - {description ? }>{description} : null} - - ); +export interface ReverificationDialogViewProps { + /** The methods this attempt may use, and which one to open on. */ + challenge: ReverificationChallenge; + /** Sends a code for a method that delivers one. Reject to keep the user on the code step. */ + prepare: (factor: ReverificationPreparationFactor) => Promise; + /** Submits the user's answer. Resolving `needs_second_factor` moves the flow on to 2FA. */ + attempt: (attempt: ReverificationAttempt) => Promise; + /** The user proved who they are. */ + onComplete: () => void; + /** The user gave up, or closed the dialog. */ + onCancel: () => void; } -function FormError({ children }: { children?: React.ReactNode }) { - return children ? ( - - {children} - - ) : null; +/** + * Title, subtitle, field label, and resend copy for a method — keyed the way + * `@clerk/localizations` keys it. `field` is absent for a method with nothing to type, and + * `resendButton` for one that delivers no code. + */ +function copyFor(factor: ReverificationFactor): { + title: string; + subtitle: string; + field?: { label: string; kind: 'code' | 'password' | 'text' }; + resendButton?: string; +} { + switch (factor.strategy) { + case 'password': + return { ...m.password, field: { label: m.formFieldLabel__password, kind: 'password' } }; + case 'passkey': + return m.passkey; + case 'email_code': + return { ...m.emailCode, field: { label: m.emailCode.formTitle, kind: 'code' } }; + case 'phone_code': { + const copy = factor.stage === 'second' ? m.phoneCodeMfa : m.phoneCode; + return { ...copy, field: { label: copy.formTitle, kind: 'code' } }; + } + case 'totp': + return { ...m.totpMfa, field: { label: m.totpMfa.formTitle, kind: 'code' } }; + case 'backup_code': + return { ...m.backupCodeMfa, field: { label: m.formFieldLabel__backupCode, kind: 'text' } }; + } } -function CancelButton({ label = m.cancel }: { label?: string }) { - return ( - ( - - ); -} +const alternativesTo = (context: ReverificationDialogMachineContext) => + context.challenge.factors.filter(factor => factor.id !== context.currentFactor?.id); -function CodeInput({ - id, - disabled, - value, - onChange, -}: { - id: string; - disabled: boolean; - value: string; - onChange: (value: string) => void; -}) { - return ( - onChange(event.target.value)} - /> - ); -} +/** + * Drives {@link ReverificationDialog} with {@link reverificationDialogMachine}. + * + * Every decision about what the flow does next lives in the machine; this layer only turns a + * snapshot into the block's props and the block's callbacks into events. The Clerk work arrives + * as `prepare` and `attempt`, so the whole flow runs against plain promises in a test or a story. + */ +export function ReverificationDialogView({ + challenge, + prepare, + attempt, + onComplete, + onCancel, +}: ReverificationDialogViewProps) { + const [snapshot, send, actor] = useMachine(reverificationDialogMachine, { + context: { initialChallenge: challenge, prepare, attempt, complete: onComplete, cancel: onCancel }, + }); + const { context } = snapshot; -function SelectFactorContent({ - stage, - availableFactors, - formError, - onSelectFactor, - onBack, - onShowHelp, -}: ReverificationDialogSelectViewProps) { - return ( - <> - - - - {formError} - {availableFactors.map(factor => ( - - ))} - - - {onBack ? ( - - ) : ( - - )} - - - - ); -} + // `useMachine` starts the actor in an effect, so the first render still sees the pre-start + // state, before `initialChallenge` has been read. Nothing truthful can be drawn from it. + if (snapshot.value === 'initializing') { + return null; + } -const safeIdentifierFrom = (factor: ReverificationFactor) => - 'safeIdentifier' in factor ? factor.safeIdentifier : undefined; + const base = { + open: snapshot.status === 'active', + onOpenChange: (open: boolean) => { + if (!open) { + send({ type: 'CANCEL' }); + } + }, + closeLabel: m.closeButton, + error: context.error?.location === 'form' ? context.error.message : undefined, + }; -function VerifyContent({ - factor, - value, - canSubmit, - isInputDisabled, - isVerifying, - fieldError, - formError, - resend, - onValueChange, - onResend, - onShowAlternatives, - onShowHelp, -}: ReverificationDialogVerifyViewProps) { - const fieldId = React.useId(); - const isDeliveredCode = factor.strategy === 'email_code' || factor.strategy === 'phone_code'; - const isCode = isDeliveredCode || factor.strategy === 'totp'; - const isPasskey = factor.strategy === 'passkey'; - const isPassword = factor.strategy === 'password'; - const description = (() => { - if (isDeliveredCode) { - return ( - <> - {m.deliveredCode} {safeIdentifierFrom(factor)} - - ); + const props = ((): ReverificationDialogProps => { + if (snapshot.value === 'unavailable') { + return { + ...base, + step: 'message', + title: m.noAvailableMethods.title, + description: m.noAvailableMethods.message, + action: { label: m.closeButton, onClick: () => send({ type: 'CANCEL' }) }, + }; } - if (factor.strategy === 'totp') { - return m.totp; + + if (snapshot.value === 'help') { + return { + ...base, + step: 'message', + title: m.alternativeMethods.getHelp.title, + description: m.alternativeMethods.getHelp.content, + action: { label: m.backButton, onClick: () => send({ type: 'BACK' }) }, + }; } - if (factor.strategy === 'backup_code') { - return m.backupCode; + + if (snapshot.value === 'selectingFactor') { + const methods = context.currentFactor ? alternativesTo(context) : context.challenge.factors; + return { + ...base, + step: 'choose', + title: m.alternativeMethods.title, + description: m.alternativeMethods.subtitle, + methods: methods.map(asMethod), + onSelectMethod: factorId => send({ type: 'SELECT_FACTOR', factorId }), + back: actor.can({ type: 'BACK' }) ? { label: m.backButton, onClick: () => send({ type: 'BACK' }) } : undefined, + cancelLabel: m.formButtonReset, + help: { label: m.alternativeMethods.actionLink, onClick: () => send({ type: 'SHOW_HELP' }) }, + }; } - if (isPasskey) { - return m.passkey; + + // Every remaining state is the flow working on the current method, so the step stays + // mounted while a code is sent or an answer is checked. + const factor = context.currentFactor; + if (!factor) { + return { + ...base, + step: 'message', + title: m.noAvailableMethods.title, + description: m.noAvailableMethods.message, + action: { label: m.closeButton, onClick: () => send({ type: 'CANCEL' }) }, + }; } - return m.password; - })(); - const fieldLabel = isPassword - ? m.passwordLabel - : factor.strategy === 'backup_code' - ? m.backupCodeLabel - : m.verificationCode; - return ( - <> - - - - {formError} - {!isPasskey ? ( - - {fieldLabel} - {isCode ? ( - - ) : ( - onValueChange(event.target.value)} - /> - )} - {fieldError ? {fieldError} : null} - - ) : null} - {resend ? ( - <> - {m.didNotReceiveCode} - - - ) : null} - - - {onShowAlternatives ? ( - - ) : onShowHelp ? ( - - ) : null} - ( - - - - ); - case 'unavailable': - return ( - <> - - - - - - - ); - } -} + return { + ...base, + step: 'verify', + title: copy.title, + description: copy.subtitle, + identifier: 'safeIdentifier' in factor ? factor.safeIdentifier : undefined, + field: copy.field + ? { + ...copy.field, + value: context.value, + disabled: !isEditable, + error: context.error?.location === 'field' ? context.error.message : undefined, + onChange: value => send({ type: 'CHANGE_VALUE', value }), + } + : undefined, + resend: resendButton + ? { + label: + context.resendSecondsRemaining > 0 ? `${resendButton} (${context.resendSecondsRemaining})` : resendButton, + disabled: !actor.can({ type: 'RESEND' }), + onResend: () => send({ type: 'RESEND' }), + } + : undefined, + submitLabel: factor.strategy === 'passkey' ? m.passkey.blockButton__passkey : m.formButtonPrimary, + pendingLabel: m.verifying, + canSubmit: actor.can({ type: 'SUBMIT' }), + isPending, + onSubmit: () => send({ type: 'SUBMIT' }), + cancelLabel: m.formButtonReset, + secondary: actor.can({ type: 'SHOW_ALTERNATIVES' }) + ? { label: m.footerActionLink__useAnotherMethod, onClick: () => send({ type: 'SHOW_ALTERNATIVES' }) } + : actor.can({ type: 'SHOW_HELP' }) + ? { label: m.alternativeMethods.actionLink, onClick: () => send({ type: 'SHOW_HELP' }) } + : undefined, + }; + })(); -export function ReverificationDialogView(props: ReverificationDialogViewProps) { - const handleSubmit = props.step === 'verify' ? props.onSubmit : undefined; - return ( - props.onOpenChange(nextOpen)} - > - - - - { - event.preventDefault(); - handleSubmit(); - }} - /> - ) : undefined - } - renderBranding={false} - /> - } - > - - - - - - ); + return ; } From c7f1fea4e3773006426c1e3a416e366b670a8d48 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 26 Aug 2026 09:46:54 -0600 Subject: [PATCH 04/12] fix(ui): restore reverification support and completion --- .../src/stories/reverification-dialog.mdx | 15 ++++-- .../stories/reverification-dialog.stories.tsx | 34 ++++++++----- .../reverification-dialog.machine.test.ts | 44 ++++++++++++++--- .../reverification-dialog.machine.ts | 27 ++++++++--- .../reverification-dialog.messages.ts | 1 + .../reverification-dialog.test.tsx | 23 +++++++-- .../reverification-dialog.tsx | 17 ++++++- .../reverification-dialog.types.ts | 1 - .../reverification-dialog.view.test.tsx | 48 +++++++++++++++++-- .../reverification-dialog.view.tsx | 29 ++++++++--- 10 files changed, 193 insertions(+), 46 deletions(-) diff --git a/packages/swingset/src/stories/reverification-dialog.mdx b/packages/swingset/src/stories/reverification-dialog.mdx index f1e6cc1b2b4..9ccb20b3e7f 100644 --- a/packages/swingset/src/stories/reverification-dialog.mdx +++ b/packages/swingset/src/stories/reverification-dialog.mdx @@ -89,14 +89,17 @@ Shared by every step: `step='message'` — a dead end: -| Prop | Type | Description | -| -------- | -------------------- | ----------------------------------- | -| `action` | `{ label, onClick }` | The single way out: back, or close. | +| Prop | Type | Description | +| -------- | -------------------- | ------------------------------------------------------------------------------------- | +| `action` | `{ label, onClick }` | The way forward — reaching a human. The primary button. | +| `back` | `{ label, onClick }` | Optional. Returns where the user came from. Omit when there is nothing to go back to. | ## Driving it from a machine `ReverificationDialogView` wires the block to `reverificationDialogMachine`, which holds every rule about what happens next: which method starts, when a code is sent, when six digits submit on their own, how long resend stays inert, and where a first-factor success leads. The Clerk work arrives as `prepare` and `attempt`, so the whole flow runs against plain promises in a test or a story. +`onComplete` is awaited: the dialog stays up and pending until it resolves, so the session is active before whatever asked for reverification runs again. Reject and the user lands back on the method they just satisfied, with the reason. + ```tsx import { ReverificationDialogView } from '@clerk/ui/mosaic/blocks/reverification-dialog'; @@ -104,7 +107,11 @@ import { ReverificationDialogView } from '@clerk/ui/mosaic/blocks/reverification challenge={{ status: 'needs_first_factor', factors, initialFactorId }} prepare={factor => session.prepareFirstFactorVerification(factor)} attempt={attempt => session.attemptFirstFactorVerification(attempt)} - onComplete={afterVerification} + onComplete={async () => { + await setActive({ session: sessionId }); + afterVerification(); + }} onCancel={closeModal} + supportEmail={supportEmail} />; ``` diff --git a/packages/swingset/src/stories/reverification-dialog.stories.tsx b/packages/swingset/src/stories/reverification-dialog.stories.tsx index 0a68bc71b40..3fb2452d5c7 100644 --- a/packages/swingset/src/stories/reverification-dialog.stories.tsx +++ b/packages/swingset/src/stories/reverification-dialog.stories.tsx @@ -27,14 +27,12 @@ export const meta: StoryMeta = { const passwordFactor: ReverificationPasswordFactor = { id: 'password', - label: 'Password', stage: 'first', strategy: 'password', }; const emailFactor: ReverificationEmailCodeFactor = { id: 'email_1', - label: 'Email code to a••••@clerk.dev', stage: 'first', strategy: 'email_code', emailAddressId: 'email_1', @@ -43,7 +41,6 @@ const emailFactor: ReverificationEmailCodeFactor = { const firstPhoneFactor: ReverificationFirstFactorPhoneCodeFactor = { id: 'phone_1', - label: 'SMS code to ••••4242', stage: 'first', strategy: 'phone_code', phoneNumberId: 'phone_1', @@ -52,14 +49,12 @@ const firstPhoneFactor: ReverificationFirstFactorPhoneCodeFactor = { const passkeyFactor: ReverificationPasskeyFactor = { id: 'passkey', - label: 'Passkey', stage: 'first', strategy: 'passkey', }; const secondPhoneFactor: ReverificationSecondFactorPhoneCodeFactor = { id: 'phone_2', - label: 'SMS code to ••••8675', stage: 'second', strategy: 'phone_code', phoneNumberId: 'phone_2', @@ -68,12 +63,23 @@ const secondPhoneFactor: ReverificationSecondFactorPhoneCodeFactor = { const secondFactors: ReverificationSecondFactor[] = [ secondPhoneFactor, - { id: 'totp', label: 'Authenticator app', stage: 'second', strategy: 'totp' }, - { id: 'backup_code', label: 'Backup code', stage: 'second', strategy: 'backup_code' }, + { id: 'totp', stage: 'second', strategy: 'totp' }, + { id: 'backup_code', stage: 'second', strategy: 'backup_code' }, ]; const firstFactors: ReverificationFirstFactor[] = [passwordFactor, emailFactor, firstPhoneFactor, passkeyFactor]; +// Only the launch buttons need these. The dialog names a method from its own messages. +const factorNames: Record = { + password: 'password', + email_1: 'email code', + phone_1: 'SMS code', + passkey: 'passkey', + phone_2: 'SMS code', + totp: 'authenticator app', + backup_code: 'backup code', +}; + interface Scenario { id: string; label: string; @@ -89,7 +95,7 @@ const scenarios: Scenario[] = [ }, ...firstFactors.map(factor => ({ id: `first-${factor.id}`, - label: `First factor — ${factor.label}`, + label: `First factor — ${factorNames[factor.id]}`, challenge: { status: 'needs_first_factor' as const, factors: firstFactors, initialFactorId: factor.id }, })), { @@ -105,7 +111,7 @@ const scenarios: Scenario[] = [ }, ...secondFactors.map(factor => ({ id: `second-${factor.id}`, - label: `Second factor — ${factor.label}`, + label: `Second factor — ${factorNames[factor.id]}`, challenge: { status: 'needs_second_factor' as const, factors: secondFactors, initialFactorId: factor.id }, })), ]; @@ -127,16 +133,22 @@ function MachineDrivenDialog({ scenario, onFinished }: { scenario: Scenario; onF [scenario.continuesToSecondFactor], ); // The view finishes in a final state, so the story unmounts it to make the demo repeatable. - // Deferred a tick because the machine reports completion from inside its own transition. + // Deferred a tick because the machine reports cancellation from inside its own transition. const finish = React.useCallback(() => window.setTimeout(onFinished, 0), [onFinished]); + // Stands in for activating the session, which the dialog waits out before it closes. + const complete = React.useCallback(async () => { + await settleAfter(800); + finish(); + }, [finish]); return ( ); } diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts index 1f9722a1a90..fbe0b894d21 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts @@ -16,14 +16,12 @@ import type { const passwordFactor: ReverificationPasswordFactor = { id: 'password', - label: 'Password', stage: 'first', strategy: 'password', }; const emailFactor: ReverificationEmailCodeFactor = { id: 'email_1', - label: 'Email code to a••••@clerk.dev', stage: 'first', strategy: 'email_code', emailAddressId: 'email_1', @@ -32,7 +30,6 @@ const emailFactor: ReverificationEmailCodeFactor = { const phoneFactor: ReverificationFirstFactorPhoneCodeFactor = { id: 'phone_1', - label: 'SMS code to ••••1234', stage: 'first', strategy: 'phone_code', phoneNumberId: 'phone_1', @@ -41,14 +38,12 @@ const phoneFactor: ReverificationFirstFactorPhoneCodeFactor = { const totpFactor: ReverificationTOTPFactor = { id: 'totp', - label: 'Authenticator app', stage: 'second', strategy: 'totp', }; const secondPhoneFactor: ReverificationSecondFactorPhoneCodeFactor = { id: 'phone_2', - label: 'SMS code to ••••5678', stage: 'second', strategy: 'phone_code', phoneNumberId: 'phone_2', @@ -69,13 +64,13 @@ function start({ attempt = vi .fn<(attempt: ReverificationAttempt) => Promise>() .mockResolvedValue({ status: 'complete' }), - complete = vi.fn(), + complete = vi.fn<() => Promise>().mockResolvedValue(undefined), cancel = vi.fn(), }: { challenge?: ReverificationChallenge; prepare?: (factor: ReverificationPreparationFactor) => Promise; attempt?: (attempt: ReverificationAttempt) => Promise; - complete?: () => void; + complete?: () => Promise; cancel?: () => void; } = {}) { const actor = createActor(reverificationDialogMachine, { @@ -127,6 +122,41 @@ describe('reverificationDialogMachine', () => { expect(actor.getSnapshot().status).toBe('done'); }); + it('stays open and pending until the caller finishes completing', async () => { + let finish = () => {}; + const complete = vi.fn<() => Promise>().mockReturnValue( + new Promise(resolve => { + finish = resolve; + }), + ); + const { actor } = start({ complete }); + + actor.send({ type: 'CHANGE_VALUE', value: 'secret' }); + actor.send({ type: 'SUBMIT' }); + + // The attempt has landed but the session is not active yet, so the flow is not done with it. + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('completing')); + expect(actor.getSnapshot().status).toBe('active'); + + finish(); + await vi.waitFor(() => expect(actor.getSnapshot().status).toBe('done')); + }); + + it('returns to the factor when completion fails, so the reason is not lost', async () => { + const complete = vi.fn<() => Promise>().mockRejectedValue(new Error('Could not activate the session.')); + const { actor } = start({ complete }); + + actor.send({ type: 'CHANGE_VALUE', value: 'secret' }); + actor.send({ type: 'SUBMIT' }); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifying')); + expect(actor.getSnapshot().context).toMatchObject({ + value: '', + error: { message: 'Could not activate the session.' }, + }); + expect(actor.getSnapshot().status).toBe('active'); + }); + it('prepares a delivered-code factor and automatically submits six normalized digits', async () => { const prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined); const attempt = vi diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts index e114002210d..8127b2cb2de 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts @@ -39,7 +39,11 @@ export interface ReverificationDialogMachineContext { returnState: ReverificationDialogReturnState; prepare: (factor: ReverificationPreparationFactor) => Promise; attempt: (attempt: ReverificationAttempt) => Promise; - complete: () => void; + /** + * The verification landed. Awaited, so the dialog stays up while the caller activates the + * session — legacy did the same before handing back to whatever asked for reverification. + */ + complete: () => Promise; cancel: () => void; } @@ -146,7 +150,7 @@ export const reverificationDialogMachine = createMachine({ returnState: 'selectingFactor', prepare: () => Promise.resolve(), attempt: () => Promise.resolve({ status: 'complete' }), - complete: () => {}, + complete: () => Promise.resolve(), cancel: () => {}, }, states: { @@ -292,7 +296,7 @@ export const reverificationDialogMachine = createMachine({ invoke: fromPromise(context => context.attempt(attemptFrom(context)), { onDone: [ { - target: 'completed', + target: 'completing', guard: (_, event) => event.output.status === 'complete', }, { @@ -360,10 +364,21 @@ export const reverificationDialogMachine = createMachine({ }, }, unavailable: { on: { CANCEL: 'cancelled' } }, - completed: { - type: 'final', - entry: context => context.complete(), + completing: { + invoke: fromPromise(context => context.complete(), { + onDone: 'completed', + // The code was right but the session did not activate. Legacy surfaced that on the card + // the user was already looking at, so the flow returns there rather than closing. + onError: ({ context, event }) => ({ + target: context.resendSecondsRemaining > 0 ? 'verifyingCooldown' : 'verifying', + context: { + value: '', + error: errorFrom(event.error, attemptErrorLocation(context)), + }, + }), + }), }, + completed: { type: 'final' }, cancelled: { type: 'final', entry: context => context.cancel(), diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts index c5973899825..10db158f1bd 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts @@ -21,6 +21,7 @@ export const reverificationDialogBase = { blockButton__phoneCode: 'Send SMS code to {identifier}', blockButton__totp: 'Use your authenticator app', getHelp: { + blockButton__emailSupport: 'Email support', content: 'If you have trouble verifying your account, email us and we will work with you to restore access as soon as possible.', title: 'Get help', diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx index 22e6759913d..bd47aa568f1 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx @@ -59,7 +59,7 @@ const messageProps = (overrides: Partial = {}) step: 'message', title: 'Get help', description: 'Email us and we will work with you to restore access.', - action: { label: 'Back', onClick: vi.fn() }, + action: { label: 'Email support', onClick: vi.fn() }, ...overrides, }); @@ -192,13 +192,28 @@ describe('ReverificationDialog', () => { expect(onResend).not.toHaveBeenCalled(); }); - it('gives a dead end exactly one way out', async () => { + it('leads a dead end with the way forward, not the way back', async () => { const onClick = vi.fn(); - renderBlock(messageProps({ action: { label: 'Back', onClick } })); + renderBlock(messageProps({ action: { label: 'Email support', onClick } })); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); - await userEvent.setup().click(screen.getByRole('button', { name: 'Back' })); + await userEvent.setup().click(screen.getByRole('button', { name: 'Email support' })); + + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('offers a way back from a dead end only when the caller supplies one', async () => { + const onClick = vi.fn(); + const { rerender } = renderBlock(messageProps()); + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); + rerender( + + + , + ); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Back' })); expect(onClick).toHaveBeenCalledOnce(); }); }); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx index 65f947fb5c7..43165777733 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx @@ -84,10 +84,13 @@ export interface ReverificationDialogVerifyProps extends ReverificationDialogBas secondary?: ReverificationDialogAction; } -/** A dead end with one way out: help, or no methods to offer. */ +/** A dead end: help, or no methods to offer. */ export interface ReverificationDialogMessageProps extends ReverificationDialogBaseProps { step: 'message'; + /** The way forward from a dead end — reaching a human. */ action: ReverificationDialogAction; + /** Returns where the user came from. Absent where there is nothing to go back to. */ + back?: ReverificationDialogAction; } export type ReverificationDialogProps = @@ -367,7 +370,7 @@ function VerifyStep({ ); } -function MessageStep({ action }: ReverificationDialogMessageProps) { +function MessageStep({ action, back }: ReverificationDialogMessageProps) { return ( + {back ? ( + + ) : null} ); } diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts index 771c24a00aa..c908d4d0a19 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts @@ -1,6 +1,5 @@ interface ReverificationFactorBase { id: string; - label: string; } export interface ReverificationPasswordFactor extends ReverificationFactorBase { diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx index 73e614e0d6f..4532838c1f8 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx @@ -16,14 +16,12 @@ import { ReverificationDialogView } from './reverification-dialog.view'; const passwordFactor: ReverificationPasswordFactor = { id: 'password', - label: 'Password', stage: 'first', strategy: 'password', }; const emailFactor: ReverificationEmailCodeFactor = { id: 'email_1', - label: 'Email code', stage: 'first', strategy: 'email_code', emailAddressId: 'email_1', @@ -32,7 +30,6 @@ const emailFactor: ReverificationEmailCodeFactor = { const passkeyFactor: ReverificationPasskeyFactor = { id: 'passkey', - label: 'Passkey', stage: 'first', strategy: 'passkey', }; @@ -47,8 +44,9 @@ function renderView({ attempt = vi .fn<(attempt: ReverificationAttempt) => Promise>() .mockResolvedValue({ status: 'complete' }), - onComplete = vi.fn(), + onComplete = vi.fn<() => Promise>().mockResolvedValue(undefined), onCancel = vi.fn(), + supportEmail = 'support@clerk.dev', } = {}) { render( @@ -58,10 +56,11 @@ function renderView({ attempt={attempt} onComplete={onComplete} onCancel={onCancel} + supportEmail={supportEmail} /> , ); - return { prepare, attempt, onComplete, onCancel }; + return { prepare, attempt, onComplete, onCancel, supportEmail }; } /** The code field is a group of single-character slots, not one input. */ @@ -198,6 +197,45 @@ describe('ReverificationDialogView', () => { await user.click(await screen.findByRole('button', { name: 'Get help' })); expect(screen.getByText(/email us and we will work with you/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Email support' })).toBeInTheDocument(); + }); + + it('sends a stuck user to support, the only thing left that can help them', async () => { + const location = { href: '' }; + Object.defineProperty(window, 'location', { value: location, writable: true }); + renderView({ challenge: { status: 'needs_first_factor', factors: [] } }); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Email support' })); + + expect(location.href).toBe('mailto:support@clerk.dev'); + }); + + it('leaves no way back from a dead end with no method to go back to', async () => { + renderView({ challenge: { status: 'needs_first_factor', factors: [] } }); + + expect(await screen.findByRole('button', { name: 'Email support' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); + }); + + it('holds the dialog open and pending while the caller completes', async () => { + let finish = () => {}; + const onComplete = vi.fn<() => Promise>().mockReturnValue( + new Promise(resolve => { + finish = resolve; + }), + ); + renderView({ onComplete }); + const user = userEvent.setup(); + + await user.type(await screen.findByLabelText('Password'), 'secret'); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => expect(onComplete).toHaveBeenCalled()); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Continue' })).toHaveAttribute('aria-busy', 'true'); + + finish(); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); it('says so when the account has no method to offer', async () => { diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx index 07eb55d1cbe..9be875a6e71 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx @@ -19,10 +19,15 @@ export interface ReverificationDialogViewProps { prepare: (factor: ReverificationPreparationFactor) => Promise; /** Submits the user's answer. Resolving `needs_second_factor` moves the flow on to 2FA. */ attempt: (attempt: ReverificationAttempt) => Promise; - /** The user proved who they are. */ - onComplete: () => void; + /** + * The user proved who they are. Awaited: the dialog stays up, pending, until this resolves, + * so a caller can activate the session before the flow hands back. + */ + onComplete: () => Promise; /** The user gave up, or closed the dialog. */ onCancel: () => void; + /** Address behind the support action on a dead end. From Clerk's `useSupportEmail`. */ + supportEmail: string; } /** @@ -93,6 +98,7 @@ export function ReverificationDialogView({ attempt, onComplete, onCancel, + supportEmail, }: ReverificationDialogViewProps) { const [snapshot, send, actor] = useMachine(reverificationDialogMachine, { context: { initialChallenge: challenge, prepare, attempt, complete: onComplete, cancel: onCancel }, @@ -105,6 +111,15 @@ export function ReverificationDialogView({ return null; } + // The one thing a user with no working method can still do. A navigation rather than a + // callback, the way the legacy error card did it. + const emailSupport = { + label: m.alternativeMethods.getHelp.blockButton__emailSupport, + onClick: () => { + window.location.href = `mailto:${supportEmail}`; + }, + }; + const base = { open: snapshot.status === 'active', onOpenChange: (open: boolean) => { @@ -117,13 +132,14 @@ export function ReverificationDialogView({ }; const props = ((): ReverificationDialogProps => { + // Legacy gave this card no way back — there is no method to go back to. if (snapshot.value === 'unavailable') { return { ...base, step: 'message', title: m.noAvailableMethods.title, description: m.noAvailableMethods.message, - action: { label: m.closeButton, onClick: () => send({ type: 'CANCEL' }) }, + action: emailSupport, }; } @@ -133,7 +149,8 @@ export function ReverificationDialogView({ step: 'message', title: m.alternativeMethods.getHelp.title, description: m.alternativeMethods.getHelp.content, - action: { label: m.backButton, onClick: () => send({ type: 'BACK' }) }, + action: emailSupport, + back: { label: m.backButton, onClick: () => send({ type: 'BACK' }) }, }; } @@ -161,13 +178,13 @@ export function ReverificationDialogView({ step: 'message', title: m.noAvailableMethods.title, description: m.noAvailableMethods.message, - action: { label: m.closeButton, onClick: () => send({ type: 'CANCEL' }) }, + action: emailSupport, }; } const copy = copyFor(factor); const { resendButton } = copy; - const isPending = snapshot.value === 'submitting'; + const isPending = snapshot.value === 'submitting' || snapshot.value === 'completing'; // Only these two states accept a keystroke; anywhere else the field would swallow one. const isEditable = snapshot.value === 'verifying' || snapshot.value === 'verifyingCooldown'; From 34f91527f069ef74e571a52b57de61a8f4291913 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 26 Aug 2026 09:47:56 -0600 Subject: [PATCH 05/12] refactor(ui): make reverification flow invariants explicit --- .../src/stories/reverification-dialog.mdx | 20 +- .../stories/reverification-dialog.stories.tsx | 76 ++++--- .../blocks/reverification-dialog/index.ts | 2 +- .../reverification-dialog.machine.test.ts | 134 ++++++++---- .../reverification-dialog.machine.ts | 207 +++++++++++------- .../reverification-dialog.messages.ts | 5 + .../reverification-dialog.test.tsx | 14 +- .../reverification-dialog.tsx | 34 ++- .../reverification-dialog.types.ts | 37 ++-- .../reverification-dialog.view.test.tsx | 76 ++++--- .../reverification-dialog.view.tsx | 45 ++-- 11 files changed, 425 insertions(+), 225 deletions(-) diff --git a/packages/swingset/src/stories/reverification-dialog.mdx b/packages/swingset/src/stories/reverification-dialog.mdx index 9ccb20b3e7f..29115767a1a 100644 --- a/packages/swingset/src/stories/reverification-dialog.mdx +++ b/packages/swingset/src/stories/reverification-dialog.mdx @@ -33,6 +33,7 @@ import { ReverificationDialog } from '@clerk/ui/mosaic/blocks/reverification-dia step='verify' open={open} onOpenChange={setOpen} + dismissible={!isPending} title='Verification required' description='Enter your current password to continue' closeLabel='Close' @@ -56,7 +57,8 @@ Shared by every step: | -------------- | ----------------------------------- | -------------------------------------------------------------------------------------- | | `step` | `'choose' \| 'verify' \| 'message'` | Which step is showing. Picks the rest of the props. | | `open` | `boolean` | Whether the dialog is showing. Controlled, the way any dialog is. | -| `onOpenChange` | `(open: boolean) => void` | Asks to open or close. Fired by Cancel, Escape, and the backdrop. | +| `onOpenChange` | `(open: boolean) => void` | Asks to open or close through an allowed close control or request. | +| `dismissible` | `boolean` | Enables close controls and close requests. | | `title` | `string` | Names what is being asked. | | `description` | `string` | Spells out what the user has to do. | | `closeLabel` | `string` | Accessible name for the corner close button. | @@ -89,26 +91,26 @@ Shared by every step: `step='message'` — a dead end: -| Prop | Type | Description | -| -------- | -------------------- | ------------------------------------------------------------------------------------- | -| `action` | `{ label, onClick }` | The way forward — reaching a human. The primary button. | -| `back` | `{ label, onClick }` | Optional. Returns where the user came from. Omit when there is nothing to go back to. | +| Prop | Type | Description | +| ----------- | -------------------- | ------------------------------------------------------------- | +| `action` | `{ label, onClick }` | The way forward — reaching a human. The primary button. | +| `secondary` | `{ label, onClick }` | Optional. A secondary action such as returning or cancelling. | ## Driving it from a machine `ReverificationDialogView` wires the block to `reverificationDialogMachine`, which holds every rule about what happens next: which method starts, when a code is sent, when six digits submit on their own, how long resend stays inert, and where a first-factor success leads. The Clerk work arrives as `prepare` and `attempt`, so the whole flow runs against plain promises in a test or a story. -`onComplete` is awaited: the dialog stays up and pending until it resolves, so the session is active before whatever asked for reverification runs again. Reject and the user lands back on the method they just satisfied, with the reason. +`onComplete` is awaited: the dialog stays up and pending until it resolves, so the session is active before whatever asked for reverification runs again. If it rejects, the verified result is retained and the user can retry completion without answering the factor again. ```tsx import { ReverificationDialogView } from '@clerk/ui/mosaic/blocks/reverification-dialog'; session.prepareFirstFactorVerification(factor)} attempt={attempt => session.attemptFirstFactorVerification(attempt)} - onComplete={async () => { - await setActive({ session: sessionId }); + onComplete={async result => { + await setActive({ session: result.sessionId }); afterVerification(); }} onCancel={closeModal} diff --git a/packages/swingset/src/stories/reverification-dialog.stories.tsx b/packages/swingset/src/stories/reverification-dialog.stories.tsx index 3fb2452d5c7..9f221bc2d42 100644 --- a/packages/swingset/src/stories/reverification-dialog.stories.tsx +++ b/packages/swingset/src/stories/reverification-dialog.stories.tsx @@ -2,6 +2,7 @@ import type { ReverificationAttempt, ReverificationAttemptResult, ReverificationChallenge, + ReverificationCompleteResult, ReverificationEmailCodeFactor, ReverificationFirstFactor, ReverificationFirstFactorPhoneCodeFactor, @@ -26,13 +27,11 @@ export const meta: StoryMeta = { }; const passwordFactor: ReverificationPasswordFactor = { - id: 'password', stage: 'first', strategy: 'password', }; const emailFactor: ReverificationEmailCodeFactor = { - id: 'email_1', stage: 'first', strategy: 'email_code', emailAddressId: 'email_1', @@ -40,7 +39,6 @@ const emailFactor: ReverificationEmailCodeFactor = { }; const firstPhoneFactor: ReverificationFirstFactorPhoneCodeFactor = { - id: 'phone_1', stage: 'first', strategy: 'phone_code', phoneNumberId: 'phone_1', @@ -48,13 +46,11 @@ const firstPhoneFactor: ReverificationFirstFactorPhoneCodeFactor = { }; const passkeyFactor: ReverificationPasskeyFactor = { - id: 'passkey', stage: 'first', strategy: 'passkey', }; const secondPhoneFactor: ReverificationSecondFactorPhoneCodeFactor = { - id: 'phone_2', stage: 'second', strategy: 'phone_code', phoneNumberId: 'phone_2', @@ -63,21 +59,26 @@ const secondPhoneFactor: ReverificationSecondFactorPhoneCodeFactor = { const secondFactors: ReverificationSecondFactor[] = [ secondPhoneFactor, - { id: 'totp', stage: 'second', strategy: 'totp' }, - { id: 'backup_code', stage: 'second', strategy: 'backup_code' }, + { stage: 'second', strategy: 'totp' }, + { stage: 'second', strategy: 'backup_code' }, ]; const firstFactors: ReverificationFirstFactor[] = [passwordFactor, emailFactor, firstPhoneFactor, passkeyFactor]; // Only the launch buttons need these. The dialog names a method from its own messages. -const factorNames: Record = { - password: 'password', - email_1: 'email code', - phone_1: 'SMS code', - passkey: 'passkey', - phone_2: 'SMS code', - totp: 'authenticator app', - backup_code: 'backup code', +const factorStoryDetails = (factor: ReverificationFirstFactor | ReverificationSecondFactor) => { + switch (factor.strategy) { + case 'email_code': + return { id: factor.emailAddressId, name: 'email code' }; + case 'phone_code': + return { id: factor.phoneNumberId, name: 'SMS code' }; + case 'totp': + return { id: factor.strategy, name: 'authenticator app' }; + case 'backup_code': + return { id: factor.strategy, name: 'backup code' }; + default: + return { id: factor.strategy, name: factor.strategy }; + } }; interface Scenario { @@ -93,15 +94,18 @@ const scenarios: Scenario[] = [ label: 'First factor — choose method', challenge: { status: 'needs_first_factor', factors: firstFactors }, }, - ...firstFactors.map(factor => ({ - id: `first-${factor.id}`, - label: `First factor — ${factorNames[factor.id]}`, - challenge: { status: 'needs_first_factor' as const, factors: firstFactors, initialFactorId: factor.id }, - })), + ...firstFactors.map(factor => { + const details = factorStoryDetails(factor); + return { + id: `first-${details.id}`, + label: `First factor — ${details.name}`, + challenge: { status: 'needs_first_factor' as const, factors: firstFactors, initialFactor: factor }, + }; + }), { id: 'first-then-second', label: 'First factor → second factor', - challenge: { status: 'needs_first_factor', factors: firstFactors, initialFactorId: passwordFactor.id }, + challenge: { status: 'needs_first_factor', factors: firstFactors, initialFactor: passwordFactor }, continuesToSecondFactor: true, }, { @@ -109,11 +113,14 @@ const scenarios: Scenario[] = [ label: 'Second factor — choose method', challenge: { status: 'needs_second_factor', factors: secondFactors }, }, - ...secondFactors.map(factor => ({ - id: `second-${factor.id}`, - label: `Second factor — ${factorNames[factor.id]}`, - challenge: { status: 'needs_second_factor' as const, factors: secondFactors, initialFactorId: factor.id }, - })), + ...secondFactors.map(factor => { + const details = factorStoryDetails(factor); + return { + id: `second-${details.id}`, + label: `Second factor — ${details.name}`, + challenge: { status: 'needs_second_factor' as const, factors: secondFactors, initialFactor: factor }, + }; + }), ]; const settleAfter = (ms: number) => new Promise(resolve => window.setTimeout(resolve, ms)); @@ -128,7 +135,7 @@ function MachineDrivenDialog({ scenario, onFinished }: { scenario: Scenario; onF if (scenario.continuesToSecondFactor && attemptValue.factor.stage === 'first') { return { status: 'needs_second_factor', factors: secondFactors }; } - return { status: 'complete' }; + return { status: 'complete', sessionId: 'sess_story' }; }, [scenario.continuesToSecondFactor], ); @@ -136,17 +143,20 @@ function MachineDrivenDialog({ scenario, onFinished }: { scenario: Scenario; onF // Deferred a tick because the machine reports cancellation from inside its own transition. const finish = React.useCallback(() => window.setTimeout(onFinished, 0), [onFinished]); // Stands in for activating the session, which the dialog waits out before it closes. - const complete = React.useCallback(async () => { - await settleAfter(800); - finish(); - }, [finish]); + const onComplete = React.useCallback( + async (_result: ReverificationCompleteResult) => { + await settleAfter(800); + finish(); + }, + [finish], + ); return ( diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts index 90e2db4fff7..85151b8233f 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts @@ -9,7 +9,7 @@ export type { ReverificationDialogResend, ReverificationDialogVerifyProps, } from './reverification-dialog'; -export { reverificationDialogMachine } from './reverification-dialog.machine'; +export { reverificationDialogMachine, reverificationFactorKey } from './reverification-dialog.machine'; export type { ReverificationDialogMachineContext, ReverificationDialogMachineEvent, diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts index fbe0b894d21..2d178dd76b9 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createActor } from '../../machine/createActor'; -import { reverificationDialogMachine } from './reverification-dialog.machine'; +import { reverificationDialogMachine, reverificationFactorKey } from './reverification-dialog.machine'; import type { ReverificationAttempt, ReverificationAttemptResult, ReverificationChallenge, + ReverificationCompleteResult, ReverificationEmailCodeFactor, ReverificationFirstFactorPhoneCodeFactor, ReverificationPasswordFactor, @@ -15,13 +16,11 @@ import type { } from './reverification-dialog.types'; const passwordFactor: ReverificationPasswordFactor = { - id: 'password', stage: 'first', strategy: 'password', }; const emailFactor: ReverificationEmailCodeFactor = { - id: 'email_1', stage: 'first', strategy: 'email_code', emailAddressId: 'email_1', @@ -29,7 +28,6 @@ const emailFactor: ReverificationEmailCodeFactor = { }; const phoneFactor: ReverificationFirstFactorPhoneCodeFactor = { - id: 'phone_1', stage: 'first', strategy: 'phone_code', phoneNumberId: 'phone_1', @@ -37,13 +35,11 @@ const phoneFactor: ReverificationFirstFactorPhoneCodeFactor = { }; const totpFactor: ReverificationTOTPFactor = { - id: 'totp', stage: 'second', strategy: 'totp', }; const secondPhoneFactor: ReverificationSecondFactorPhoneCodeFactor = { - id: 'phone_2', stage: 'second', strategy: 'phone_code', phoneNumberId: 'phone_2', @@ -59,18 +55,18 @@ const firstFactorChallenge = ( }); function start({ - challenge = firstFactorChallenge({ initialFactorId: passwordFactor.id }), + challenge = firstFactorChallenge({ initialFactor: passwordFactor }), prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined), attempt = vi .fn<(attempt: ReverificationAttempt) => Promise>() - .mockResolvedValue({ status: 'complete' }), - complete = vi.fn<() => Promise>().mockResolvedValue(undefined), + .mockResolvedValue({ status: 'complete', sessionId: 'sess_1' }), + complete = vi.fn<(result: ReverificationCompleteResult) => Promise>().mockResolvedValue(undefined), cancel = vi.fn(), }: { challenge?: ReverificationChallenge; prepare?: (factor: ReverificationPreparationFactor) => Promise; attempt?: (attempt: ReverificationAttempt) => Promise; - complete?: () => Promise; + complete?: (result: ReverificationCompleteResult) => Promise; cancel?: () => void; } = {}) { const actor = createActor(reverificationDialogMachine, { @@ -91,7 +87,7 @@ describe('reverificationDialogMachine', () => { expect(actor.getSnapshot().context.challenge.factors).toEqual([passwordFactor, emailFactor, phoneFactor]); expect(actor.can({ type: 'BACK' })).toBe(false); - actor.send({ type: 'SELECT_FACTOR', factorId: passwordFactor.id }); + actor.send({ type: 'SELECT_FACTOR', factorKey: reverificationFactorKey(passwordFactor) }); expect(actor.getSnapshot()).toMatchObject({ value: 'verifying', context: { currentFactor: passwordFactor }, @@ -99,16 +95,30 @@ describe('reverificationDialogMachine', () => { }); it('treats an invalid initial factor as no selection', () => { - const { actor } = start({ challenge: firstFactorChallenge({ initialFactorId: 'missing' }) }); + const { actor } = start({ + challenge: firstFactorChallenge({ + initialFactor: { ...emailFactor, emailAddressId: 'missing' }, + }), + }); expect(actor.getSnapshot().value).toBe('selectingFactor'); expect(actor.getSnapshot().context.currentFactor).toBeNull(); }); + it('rejects factors whose derived identities collide', () => { + expect(() => + start({ + challenge: firstFactorChallenge({ + factors: [emailFactor, { ...emailFactor, safeIdentifier: 'b••••@clerk.dev' }], + }), + }), + ).toThrow('Reverification factors must have unique identities.'); + }); + it('submits the selected password and completes the attempt', async () => { const attempt = vi .fn<(attempt: ReverificationAttempt) => Promise>() - .mockResolvedValue({ status: 'complete' }); + .mockResolvedValue({ status: 'complete', sessionId: 'sess_1' }); const complete = vi.fn(); const { actor } = start({ attempt, complete }); @@ -118,13 +128,13 @@ describe('reverificationDialogMachine', () => { expect(actor.getSnapshot().value).toBe('submitting'); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('completed')); expect(attempt).toHaveBeenCalledWith({ factor: passwordFactor, password: 'secret' }); - expect(complete).toHaveBeenCalledOnce(); + expect(complete).toHaveBeenCalledWith({ status: 'complete', sessionId: 'sess_1' }); expect(actor.getSnapshot().status).toBe('done'); }); it('stays open and pending until the caller finishes completing', async () => { let finish = () => {}; - const complete = vi.fn<() => Promise>().mockReturnValue( + const complete = vi.fn<(result: ReverificationCompleteResult) => Promise>().mockReturnValue( new Promise(resolve => { finish = resolve; }), @@ -137,33 +147,63 @@ describe('reverificationDialogMachine', () => { // The attempt has landed but the session is not active yet, so the flow is not done with it. await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('completing')); expect(actor.getSnapshot().status).toBe('active'); + expect(actor.can({ type: 'CANCEL' })).toBe(false); finish(); await vi.waitFor(() => expect(actor.getSnapshot().status).toBe('done')); }); - it('returns to the factor when completion fails, so the reason is not lost', async () => { - const complete = vi.fn<() => Promise>().mockRejectedValue(new Error('Could not activate the session.')); - const { actor } = start({ complete }); + it('retries completion with the verified result without repeating the attempt', async () => { + const complete = vi + .fn<(result: ReverificationCompleteResult) => Promise>() + .mockRejectedValueOnce(new Error('Could not activate the session.')) + .mockResolvedValue(undefined); + const { actor, attempt } = start({ complete }); actor.send({ type: 'CHANGE_VALUE', value: 'secret' }); actor.send({ type: 'SUBMIT' }); - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifying')); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('completionFailed')); expect(actor.getSnapshot().context).toMatchObject({ - value: '', - error: { message: 'Could not activate the session.' }, + value: 'secret', + verification: { status: 'complete', sessionId: 'sess_1' }, + error: { scope: 'flow', message: 'Could not activate the session.' }, }); expect(actor.getSnapshot().status).toBe('active'); + expect(actor.can({ type: 'CANCEL' })).toBe(true); + + actor.send({ type: 'RETRY_COMPLETE' }); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('completed')); + + expect(attempt).toHaveBeenCalledOnce(); + expect(complete).toHaveBeenCalledTimes(2); + expect(complete).toHaveBeenNthCalledWith(1, { status: 'complete', sessionId: 'sess_1' }); + expect(complete).toHaveBeenNthCalledWith(2, { status: 'complete', sessionId: 'sess_1' }); + }); + + it('allows cancellation after completion fails', async () => { + const complete = vi + .fn<(result: ReverificationCompleteResult) => Promise>() + .mockRejectedValue(new Error('Could not activate the session.')); + const { actor, cancel } = start({ complete }); + + actor.send({ type: 'CHANGE_VALUE', value: 'secret' }); + actor.send({ type: 'SUBMIT' }); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('completionFailed')); + + actor.send({ type: 'CANCEL' }); + + expect(cancel).toHaveBeenCalledOnce(); + expect(actor.getSnapshot()).toMatchObject({ value: 'cancelled', status: 'done' }); }); it('prepares a delivered-code factor and automatically submits six normalized digits', async () => { const prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined); const attempt = vi .fn<(attempt: ReverificationAttempt) => Promise>() - .mockResolvedValue({ status: 'complete' }); + .mockResolvedValue({ status: 'complete', sessionId: 'sess_1' }); const { actor } = start({ - challenge: firstFactorChallenge({ initialFactorId: emailFactor.id }), + challenge: firstFactorChallenge({ initialFactor: emailFactor }), prepare, attempt, }); @@ -180,13 +220,13 @@ describe('reverificationDialogMachine', () => { }); it('continues from first-factor success into a normalized second-factor challenge', async () => { - const initialChallenge = firstFactorChallenge({ initialFactorId: passwordFactor.id }); + const initialChallenge = firstFactorChallenge({ initialFactor: passwordFactor }); const attempt = vi .fn<(attempt: ReverificationAttempt) => Promise>() .mockResolvedValue({ status: 'needs_second_factor', factors: [totpFactor, secondPhoneFactor], - initialFactorId: totpFactor.id, + initialFactor: totpFactor, }); const { actor } = start({ challenge: initialChallenge, attempt }); @@ -202,7 +242,7 @@ describe('reverificationDialogMachine', () => { actor.setContext({ initialChallenge }); actor.send({ type: 'SHOW_ALTERNATIVES' }); - actor.send({ type: 'SELECT_FACTOR', factorId: secondPhoneFactor.id }); + actor.send({ type: 'SELECT_FACTOR', factorKey: reverificationFactorKey(secondPhoneFactor) }); expect(actor.getSnapshot()).toMatchObject({ value: 'preparing', context: { @@ -228,13 +268,13 @@ describe('reverificationDialogMachine', () => { it('matches legacy help visibility outside factor selection', async () => { const { actor: passwordActor } = start({ - challenge: firstFactorChallenge({ factors: [passwordFactor], initialFactorId: passwordFactor.id }), + challenge: firstFactorChallenge({ factors: [passwordFactor], initialFactor: passwordFactor }), }); expect(passwordActor.getSnapshot().value).toBe('verifying'); expect(passwordActor.can({ type: 'SHOW_HELP' })).toBe(true); const { actor: emailActor } = start({ - challenge: firstFactorChallenge({ factors: [emailFactor], initialFactorId: emailFactor.id }), + challenge: firstFactorChallenge({ factors: [emailFactor], initialFactor: emailFactor }), }); await vi.waitFor(() => expect(emailActor.getSnapshot().value).toBe('verifyingCooldown')); expect(emailActor.can({ type: 'SHOW_HELP' })).toBe(false); @@ -244,10 +284,26 @@ describe('reverificationDialogMachine', () => { expect(emailActor.getSnapshot().value).toBe('verifyingCooldown'); }); + it('returns from help to the state that opened it without storing a goto', () => { + const { actor: selectionActor } = start({ challenge: firstFactorChallenge() }); + selectionActor.send({ type: 'SHOW_HELP' }); + expect(selectionActor.getSnapshot().value).toBe('helpFromSelection'); + selectionActor.send({ type: 'BACK' }); + expect(selectionActor.getSnapshot().value).toBe('selectingFactor'); + + const { actor: factorActor } = start({ + challenge: firstFactorChallenge({ factors: [passwordFactor], initialFactor: passwordFactor }), + }); + factorActor.send({ type: 'SHOW_HELP' }); + expect(factorActor.getSnapshot().value).toBe('helpFromFactor'); + factorActor.send({ type: 'BACK' }); + expect(factorActor.getSnapshot().value).toBe('verifying'); + }); + it('returns from alternatives without preparing the unchanged factor again', async () => { const prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined); const { actor } = start({ - challenge: firstFactorChallenge({ initialFactorId: emailFactor.id }), + challenge: firstFactorChallenge({ initialFactor: emailFactor }), prepare, }); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); @@ -266,16 +322,16 @@ describe('reverificationDialogMachine', () => { it('prepares a code factor again after it was replaced and selected again', async () => { const prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined); const { actor } = start({ - challenge: firstFactorChallenge({ initialFactorId: emailFactor.id }), + challenge: firstFactorChallenge({ initialFactor: emailFactor }), prepare, }); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); actor.send({ type: 'SHOW_ALTERNATIVES' }); - actor.send({ type: 'SELECT_FACTOR', factorId: phoneFactor.id }); + actor.send({ type: 'SELECT_FACTOR', factorKey: reverificationFactorKey(phoneFactor) }); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); actor.send({ type: 'SHOW_ALTERNATIVES' }); - actor.send({ type: 'SELECT_FACTOR', factorId: emailFactor.id }); + actor.send({ type: 'SELECT_FACTOR', factorKey: reverificationFactorKey(emailFactor) }); await vi.waitFor(() => expect(prepare).toHaveBeenCalledTimes(3)); expect(prepare).toHaveBeenNthCalledWith(1, emailFactor); @@ -289,7 +345,7 @@ describe('reverificationDialogMachine', () => { .mockRejectedValueOnce(new Error('Could not send the code.')) .mockResolvedValue(undefined); const { actor } = start({ - challenge: firstFactorChallenge({ initialFactorId: emailFactor.id }), + challenge: firstFactorChallenge({ initialFactor: emailFactor }), prepare, }); @@ -303,7 +359,7 @@ describe('reverificationDialogMachine', () => { await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('preparationFailed')); expect(actor.getSnapshot().context).toMatchObject({ currentFactor: emailFactor, - error: { location: 'form', message: 'Could not send the code.' }, + error: { scope: 'flow', message: 'Could not send the code.' }, resendSecondsRemaining: 0, }); expect(actor.can({ type: 'RESEND' })).toBe(true); @@ -317,7 +373,7 @@ describe('reverificationDialogMachine', () => { it('returns verification failures to the field and clears them on input', async () => { const attempt = vi .fn<(attempt: ReverificationAttempt) => Promise>() - .mockRejectedValue(new Error('Incorrect password.')); + .mockRejectedValue({ scope: 'answer', message: 'Incorrect password.' }); const { actor } = start({ attempt }); actor.send({ type: 'CHANGE_VALUE', value: 'wrong' }); @@ -326,7 +382,7 @@ describe('reverificationDialogMachine', () => { expect(actor.getSnapshot().context).toMatchObject({ value: '', - error: { location: 'field', message: 'Incorrect password.' }, + error: { scope: 'answer', message: 'Incorrect password.' }, }); actor.send({ type: 'CHANGE_VALUE', value: 'new value' }); expect(actor.getSnapshot().context.error).toBeNull(); @@ -336,7 +392,7 @@ describe('reverificationDialogMachine', () => { vi.useFakeTimers(); const prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined); const { actor } = start({ - challenge: firstFactorChallenge({ initialFactorId: emailFactor.id }), + challenge: firstFactorChallenge({ initialFactor: emailFactor }), prepare, }); await vi.runAllTicks(); @@ -368,7 +424,7 @@ describe('reverificationDialogMachine', () => { .mockRejectedValueOnce(new Error('Rate limited.')) .mockResolvedValue(undefined); const { actor } = start({ - challenge: firstFactorChallenge({ initialFactorId: emailFactor.id }), + challenge: firstFactorChallenge({ initialFactor: emailFactor }), prepare, }); await vi.runAllTicks(); @@ -380,7 +436,7 @@ describe('reverificationDialogMachine', () => { value: 'verifying', context: { resendSecondsRemaining: 0, - error: { location: 'form', message: 'Rate limited.' }, + error: { scope: 'flow', message: 'Rate limited.' }, }, }); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts index 8127b2cb2de..66291339ad5 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts @@ -5,20 +5,12 @@ import type { ReverificationAttempt, ReverificationAttemptResult, ReverificationChallenge, + ReverificationCompleteResult, + ReverificationError, ReverificationFactor, ReverificationPreparationFactor, } from './reverification-dialog.types'; -/** - * A failed attempt, and where the message belongs. `location` is the machine deciding a - * rendering question, which is why it is keyed off the strategy rather than off the error — - * legacy's `handleError` read the error's own shape to choose between the field and the card. - */ -export interface ReverificationDialogError { - location: 'field' | 'form'; - message: string; -} - const RESEND_COOLDOWN_SECONDS = 30; const emptyChallenge: ReverificationChallenge = { @@ -26,24 +18,30 @@ const emptyChallenge: ReverificationChallenge = { factors: [], }; -type ReverificationDialogReturnState = 'selectingFactor' | 'routingFactor'; - export interface ReverificationDialogMachineContext { + /** The challenge injected by the view and captured when the actor starts. */ initialChallenge: ReverificationChallenge; + /** The active challenge, replaced when first-factor verification requires a second factor. */ challenge: ReverificationChallenge; + /** The factor currently being prepared or verified. */ currentFactor: ReverificationFactor | null; + /** The answer entered for the current factor. */ value: string; - error: ReverificationDialogError | null; - preparedFactorId: string | null; + /** Why the last operation failed and whether it belongs to the answer or the flow. */ + error: ReverificationError | null; + /** The delivered-code factor most recently prepared. */ + preparedFactorKey: string | null; + /** The successful verification retained while completion runs or retries. */ + verification: ReverificationCompleteResult | null; + /** Seconds until another delivered code may be requested. */ resendSecondsRemaining: number; - returnState: ReverificationDialogReturnState; + /** Sends a code. Injected by the view from its `prepare` prop. */ prepare: (factor: ReverificationPreparationFactor) => Promise; + /** Checks an answer. Injected by the view from its `attempt` prop. */ attempt: (attempt: ReverificationAttempt) => Promise; - /** - * The verification landed. Awaited, so the dialog stays up while the caller activates the - * session — legacy did the same before handing back to whatever asked for reverification. - */ - complete: () => Promise; + /** Activates the verified session. Injected by the view from its `onComplete` prop. */ + complete: (result: ReverificationCompleteResult) => Promise; + /** Reports cancellation. Injected by the view from its `onCancel` prop. */ cancel: () => void; } @@ -52,10 +50,11 @@ export type ReverificationDialogMachineEvent = | { type: 'SUBMIT' } | { type: 'RESEND' } | { type: 'CANCEL' } - | { type: 'SELECT_FACTOR'; factorId: string } + | { type: 'SELECT_FACTOR'; factorKey: string } | { type: 'SHOW_ALTERNATIVES' } | { type: 'SHOW_HELP' } - | { type: 'BACK' }; + | { type: 'BACK' } + | { type: 'RETRY_COMPLETE' }; const { createMachine, assign, fromPromise } = setup< ReverificationDialogMachineContext, @@ -64,16 +63,41 @@ const { createMachine, assign, fromPromise } = setup< const factorsFrom = (context: ReverificationDialogMachineContext): ReverificationFactor[] => context.challenge.factors; -const factorFrom = (context: ReverificationDialogMachineContext, factorId: string) => - factorsFrom(context).find(factor => factor.id === factorId); +export const reverificationFactorKey = (factor: ReverificationFactor): string => { + switch (factor.strategy) { + case 'email_code': + return `email_code:${factor.emailAddressId}`; + case 'phone_code': + return `phone_code:${factor.phoneNumberId}`; + default: + return factor.strategy; + } +}; -const initialFactorFrom = (challenge: ReverificationChallenge): ReverificationFactor | null => - challenge.initialFactorId - ? (challenge.factors.find(factor => factor.id === challenge.initialFactorId) ?? null) - : null; +const assertValidChallenge = (challenge: ReverificationChallenge) => { + const keys = challenge.factors.map(reverificationFactorKey); + if (new Set(keys).size !== keys.length) { + throw new Error('Reverification factors must have unique identities.'); + } +}; + +const factorFrom = (context: ReverificationDialogMachineContext, factorKey: string) => + factorsFrom(context).find(factor => reverificationFactorKey(factor) === factorKey); + +const initialFactorFrom = (challenge: ReverificationChallenge): ReverificationFactor | null => { + const initialFactor = challenge.initialFactor; + if (!initialFactor) { + return null; + } + const initialFactorKey = reverificationFactorKey(initialFactor); + return challenge.factors.find(factor => reverificationFactorKey(factor) === initialFactorKey) ?? null; +}; const alternativesFrom = (context: ReverificationDialogMachineContext) => - factorsFrom(context).filter(factor => factor.id !== context.currentFactor?.id); + factorsFrom(context).filter( + factor => + !context.currentFactor || reverificationFactorKey(factor) !== reverificationFactorKey(context.currentFactor), + ); const hasAlternatives = (context: ReverificationDialogMachineContext) => alternativesFrom(context).length > 0; @@ -114,13 +138,19 @@ const attemptFrom = (context: ReverificationDialogMachineContext): Reverificatio return { factor, code: context.value }; }; -const errorFrom = (error: unknown, location: ReverificationDialogError['location']): ReverificationDialogError => ({ - location, - message: error instanceof Error ? error.message : m.unstable__errors__generic, -}); - -const attemptErrorLocation = (context: ReverificationDialogMachineContext): ReverificationDialogError['location'] => - context.currentFactor?.strategy === 'passkey' ? 'form' : 'field'; +const errorFrom = (error: unknown): ReverificationError => { + if ( + typeof error === 'object' && + error !== null && + 'scope' in error && + (error.scope === 'answer' || error.scope === 'flow') && + 'message' in error && + typeof error.message === 'string' + ) { + return { scope: error.scope, message: error.message }; + } + return { scope: 'flow', message: error instanceof Error ? error.message : m.unstable__errors__generic }; +}; const changeValue = ({ context, @@ -145,28 +175,34 @@ export const reverificationDialogMachine = createMachine({ currentFactor: null, value: '', error: null, - preparedFactorId: null, + preparedFactorKey: null, + verification: null, resendSecondsRemaining: 0, - returnState: 'selectingFactor', prepare: () => Promise.resolve(), - attempt: () => Promise.resolve({ status: 'complete' }), + attempt: () => Promise.resolve({ status: 'complete', sessionId: '' }), complete: () => Promise.resolve(), cancel: () => {}, }, states: { initializing: { - entry: assign(context => ({ challenge: context.initialChallenge })), + entry: assign(context => { + assertValidChallenge(context.initialChallenge); + return { challenge: context.initialChallenge }; + }), always: 'starting', }, starting: { - entry: assign(context => ({ - currentFactor: initialFactorFrom(context.challenge), - value: '', - error: null, - preparedFactorId: null, - resendSecondsRemaining: 0, - returnState: 'selectingFactor', - })), + entry: assign(context => { + assertValidChallenge(context.challenge); + return { + currentFactor: initialFactorFrom(context.challenge), + value: '', + error: null, + preparedFactorKey: null, + verification: null, + resendSecondsRemaining: 0, + }; + }), always: [ { target: 'unavailable', guard: context => factorsFrom(context).length === 0 }, { target: 'routingFactor', guard: context => Boolean(context.currentFactor) }, @@ -177,12 +213,12 @@ export const reverificationDialogMachine = createMachine({ on: { SELECT_FACTOR: { target: 'routingFactor', - guard: (context, event) => Boolean(factorFrom(context, event.factorId)), + guard: (context, event) => Boolean(factorFrom(context, event.factorKey)), actions: assign((context, event) => ({ - currentFactor: factorFrom(context, event.factorId) ?? context.currentFactor, + currentFactor: factorFrom(context, event.factorKey) ?? context.currentFactor, value: '', error: null, - preparedFactorId: null, + preparedFactorKey: null, resendSecondsRemaining: 0, })), }, @@ -191,8 +227,7 @@ export const reverificationDialogMachine = createMachine({ guard: context => Boolean(context.currentFactor), }, SHOW_HELP: { - target: 'help', - actions: assign(() => ({ returnState: 'selectingFactor' })), + target: 'helpFromSelection', }, CANCEL: 'cancelled', }, @@ -203,7 +238,8 @@ export const reverificationDialogMachine = createMachine({ { target: 'preparing', guard: context => - requiresPreparation(context.currentFactor) && context.preparedFactorId !== context.currentFactor.id, + requiresPreparation(context.currentFactor) && + context.preparedFactorKey !== reverificationFactorKey(context.currentFactor), }, { target: 'verifyingCooldown', @@ -224,14 +260,14 @@ export const reverificationDialogMachine = createMachine({ onDone: { target: 'verifyingCooldown', actions: assign(context => ({ - preparedFactorId: context.currentFactor?.id ?? null, + preparedFactorKey: context.currentFactor ? reverificationFactorKey(context.currentFactor) : null, resendSecondsRemaining: RESEND_COOLDOWN_SECONDS, error: null, })), }, onError: { target: 'preparationFailed', - actions: assign((_, event) => ({ error: errorFrom(event.error, 'form') })), + actions: assign((_, event) => ({ error: errorFrom(event.error) })), }, }, ), @@ -257,9 +293,8 @@ export const reverificationDialogMachine = createMachine({ RESEND: { target: 'resending', guard: context => requiresPreparation(context.currentFactor) }, SHOW_ALTERNATIVES: { target: 'selectingFactor', guard: hasAlternatives }, SHOW_HELP: { - target: 'help', + target: 'helpFromFactor', guard: context => context.currentFactor?.strategy === 'password' && !hasAlternatives(context), - actions: assign(() => ({ returnState: 'routingFactor' })), }, CANCEL: 'cancelled', }, @@ -270,9 +305,8 @@ export const reverificationDialogMachine = createMachine({ SUBMIT: { target: 'submitting', guard: canSubmit }, SHOW_ALTERNATIVES: { target: 'selectingFactor', guard: hasAlternatives }, SHOW_HELP: { - target: 'help', + target: 'helpFromFactor', guard: context => context.currentFactor?.strategy === 'password' && !hasAlternatives(context), - actions: assign(() => ({ returnState: 'routingFactor' })), }, CANCEL: 'cancelled', }, @@ -298,6 +332,10 @@ export const reverificationDialogMachine = createMachine({ { target: 'completing', guard: (_, event) => event.output.status === 'complete', + actions: assign((_, event) => ({ + verification: event.output.status === 'complete' ? event.output : null, + error: null, + })), }, { target: 'starting', @@ -310,7 +348,7 @@ export const reverificationDialogMachine = createMachine({ challenge: { status: 'needs_second_factor', factors: event.output.factors, - initialFactorId: event.output.initialFactorId, + initialFactor: event.output.initialFactor, }, }; }), @@ -320,7 +358,7 @@ export const reverificationDialogMachine = createMachine({ target: context.resendSecondsRemaining > 0 ? 'verifyingCooldown' : 'verifying', context: { value: '', - error: errorFrom(event.error, attemptErrorLocation(context)), + error: errorFrom(event.error), }, }), }), @@ -338,7 +376,7 @@ export const reverificationDialogMachine = createMachine({ onDone: { target: 'verifyingCooldown', actions: assign(context => ({ - preparedFactorId: context.currentFactor?.id ?? null, + preparedFactorKey: context.currentFactor ? reverificationFactorKey(context.currentFactor) : null, resendSecondsRemaining: RESEND_COOLDOWN_SECONDS, error: null, })), @@ -347,7 +385,7 @@ export const reverificationDialogMachine = createMachine({ target: 'verifying', actions: assign((_, event) => ({ resendSecondsRemaining: 0, - error: errorFrom(event.error, 'form'), + error: errorFrom(event.error), })), }, }, @@ -357,26 +395,41 @@ export const reverificationDialogMachine = createMachine({ CANCEL: 'cancelled', }, }, - help: { + helpFromSelection: { on: { - BACK: ({ context }) => ({ target: context.returnState }), + BACK: 'selectingFactor', + CANCEL: 'cancelled', + }, + }, + helpFromFactor: { + on: { + BACK: 'routingFactor', CANCEL: 'cancelled', }, }, unavailable: { on: { CANCEL: 'cancelled' } }, completing: { - invoke: fromPromise(context => context.complete(), { - onDone: 'completed', - // The code was right but the session did not activate. Legacy surfaced that on the card - // the user was already looking at, so the flow returns there rather than closing. - onError: ({ context, event }) => ({ - target: context.resendSecondsRemaining > 0 ? 'verifyingCooldown' : 'verifying', - context: { - value: '', - error: errorFrom(event.error, attemptErrorLocation(context)), + invoke: fromPromise( + context => { + if (!context.verification) { + return Promise.reject(new Error(m.unstable__errors__generic)); + } + return context.complete(context.verification); + }, + { + onDone: 'completed', + onError: { + target: 'completionFailed', + actions: assign((_, event) => ({ error: errorFrom(event.error) })), }, - }), - }), + }, + ), + }, + completionFailed: { + on: { + RETRY_COMPLETE: 'completing', + CANCEL: 'cancelled', + }, }, completed: { type: 'final' }, cancelled: { diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts index 10db158f1bd..1f0ce3bd0fb 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts @@ -33,6 +33,11 @@ export const reverificationDialogBase = { subtitle: 'Enter the backup code you received when setting up two-step authentication', title: 'Enter a backup code', }, + completionFailed: { + message: 'Your identity was verified, but we couldn’t finish setting up your session.', + retryButton: 'Try again', + title: 'Couldn’t complete verification', + }, emailCode: { formTitle: 'Verification code', resendButton: 'Didn’t receive a code? Resend', diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx index bd47aa568f1..a1d38a7901e 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx @@ -13,6 +13,7 @@ import { ReverificationDialog } from './reverification-dialog'; const base = { open: true as const, onOpenChange: vi.fn(), + dismissible: true, closeLabel: 'Close', }; @@ -70,6 +71,17 @@ describe('ReverificationDialog', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); + it('removes every close request while the caller says it is not dismissible', async () => { + const onOpenChange = vi.fn(); + renderBlock(verifyProps({ dismissible: false, onOpenChange })); + + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + + await userEvent.setup().keyboard('{Escape}'); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + it('hands back the id of the chosen method', async () => { const onSelectMethod = vi.fn(); renderBlock(chooseProps({ onSelectMethod })); @@ -209,7 +221,7 @@ describe('ReverificationDialog', () => { rerender( - + , ); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx index 43165777733..8d9a092a132 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx @@ -44,6 +44,8 @@ interface ReverificationDialogBaseProps { open: boolean; /** Callback when open state changes */ onOpenChange: (open: boolean) => void; + /** Whether close requests and explicit close controls may dismiss the dialog. */ + dismissible: boolean; /** Dialog heading */ title: string; /** What is being asked of the user */ @@ -89,8 +91,8 @@ export interface ReverificationDialogMessageProps extends ReverificationDialogBa step: 'message'; /** The way forward from a dead end — reaching a human. */ action: ReverificationDialogAction; - /** Returns where the user came from. Absent where there is nothing to go back to. */ - back?: ReverificationDialogAction; + /** An optional secondary action, such as returning or cancelling. */ + secondary?: ReverificationDialogAction; } export type ReverificationDialogProps = @@ -133,6 +135,7 @@ function CodeSlots({ baseId, invalid }: { baseId: string; invalid: boolean }) { * step='verify' * open={snapshot.value !== 'cancelled'} * onOpenChange={open => !open && send({ type: 'CANCEL' })} + * dismissible={snapshot.can({ type: 'CANCEL' })} * title='Verification required' * description='Enter the code sent to your email to continue' * closeLabel='Close' @@ -146,12 +149,12 @@ function CodeSlots({ baseId, invalid }: { baseId: string; invalid: boolean }) { * /> */ export function ReverificationDialog(props: ReverificationDialogProps) { - const { open, onOpenChange, title, description, closeLabel, error } = props; + const { open, onOpenChange, dismissible, title, description, closeLabel, error } = props; return ( @@ -166,7 +169,7 @@ export function ReverificationDialog(props: ReverificationDialogProps) { /> } > - + {dismissible ? : null} }>{title} }>{description} @@ -200,7 +203,14 @@ function StepContent(props: ReverificationDialogProps) { } } -function ChooseStep({ methods, onSelectMethod, back, cancelLabel, help }: ReverificationDialogChooseProps) { +function ChooseStep({ + methods, + onSelectMethod, + back, + cancelLabel, + help, + dismissible, +}: ReverificationDialogChooseProps) { return ( <> @@ -230,6 +240,7 @@ function ChooseStep({ methods, onSelectMethod, back, cancelLabel, help }: Reveri render={ - {back ? ( + {secondary ? ( ) : null} diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts index c908d4d0a19..b2341c0a252 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts @@ -1,44 +1,40 @@ -interface ReverificationFactorBase { - id: string; -} - -export interface ReverificationPasswordFactor extends ReverificationFactorBase { +export interface ReverificationPasswordFactor { stage: 'first'; strategy: 'password'; } -export interface ReverificationEmailCodeFactor extends ReverificationFactorBase { +export interface ReverificationEmailCodeFactor { stage: 'first'; strategy: 'email_code'; emailAddressId: string; safeIdentifier: string; } -export interface ReverificationFirstFactorPhoneCodeFactor extends ReverificationFactorBase { +export interface ReverificationFirstFactorPhoneCodeFactor { stage: 'first'; strategy: 'phone_code'; phoneNumberId: string; safeIdentifier: string; } -export interface ReverificationPasskeyFactor extends ReverificationFactorBase { +export interface ReverificationPasskeyFactor { stage: 'first'; strategy: 'passkey'; } -export interface ReverificationSecondFactorPhoneCodeFactor extends ReverificationFactorBase { +export interface ReverificationSecondFactorPhoneCodeFactor { stage: 'second'; strategy: 'phone_code'; phoneNumberId: string; safeIdentifier: string; } -export interface ReverificationTOTPFactor extends ReverificationFactorBase { +export interface ReverificationTOTPFactor { stage: 'second'; strategy: 'totp'; } -export interface ReverificationBackupCodeFactor extends ReverificationFactorBase { +export interface ReverificationBackupCodeFactor { stage: 'second'; strategy: 'backup_code'; } @@ -60,12 +56,12 @@ export type ReverificationChallenge = | { status: 'needs_first_factor'; factors: ReverificationFirstFactor[]; - initialFactorId?: string; + initialFactor?: ReverificationFirstFactor; } | { status: 'needs_second_factor'; factors: ReverificationSecondFactor[]; - initialFactorId?: string; + initialFactor?: ReverificationSecondFactor; }; export type ReverificationPreparationFactor = Extract; @@ -78,10 +74,21 @@ export type ReverificationAttempt = } | { factor: ReverificationPasskeyFactor }; +export interface ReverificationCompleteResult { + status: 'complete'; + sessionId: string; +} + +export interface ReverificationError { + /** Whether the failure belongs to the submitted answer or to the flow as a whole. */ + scope: 'answer' | 'flow'; + message: string; +} + export type ReverificationAttemptResult = - | { status: 'complete' } + | ReverificationCompleteResult | { status: 'needs_second_factor'; factors: ReverificationSecondFactor[]; - initialFactorId?: string; + initialFactor?: ReverificationSecondFactor; }; diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx index 4532838c1f8..ba0df7e0e0e 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx @@ -7,6 +7,7 @@ import type { ReverificationAttempt, ReverificationAttemptResult, ReverificationChallenge, + ReverificationCompleteResult, ReverificationEmailCodeFactor, ReverificationPasskeyFactor, ReverificationPasswordFactor, @@ -15,13 +16,11 @@ import type { import { ReverificationDialogView } from './reverification-dialog.view'; const passwordFactor: ReverificationPasswordFactor = { - id: 'password', stage: 'first', strategy: 'password', }; const emailFactor: ReverificationEmailCodeFactor = { - id: 'email_1', stage: 'first', strategy: 'email_code', emailAddressId: 'email_1', @@ -29,29 +28,28 @@ const emailFactor: ReverificationEmailCodeFactor = { }; const passkeyFactor: ReverificationPasskeyFactor = { - id: 'passkey', stage: 'first', strategy: 'passkey', }; function renderView({ - challenge = { + initialChallenge = { status: 'needs_first_factor', factors: [passwordFactor, emailFactor], - initialFactorId: passwordFactor.id, + initialFactor: passwordFactor, } as ReverificationChallenge, prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined), attempt = vi .fn<(attempt: ReverificationAttempt) => Promise>() - .mockResolvedValue({ status: 'complete' }), - onComplete = vi.fn<() => Promise>().mockResolvedValue(undefined), + .mockResolvedValue({ status: 'complete', sessionId: 'sess_1' }), + onComplete = vi.fn<(result: ReverificationCompleteResult) => Promise>().mockResolvedValue(undefined), onCancel = vi.fn(), supportEmail = 'support@clerk.dev', } = {}) { render( { await user.click(screen.getByRole('button', { name: 'Continue' })); await waitFor(() => expect(attempt).toHaveBeenCalledWith({ factor: passwordFactor, password: 'secret' })); - expect(onComplete).toHaveBeenCalledOnce(); + expect(onComplete).toHaveBeenCalledWith({ status: 'complete', sessionId: 'sess_1' }); }); it('sends the code behind the code step and submits six digits without a press', async () => { const { prepare, attempt } = renderView({ - challenge: { + initialChallenge: { status: 'needs_first_factor', factors: [passwordFactor, emailFactor], - initialFactorId: emailFactor.id, + initialFactor: emailFactor, }, }); @@ -107,10 +105,10 @@ describe('ReverificationDialogView', () => { }), ); renderView({ - challenge: { + initialChallenge: { status: 'needs_first_factor', factors: [passwordFactor, emailFactor], - initialFactorId: emailFactor.id, + initialFactor: emailFactor, }, prepare, }); @@ -149,10 +147,10 @@ describe('ReverificationDialogView', () => { .mockRejectedValueOnce(new Error('Could not send the code.')) .mockResolvedValue(undefined); renderView({ - challenge: { + initialChallenge: { status: 'needs_first_factor', factors: [passwordFactor, emailFactor], - initialFactorId: emailFactor.id, + initialFactor: emailFactor, }, prepare, }); @@ -167,10 +165,10 @@ describe('ReverificationDialogView', () => { it('counts the resend cooldown down in the label and holds the button inert', async () => { renderView({ - challenge: { + initialChallenge: { status: 'needs_first_factor', factors: [passwordFactor, emailFactor], - initialFactorId: emailFactor.id, + initialFactor: emailFactor, }, }); @@ -180,7 +178,7 @@ describe('ReverificationDialogView', () => { it('renders a passkey as an action with nothing to type', async () => { renderView({ - challenge: { status: 'needs_first_factor', factors: [passkeyFactor], initialFactorId: passkeyFactor.id }, + initialChallenge: { status: 'needs_first_factor', factors: [passkeyFactor], initialFactor: passkeyFactor }, }); expect(await screen.findByRole('button', { name: 'Use your passkey' })).toBeEnabled(); @@ -189,7 +187,7 @@ describe('ReverificationDialogView', () => { it('offers help instead of alternatives when there is only one method', async () => { renderView({ - challenge: { status: 'needs_first_factor', factors: [passwordFactor], initialFactorId: passwordFactor.id }, + initialChallenge: { status: 'needs_first_factor', factors: [passwordFactor], initialFactor: passwordFactor }, }); const user = userEvent.setup(); @@ -203,7 +201,7 @@ describe('ReverificationDialogView', () => { it('sends a stuck user to support, the only thing left that can help them', async () => { const location = { href: '' }; Object.defineProperty(window, 'location', { value: location, writable: true }); - renderView({ challenge: { status: 'needs_first_factor', factors: [] } }); + renderView({ initialChallenge: { status: 'needs_first_factor', factors: [] } }); await userEvent.setup().click(await screen.findByRole('button', { name: 'Email support' })); @@ -211,7 +209,7 @@ describe('ReverificationDialogView', () => { }); it('leaves no way back from a dead end with no method to go back to', async () => { - renderView({ challenge: { status: 'needs_first_factor', factors: [] } }); + renderView({ initialChallenge: { status: 'needs_first_factor', factors: [] } }); expect(await screen.findByRole('button', { name: 'Email support' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); @@ -219,12 +217,12 @@ describe('ReverificationDialogView', () => { it('holds the dialog open and pending while the caller completes', async () => { let finish = () => {}; - const onComplete = vi.fn<() => Promise>().mockReturnValue( + const onComplete = vi.fn<(result: ReverificationCompleteResult) => Promise>().mockReturnValue( new Promise(resolve => { finish = resolve; }), ); - renderView({ onComplete }); + const { onCancel } = renderView({ onComplete }); const user = userEvent.setup(); await user.type(await screen.findByLabelText('Password'), 'secret'); @@ -233,13 +231,41 @@ describe('ReverificationDialogView', () => { await waitFor(() => expect(onComplete).toHaveBeenCalled()); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Continue' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(onCancel).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); finish(); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); + it('retries only completion after verification has succeeded', async () => { + const onComplete = vi + .fn<(result: ReverificationCompleteResult) => Promise>() + .mockRejectedValueOnce(new Error('Could not activate the session.')) + .mockResolvedValue(undefined); + const { attempt, onCancel } = renderView({ onComplete }); + const user = userEvent.setup(); + + await user.type(await screen.findByLabelText('Password'), 'secret'); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + expect(await screen.findByText('Couldn’t complete verification')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Could not activate the session.'); + expect(screen.getByRole('button', { name: 'Close' })).toBeEnabled(); + + await user.click(screen.getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + expect(attempt).toHaveBeenCalledOnce(); + expect(onComplete).toHaveBeenCalledTimes(2); + expect(onCancel).not.toHaveBeenCalled(); + }); + it('says so when the account has no method to offer', async () => { - renderView({ challenge: { status: 'needs_first_factor', factors: [] } }); + renderView({ initialChallenge: { status: 'needs_first_factor', factors: [] } }); expect(await screen.findByText('Cannot verify your account')).toBeInTheDocument(); }); @@ -247,7 +273,7 @@ describe('ReverificationDialogView', () => { it('reports the failure against the field and lets the user try again', async () => { const attempt = vi .fn<(attempt: ReverificationAttempt) => Promise>() - .mockRejectedValue(new Error('Incorrect password.')); + .mockRejectedValue({ scope: 'answer', message: 'Incorrect password.' }); renderView({ attempt }); const user = userEvent.setup(); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx index 9be875a6e71..81e39d93dc9 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx @@ -2,28 +2,29 @@ import { useMachine } from '../../machine/useMachine'; import type { ReverificationDialogMethod, ReverificationDialogProps } from './reverification-dialog'; import { ReverificationDialog } from './reverification-dialog'; import type { ReverificationDialogMachineContext } from './reverification-dialog.machine'; -import { reverificationDialogMachine } from './reverification-dialog.machine'; +import { reverificationDialogMachine, reverificationFactorKey } from './reverification-dialog.machine'; import { fill, reverificationDialogBase as m } from './reverification-dialog.messages'; import type { ReverificationAttempt, ReverificationAttemptResult, ReverificationChallenge, + ReverificationCompleteResult, ReverificationFactor, ReverificationPreparationFactor, } from './reverification-dialog.types'; export interface ReverificationDialogViewProps { - /** The methods this attempt may use, and which one to open on. */ - challenge: ReverificationChallenge; + /** The methods this run may use, captured when the machine starts. */ + initialChallenge: ReverificationChallenge; /** Sends a code for a method that delivers one. Reject to keep the user on the code step. */ prepare: (factor: ReverificationPreparationFactor) => Promise; - /** Submits the user's answer. Resolving `needs_second_factor` moves the flow on to 2FA. */ + /** Submits the user's answer. Reject with `ReverificationError` to place the message semantically. */ attempt: (attempt: ReverificationAttempt) => Promise; /** * The user proved who they are. Awaited: the dialog stays up, pending, until this resolves, * so a caller can activate the session before the flow hands back. */ - onComplete: () => Promise; + onComplete: (result: ReverificationCompleteResult) => Promise; /** The user gave up, or closed the dialog. */ onCancel: () => void; /** Address behind the support action on a dead end. From Clerk's `useSupportEmail`. */ @@ -78,12 +79,15 @@ function methodLabel(factor: ReverificationFactor): string { } const asMethod = (factor: ReverificationFactor): ReverificationDialogMethod => ({ - id: factor.id, + id: reverificationFactorKey(factor), label: methodLabel(factor), }); const alternativesTo = (context: ReverificationDialogMachineContext) => - context.challenge.factors.filter(factor => factor.id !== context.currentFactor?.id); + context.challenge.factors.filter( + factor => + !context.currentFactor || reverificationFactorKey(factor) !== reverificationFactorKey(context.currentFactor), + ); /** * Drives {@link ReverificationDialog} with {@link reverificationDialogMachine}. @@ -93,7 +97,7 @@ const alternativesTo = (context: ReverificationDialogMachineContext) => * as `prepare` and `attempt`, so the whole flow runs against plain promises in a test or a story. */ export function ReverificationDialogView({ - challenge, + initialChallenge, prepare, attempt, onComplete, @@ -101,7 +105,7 @@ export function ReverificationDialogView({ supportEmail, }: ReverificationDialogViewProps) { const [snapshot, send, actor] = useMachine(reverificationDialogMachine, { - context: { initialChallenge: challenge, prepare, attempt, complete: onComplete, cancel: onCancel }, + context: { initialChallenge, prepare, attempt, complete: onComplete, cancel: onCancel }, }); const { context } = snapshot; @@ -120,15 +124,17 @@ export function ReverificationDialogView({ }, }; + const canCancel = actor.can({ type: 'CANCEL' }); const base = { open: snapshot.status === 'active', + dismissible: canCancel, onOpenChange: (open: boolean) => { if (!open) { send({ type: 'CANCEL' }); } }, closeLabel: m.closeButton, - error: context.error?.location === 'form' ? context.error.message : undefined, + error: context.error?.scope === 'flow' ? context.error.message : undefined, }; const props = ((): ReverificationDialogProps => { @@ -143,14 +149,14 @@ export function ReverificationDialogView({ }; } - if (snapshot.value === 'help') { + if (snapshot.value === 'helpFromSelection' || snapshot.value === 'helpFromFactor') { return { ...base, step: 'message', title: m.alternativeMethods.getHelp.title, description: m.alternativeMethods.getHelp.content, action: emailSupport, - back: { label: m.backButton, onClick: () => send({ type: 'BACK' }) }, + secondary: { label: m.backButton, onClick: () => send({ type: 'BACK' }) }, }; } @@ -162,13 +168,24 @@ export function ReverificationDialogView({ title: m.alternativeMethods.title, description: m.alternativeMethods.subtitle, methods: methods.map(asMethod), - onSelectMethod: factorId => send({ type: 'SELECT_FACTOR', factorId }), + onSelectMethod: factorKey => send({ type: 'SELECT_FACTOR', factorKey }), back: actor.can({ type: 'BACK' }) ? { label: m.backButton, onClick: () => send({ type: 'BACK' }) } : undefined, cancelLabel: m.formButtonReset, help: { label: m.alternativeMethods.actionLink, onClick: () => send({ type: 'SHOW_HELP' }) }, }; } + if (snapshot.value === 'completionFailed') { + return { + ...base, + step: 'message', + title: m.completionFailed.title, + description: m.completionFailed.message, + action: { label: m.completionFailed.retryButton, onClick: () => send({ type: 'RETRY_COMPLETE' }) }, + secondary: { label: m.formButtonReset, onClick: () => send({ type: 'CANCEL' }) }, + }; + } + // Every remaining state is the flow working on the current method, so the step stays // mounted while a code is sent or an answer is checked. const factor = context.currentFactor; @@ -199,7 +216,7 @@ export function ReverificationDialogView({ ...copy.field, value: context.value, disabled: !isEditable, - error: context.error?.location === 'field' ? context.error.message : undefined, + error: context.error?.scope === 'answer' ? context.error.message : undefined, onChange: value => send({ type: 'CHANGE_VALUE', value }), } : undefined, From 750d89b9234cb64b8b7183fd127a6cd98316b1dc Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 26 Aug 2026 09:48:46 -0600 Subject: [PATCH 06/12] docs(ui): document legacy reverification architecture --- references/reverification-architecture.md | 201 ++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 references/reverification-architecture.md diff --git a/references/reverification-architecture.md b/references/reverification-architecture.md new file mode 100644 index 00000000000..10065ffef8a --- /dev/null +++ b/references/reverification-architecture.md @@ -0,0 +1,201 @@ +# Legacy reverification flow and Mosaic migration review + +This document records the legacy reverification behavior that the Mosaic replacement must either preserve or +deliberately change. It also records the architecture used by the current Mosaic work. + +The legacy implementation is under `packages/ui/src/components/UserVerification/`. The Mosaic implementation is one +block module under `packages/ui/src/mosaic/blocks/reverification-dialog/`. + +## Architecture decision + +For this work, the established implementation pattern is: + +```text +integration caller + supplies plain data and async operations + | + v +actor-owning view + creates the machine actor, renders snapshots, emits events + | + +----------> pure machine + | + v +controlled renderer + renders props; owns only transient state no outer layer can use +``` + +This is the pattern implemented by `origin/carp/mosaic-user-profile-delete-account`: + +- `UserProfileDeleteSectionView` accepts `onDelete`, calls `useMachine`, derives block props from the snapshot, and + sends events. +- `userProfileDeleteSectionMachine` owns the flow and invokes the injected delete operation. +- `Destructive` is a controlled block. It owns only the half-typed confirmation phrase because nothing outside the + block can use it. + +The reverification implementation follows the same shape: + +- `ReverificationDialogView` accepts an initial challenge plus `prepare`, `attempt`, `onComplete`, and `onCancel`; it + owns the actor and derives `actor.can(...)` values. +- `reverificationDialogMachine` owns factor selection, preparation, submission, resend, help, completion, and + cancellation transitions. +- `ReverificationDialog` is the block's controlled, stateless renderer. The answer belongs to the machine because + guards and attempts use it. + +The machine, actor-owning view, renderer, messages, and shared vocabulary are internal roles of one cohesive block +module. They are colocated behind one `index.ts`; a future controller belongs in the same directory. + +No separate controller is required to match that precedent. Before the flow becomes reachable, it will still need a +production integration wrapper that translates Clerk resources into the plain interface above. That wrapper is an +adapter, regardless of whether the codebase calls it a controller. + +This convention conflicts with the current `references/mosaic-architecture.md`, which describes a controller owning +the actor and a view receiving a fake snapshot and `send`. The implementation and its tests should be reviewed against +one convention consistently. Under the convention selected here, asking the view to own the actor is intentional. + +## Legacy end-to-end lifecycle + +The dialog is only one part of reverification. The full legacy lifecycle is: + +1. `useReverification(fetcher)` calls the protected operation. +2. A `session_reverification_required` result opens the internal reverification modal with a required level and two + callbacks. +3. Closing the modal calls `afterVerificationCancelled`, rejects the protected operation with + `reverification_cancelled`, and does not retry it. +4. The UI calls `session.startVerification({ level })`. An absent level defaults to `second_factor`. The request is + cached by level and the cache is invalidated when the flow unmounts. +5. The returned `SessionVerificationResource.status` selects first-factor or second-factor UI. +6. The chosen method is prepared when necessary and attempted. +7. `needs_second_factor` updates the cached verification resource and routes to the second-factor step. +8. `complete` updates the cache, awaits `clerk.setActive({ session: response.session.id })`, then calls + `afterVerification`. The modal closes without firing cancellation, and `useReverification` retries the original + protected operation once. + +The ordering in step 8 is load-bearing. Completion is not merely a notification that an attempt returned +`complete`; session activation must finish before the protected operation is retried. + +## Legacy factor selection + +### First factor + +The legacy flow: + +- keeps only `password`, `email_code`, `phone_code`, and `passkey`; +- gives a primary email address or phone number priority among otherwise equivalent factors; +- uses the instance's preferred sign-in strategy to choose between password and one-time-code ordering; +- prefers a supported passkey before either ordering; +- compares email and phone factors by their resource ID, rather than treating every factor with the same strategy as + identical; +- filters passkeys from the alternatives list when WebAuthn is unavailable; and +- sorts alternatives as email code, phone code, passkey, then password. + +The initial-factor helper has an edge case: it can still fall back to a passkey in some unsupported-WebAuthn factor +sets because passkeys remain in the array used by the fallback sort. The Mosaic integration should preserve the +intended capability check, not that bug. + +If no first factor can be selected, legacy renders an unavailable `ErrorCard` rather than an alternatives list. + +### Second factor + +The legacy flow: + +- keeps `phone_code`, `totp`, and `backup_code`; +- starts with TOTP, otherwise phone code, otherwise the first remaining factor; +- compares phone factors by phone-number ID and other factors by strategy; and +- sorts alternatives as TOTP, phone code, then backup code. + +Landing on the wrong route is corrected from the verification resource status: first factor routes forward to second +factor, while second factor routes back to first factor. + +## Legacy behavior by method + +| Method | Stage | Prepare | Attempt | Other behavior | +| ----------- | ------ | ---------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Password | First | No | `attemptFirstFactorVerification({ strategy: 'password', password })` | API field errors can land on the password field; global errors land on the card. | +| Email code | First | On entry and resend | Six digits automatically call `attemptFirstFactorVerification` | A successfully prepared factor is remembered to avoid preparing the unchanged factor again. | +| Phone code | First | On entry and resend | Six digits automatically call `attemptFirstFactorVerification` | Preparation carries the phone-number ID and the factor's `default` value. | +| Passkey | First | Inside `verifyWithPasskey()` | `verifyWithPasskey()` prepares WebAuthn, gets a credential, then attempts it | Alternatives omit passkey when WebAuthn is unavailable. | +| Phone code | Second | On entry and resend | Six digits automatically call `attemptSecondFactorVerification` | Preparation uses the phone-number ID. | +| TOTP | Second | No | Six digits automatically call `attemptSecondFactorVerification` | No resend action. | +| Backup code | Second | No | Form submission calls `attemptSecondFactorVerification` | API field errors can land on the backup-code field. | + +Code resend is throttled for 30 seconds by `TimerButton`. The legacy timer decrements an in-memory counter with +`setInterval`; it is not a wall-clock deadline. Moving to a `Date.now()` deadline would be a reliability improvement, +not legacy parity. The interval also remains mounted and continues decrementing while an attempt is in flight. The +Mosaic delayed transition belongs to `verifyingCooldown`, so entering `submitting` cancels that timer and returning +after a failed attempt resumes from the frozen count. + +## Errors, help, and unavailable states + +Legacy `handleError` inspects Clerk errors rather than choosing error placement from the active strategy: + +- errors with `meta.paramName` are mapped to the matching form control; +- the first global API error is rendered at card level; +- Clerk runtime errors render at card level; +- `reverification_cancelled` is ignored by general error UI; and +- unknown errors are rethrown. + +Preparation errors are sent to card-level error handling. OTP attempt errors reset the input after the error feedback +has been shown. + +Both help and unavailable states render `ErrorCard`. That surface includes an Email support action using the instance's +support email. Help also offers Back. The unavailable state includes all three pieces of copy: title, subtitle, and +message. + +## Mosaic parity audit + +| Legacy behavior | Status | Current Mosaic evidence or gap | +| --------------------------------------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Machine-owned selection, preparation, attempt, resend, and cancellation | Migrated | Explicit machine states and injected `prepare` / `attempt` operations. | +| First-factor success can continue to second factor | Migrated | `needs_second_factor` replaces the challenge and returns through `starting`. | +| Six-digit email, phone, and TOTP codes submit automatically | Migrated | `CHANGE_VALUE` normalizes to six digits and targets `submitting`. | +| The unchanged prepared factor is not prepared again after opening alternatives and going Back | Migrated | The module-derived `preparedFactorKey` survives `SHOW_ALTERNATIVES` / `BACK`. | +| Cancel calls its completion exactly once | Migrated | `cancelled` is a final state with a cancel entry action. | +| Start verification, loading, level default, and cache lifecycle | Deferred | The view starts from an already-built `ReverificationChallenge`; no Clerk integration exists. | +| Filter, capability-check, sort, and choose the starting factor | Deferred | The caller supplies ordered factors and an optional `initialFactor`; the module derives and validates identities. | +| Preserve Clerk preparation data | Deferred | The custom first-factor phone type omits `default`; an adapter must retain or look up the original factor. | +| Field error versus card error based on Clerk error metadata | Deferred integration | The machine accepts semantic `answer` / `flow` errors. The future adapter must normalize Clerk error metadata into that vocabulary. | +| Activate the completed session before retrying the protected action | Migrated contract | A complete attempt retains its `sessionId`; `completing` awaits `complete(result)` before entering the final state. | +| Update and invalidate the verification cache | Deferred | No integration layer exists. | +| Close without cancellation, then retry the protected operation once | Deferred | `onComplete` / `onCancel` remain injected operations until the integration adapter exists. | +| Email support from help and unavailable states | Migrated | Both message paths expose the injected support email as their primary action. | +| Unavailable title, subtitle, message, and support action | Partially migrated | The new message renders title, message, and support action, but still omits the subtitle. | +| Alternative-method explanatory text | Partially migrated | Legacy renders “Don’t have any of these?” next to Get help; the new choose footer renders only Get help. | +| Identifier formatting | Deferred | The new labels use `safeIdentifier` directly instead of `formatSafeIdentifier`. | +| Localization | Deferred | The block accepts strings, but the actor-owning view currently reads an English base object directly. | +| Resend timing | Deliberately changed | Mosaic starts the cooldown after successful prepare instead of mount/click. Its state-local timer also freezes during `submitting`; legacy's mounted interval keeps decrementing. Neither implementation uses a wall-clock deadline. | +| Empty or invalid `initialFactor` | Deliberately changed | Mosaic opens factor selection when factors exist; legacy first factor shows unavailable and second factor remains loading when no current factor is selected. | +| Submit empty or incomplete answers | Deliberately changed | Mosaic guards submit and automatically submits fixed-length codes; legacy's Continue path can invoke an OTP attempt with an empty value. | +| Password-only help path | Deliberately changed | Mosaic links directly to help; legacy reaches help through its alternatives surface. | + +## Interface review + +The machine/view/renderer split is sound, but the current external interface is shallower than the delete-account +precedent. Delete account asks its caller for one operation. Reverification asks its caller to understand factor IDs, +stage tags, initial-factor policy, lossy resource translation, preparation, attempt result normalization, activation, +and modal completion semantics. + +Before production integration: + +1. Add one integration wrapper that owns Clerk hooks/resources and translates them into the existing plain machine + dependencies. It does not need to be named or factored as a controller. +2. Make challenge construction one reusable function so filtering, capability checks, ordering, and initial-factor + selection cannot drift across callers or tests. Factor identities are already derived and duplicate identities + are rejected by the machine. +3. Normalize Clerk errors into the machine's `answer` / `flow` vocabulary. Plain rejected errors deliberately fall + back to flow-level messages rather than inferring placement from strategy. +4. Implement `onComplete(result)` by activating `result.sessionId`, then retrying the protected operation. A failed + completion now enters `completionFailed`; retry invokes only `complete(result)`, never the successful attempt. +5. Restore the unavailable subtitle and alternative-method explanatory text. +6. Wire the existing localization namespace before the flow is reachable. + +These items deepen the module by moving policy out of every future caller. Adding a pass-through controller without +moving any of this policy would not. + +## Verification snapshot + +On the current branch, the three targeted files contain 49 tests: 19 machine, 16 actor-owning view, and 14 block tests. +The targeted Vitest run passes all 49. The run reports that Vite did not exit within its +10-second close timeout, although Vitest reports the tests themselves closed successfully. + +Browser QA was not performed as part of this review. From 79abc60962cbc9d2cd5dbeb15a5f5282d2f3659d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 26 Aug 2026 10:47:55 -0600 Subject: [PATCH 07/12] docs(swingset): explain how the reverification dialog is composed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The submit note read as though the button being outside the form was what made Enter submit, when the form attribute is. State the cause — content and footer are sibling card regions — then the mechanism that reconnects them. Add "Where it opens": a reverification is raised by an action already under way, so it opens over the dialog that asked and wants to be a stacked prompt, while the block is a root-level card. Document what that costs today rather than the shape it is heading for. Co-Authored-By: Claude Opus 5 --- packages/swingset/src/stories/reverification-dialog.mdx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/swingset/src/stories/reverification-dialog.mdx b/packages/swingset/src/stories/reverification-dialog.mdx index 29115767a1a..4be1c3a0ff5 100644 --- a/packages/swingset/src/stories/reverification-dialog.mdx +++ b/packages/swingset/src/stories/reverification-dialog.mdx @@ -48,6 +48,14 @@ import { ReverificationDialog } from '@clerk/ui/mosaic/blocks/reverification-dia ``` The action sits in the footer, outside the field's form, so pressing Enter in the field submits the same way the button does. +A reverification is raised by something the user has already started — deleting an account, revoking a session — so it +opens over the dialog that asked, not over the page. That makes it a stacked surface, and per the +[Dialog](/components/dialog) page's "Nested dialogs and stacks", the thing that opens is always a `prompt`. + +This block is `size='card'`, which is a root-level surface. Opened inside another dialog it warns in development and +takes the nested treatment — its own scrim over the host's, with the surface beneath neither dimming nor receding, and +both surfaces at the same width so the one underneath is hidden rather than showing behind. Until the size is a prop, +compose it at the root only. ## Props From d5b3f60d7f033bec61378210c26736f145595758 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 26 Aug 2026 11:04:47 -0600 Subject: [PATCH 08/12] refactor(ui): remove speculative reverification docs --- .../reverification-dialog.messages.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts index 1f0ce3bd0fb..0fc47398bbc 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts @@ -1,15 +1,3 @@ -/** - * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so - * localizing this component is a matter of registering the namespace and swapping the reads for - * `useMessages('reverification', reverificationDialogBase)`, not of hunting the literals down first. - * - * The `reverification.*` keys mirror the namespace already shipping in `@clerk/localizations`, and - * the flat keys below them mirror the root-level keys the legacy flow shares with the rest of the - * UI. Keeping both sets verbatim is what makes the eventual swap a rename rather than a retranslation. - * - * A plural message is its forms, the way `count()` takes them; a parameterized one is its template, - * the way `params()` takes it. `plural` and `fill` below resolve them until that layer lands. - */ export const reverificationDialogBase = { alternativeMethods: { actionLink: 'Get help', From 761cdedb50e6b61e644ab3da1f3d787de62f21de Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 26 Aug 2026 15:19:09 -0600 Subject: [PATCH 09/12] refactor(ui): rename reverification machine to controller --- .../src/stories/reverification-dialog.mdx | 8 ++--- .../stories/reverification-dialog.stories.tsx | 6 ++-- .../blocks/reverification-dialog/index.ts | 10 +++---- ... reverification-dialog.controller.test.ts} | 6 ++-- ...ts => reverification-dialog.controller.ts} | 29 ++++++++++--------- .../reverification-dialog.tsx | 4 +-- .../reverification-dialog.view.test.tsx | 2 +- .../reverification-dialog.view.tsx | 14 ++++----- references/reverification-architecture.md | 26 ++++++++--------- 9 files changed, 53 insertions(+), 52 deletions(-) rename packages/ui/src/mosaic/blocks/reverification-dialog/{reverification-dialog.machine.test.ts => reverification-dialog.controller.test.ts} (98%) rename packages/ui/src/mosaic/blocks/reverification-dialog/{reverification-dialog.machine.ts => reverification-dialog.controller.ts} (92%) diff --git a/packages/swingset/src/stories/reverification-dialog.mdx b/packages/swingset/src/stories/reverification-dialog.mdx index 4be1c3a0ff5..647b10b3e92 100644 --- a/packages/swingset/src/stories/reverification-dialog.mdx +++ b/packages/swingset/src/stories/reverification-dialog.mdx @@ -6,7 +6,7 @@ The dialog that asks a user to prove who they are before a sensitive action. It ## Example -The launch buttons are showcase controls, not part of the block. Each one mounts `ReverificationDialogView`, which drives the block from a state machine, so method selection, code delivery, automatic submission, errors, the resend cooldown, completion, and cancellation all run for real against stubbed operations. +The launch buttons are showcase controls, not part of the block. Each one mounts `ReverificationDialogView`, whose controller drives method selection, code delivery, automatic submission, errors, the resend cooldown, completion, and cancellation against stubbed operations. new Promise(resolve => window.setTimeout(resolve, ms)); -function MachineDrivenDialog({ scenario, onFinished }: { scenario: Scenario; onFinished: () => void }) { +function ControllerDrivenDialog({ scenario, onFinished }: { scenario: Scenario; onFinished: () => void }) { const prepare = React.useCallback(async (_factor: ReverificationPreparationFactor) => { await settleAfter(600); }, []); @@ -140,7 +140,7 @@ function MachineDrivenDialog({ scenario, onFinished }: { scenario: Scenario; onF [scenario.continuesToSecondFactor], ); // The view finishes in a final state, so the story unmounts it to make the demo repeatable. - // Deferred a tick because the machine reports cancellation from inside its own transition. + // Deferred a tick because the controller reports cancellation from inside its own transition. const finish = React.useCallback(() => window.setTimeout(onFinished, 0), [onFinished]); // Stands in for activating the session, which the dialog waits out before it closes. const onComplete = React.useCallback( @@ -187,7 +187,7 @@ export function Default() { ))} {active ? ( - setActive(null)} diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts index 85151b8233f..a611c6e4176 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts @@ -9,12 +9,12 @@ export type { ReverificationDialogResend, ReverificationDialogVerifyProps, } from './reverification-dialog'; -export { reverificationDialogMachine, reverificationFactorKey } from './reverification-dialog.machine'; +export { reverificationDialogController, reverificationFactorKey } from './reverification-dialog.controller'; export type { - ReverificationDialogMachineContext, - ReverificationDialogMachineEvent, - ReverificationDialogMachineSnapshot, -} from './reverification-dialog.machine'; + ReverificationDialogControllerContext, + ReverificationDialogControllerEvent, + ReverificationDialogControllerSnapshot, +} from './reverification-dialog.controller'; export type * from './reverification-dialog.types'; export { ReverificationDialogView } from './reverification-dialog.view'; export type { ReverificationDialogViewProps } from './reverification-dialog.view'; diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.controller.test.ts similarity index 98% rename from packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts rename to packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.controller.test.ts index 2d178dd76b9..78211e6e463 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.test.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.controller.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createActor } from '../../machine/createActor'; -import { reverificationDialogMachine, reverificationFactorKey } from './reverification-dialog.machine'; +import { reverificationDialogController, reverificationFactorKey } from './reverification-dialog.controller'; import type { ReverificationAttempt, ReverificationAttemptResult, @@ -69,7 +69,7 @@ function start({ complete?: (result: ReverificationCompleteResult) => Promise; cancel?: () => void; } = {}) { - const actor = createActor(reverificationDialogMachine, { + const actor = createActor(reverificationDialogController, { context: { initialChallenge: challenge, prepare, attempt, complete, cancel }, }).start(); return { actor, prepare, attempt, complete, cancel }; @@ -79,7 +79,7 @@ afterEach(() => { vi.useRealTimers(); }); -describe('reverificationDialogMachine', () => { +describe('reverificationDialogController', () => { it('starts at factor selection when no initial factor is provided', () => { const { actor } = start({ challenge: firstFactorChallenge() }); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.controller.ts similarity index 92% rename from packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts rename to packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.controller.ts index 66291339ad5..be3c20f78bb 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.machine.ts +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.controller.ts @@ -18,7 +18,7 @@ const emptyChallenge: ReverificationChallenge = { factors: [], }; -export interface ReverificationDialogMachineContext { +export interface ReverificationDialogControllerContext { /** The challenge injected by the view and captured when the actor starts. */ initialChallenge: ReverificationChallenge; /** The active challenge, replaced when first-factor verification requires a second factor. */ @@ -45,7 +45,7 @@ export interface ReverificationDialogMachineContext { cancel: () => void; } -export type ReverificationDialogMachineEvent = +export type ReverificationDialogControllerEvent = | { type: 'CHANGE_VALUE'; value: string } | { type: 'SUBMIT' } | { type: 'RESEND' } @@ -57,11 +57,12 @@ export type ReverificationDialogMachineEvent = | { type: 'RETRY_COMPLETE' }; const { createMachine, assign, fromPromise } = setup< - ReverificationDialogMachineContext, - ReverificationDialogMachineEvent + ReverificationDialogControllerContext, + ReverificationDialogControllerEvent >(); -const factorsFrom = (context: ReverificationDialogMachineContext): ReverificationFactor[] => context.challenge.factors; +const factorsFrom = (context: ReverificationDialogControllerContext): ReverificationFactor[] => + context.challenge.factors; export const reverificationFactorKey = (factor: ReverificationFactor): string => { switch (factor.strategy) { @@ -81,7 +82,7 @@ const assertValidChallenge = (challenge: ReverificationChallenge) => { } }; -const factorFrom = (context: ReverificationDialogMachineContext, factorKey: string) => +const factorFrom = (context: ReverificationDialogControllerContext, factorKey: string) => factorsFrom(context).find(factor => reverificationFactorKey(factor) === factorKey); const initialFactorFrom = (challenge: ReverificationChallenge): ReverificationFactor | null => { @@ -93,13 +94,13 @@ const initialFactorFrom = (challenge: ReverificationChallenge): ReverificationFa return challenge.factors.find(factor => reverificationFactorKey(factor) === initialFactorKey) ?? null; }; -const alternativesFrom = (context: ReverificationDialogMachineContext) => +const alternativesFrom = (context: ReverificationDialogControllerContext) => factorsFrom(context).filter( factor => !context.currentFactor || reverificationFactorKey(factor) !== reverificationFactorKey(context.currentFactor), ); -const hasAlternatives = (context: ReverificationDialogMachineContext) => alternativesFrom(context).length > 0; +const hasAlternatives = (context: ReverificationDialogControllerContext) => alternativesFrom(context).length > 0; const requiresPreparation = (factor: ReverificationFactor | null): factor is ReverificationPreparationFactor => factor?.strategy === 'email_code' || factor?.strategy === 'phone_code'; @@ -110,7 +111,7 @@ const isFixedLengthCode = (factor: ReverificationFactor | null) => const normalizeValue = (factor: ReverificationFactor | null, value: string) => isFixedLengthCode(factor) ? value.replace(/\D/g, '').slice(0, 6) : value; -const canSubmit = (context: ReverificationDialogMachineContext) => { +const canSubmit = (context: ReverificationDialogControllerContext) => { const factor = context.currentFactor; if (!factor) { return false; @@ -124,7 +125,7 @@ const canSubmit = (context: ReverificationDialogMachineContext) => { return context.value.trim().length > 0; }; -const attemptFrom = (context: ReverificationDialogMachineContext): ReverificationAttempt => { +const attemptFrom = (context: ReverificationDialogControllerContext): ReverificationAttempt => { const factor = context.currentFactor; if (!factor) { throw new Error(m.unstable__errors__generic); @@ -156,8 +157,8 @@ const changeValue = ({ context, event, }: { - context: ReverificationDialogMachineContext; - event: Extract; + context: ReverificationDialogControllerContext; + event: Extract; }) => { const value = normalizeValue(context.currentFactor, event.value); return { @@ -166,7 +167,7 @@ const changeValue = ({ }; }; -export const reverificationDialogMachine = createMachine({ +export const reverificationDialogController = createMachine({ id: 'reverificationDialog', initial: 'initializing', context: { @@ -439,4 +440,4 @@ export const reverificationDialogMachine = createMachine({ }, }); -export type ReverificationDialogMachineSnapshot = Snapshot; +export type ReverificationDialogControllerSnapshot = Snapshot; diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx index 8d9a092a132..f254e27adfc 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx @@ -100,7 +100,7 @@ export type ReverificationDialogProps = | ReverificationDialogVerifyProps | ReverificationDialogMessageProps; -/** Every code this dialog asks for is six characters, the length the flow's machine normalizes to. */ +/** Every code this dialog asks for is six characters, the length the flow's controller normalizes to. */ const CODE_LENGTH = 6; /** @@ -128,7 +128,7 @@ function CodeSlots({ baseId, invalid }: { baseId: string; invalid: boolean }) { * * Controlled and stateless: every label, every enabled/disabled decision, and `open` itself * belong to the caller. The block holds nothing, so a step renders identically whether it was - * reached from a machine or from a story. + * reached from a controller or from a story. * * @example * { prepare, }); - // The machine takes no keystroke until the code is out, so an editable-looking field would + // The controller accepts no keystroke until the code is out, so an editable-looking field would // swallow one. await waitFor(() => expect(codeSlots()[0]).toBeDisabled()); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx index 81e39d93dc9..5a7bcccdd11 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx +++ b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx @@ -1,8 +1,8 @@ import { useMachine } from '../../machine/useMachine'; import type { ReverificationDialogMethod, ReverificationDialogProps } from './reverification-dialog'; import { ReverificationDialog } from './reverification-dialog'; -import type { ReverificationDialogMachineContext } from './reverification-dialog.machine'; -import { reverificationDialogMachine, reverificationFactorKey } from './reverification-dialog.machine'; +import type { ReverificationDialogControllerContext } from './reverification-dialog.controller'; +import { reverificationDialogController, reverificationFactorKey } from './reverification-dialog.controller'; import { fill, reverificationDialogBase as m } from './reverification-dialog.messages'; import type { ReverificationAttempt, @@ -14,7 +14,7 @@ import type { } from './reverification-dialog.types'; export interface ReverificationDialogViewProps { - /** The methods this run may use, captured when the machine starts. */ + /** The methods this run may use, captured when the controller starts. */ initialChallenge: ReverificationChallenge; /** Sends a code for a method that delivers one. Reject to keep the user on the code step. */ prepare: (factor: ReverificationPreparationFactor) => Promise; @@ -83,16 +83,16 @@ const asMethod = (factor: ReverificationFactor): ReverificationDialogMethod => ( label: methodLabel(factor), }); -const alternativesTo = (context: ReverificationDialogMachineContext) => +const alternativesTo = (context: ReverificationDialogControllerContext) => context.challenge.factors.filter( factor => !context.currentFactor || reverificationFactorKey(factor) !== reverificationFactorKey(context.currentFactor), ); /** - * Drives {@link ReverificationDialog} with {@link reverificationDialogMachine}. + * Drives {@link ReverificationDialog} with {@link reverificationDialogController}. * - * Every decision about what the flow does next lives in the machine; this layer only turns a + * Every decision about what the flow does next lives in the controller; this layer only turns a * snapshot into the block's props and the block's callbacks into events. The Clerk work arrives * as `prepare` and `attempt`, so the whole flow runs against plain promises in a test or a story. */ @@ -104,7 +104,7 @@ export function ReverificationDialogView({ onCancel, supportEmail, }: ReverificationDialogViewProps) { - const [snapshot, send, actor] = useMachine(reverificationDialogMachine, { + const [snapshot, send, actor] = useMachine(reverificationDialogController, { context: { initialChallenge, prepare, attempt, complete: onComplete, cancel: onCancel }, }); const { context } = snapshot; diff --git a/references/reverification-architecture.md b/references/reverification-architecture.md index 10065ffef8a..fbd04e8f671 100644 --- a/references/reverification-architecture.md +++ b/references/reverification-architecture.md @@ -16,9 +16,9 @@ integration caller | v actor-owning view - creates the machine actor, renders snapshots, emits events + creates the controller actor, renders snapshots, emits events | - +----------> pure machine + +----------> pure controller | v controlled renderer @@ -37,13 +37,13 @@ The reverification implementation follows the same shape: - `ReverificationDialogView` accepts an initial challenge plus `prepare`, `attempt`, `onComplete`, and `onCancel`; it owns the actor and derives `actor.can(...)` values. -- `reverificationDialogMachine` owns factor selection, preparation, submission, resend, help, completion, and +- `reverificationDialogController` owns factor selection, preparation, submission, resend, help, completion, and cancellation transitions. -- `ReverificationDialog` is the block's controlled, stateless renderer. The answer belongs to the machine because +- `ReverificationDialog` is the block's controlled, stateless renderer. The answer belongs to the controller because guards and attempts use it. -The machine, actor-owning view, renderer, messages, and shared vocabulary are internal roles of one cohesive block -module. They are colocated behind one `index.ts`; a future controller belongs in the same directory. +The controller, actor-owning view, renderer, messages, and shared vocabulary are internal roles of one cohesive block +module. They are colocated behind one `index.ts`. No separate controller is required to match that precedent. Before the flow becomes reachable, it will still need a production integration wrapper that translates Clerk resources into the plain interface above. That wrapper is an @@ -146,7 +146,7 @@ message. | Legacy behavior | Status | Current Mosaic evidence or gap | | --------------------------------------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Machine-owned selection, preparation, attempt, resend, and cancellation | Migrated | Explicit machine states and injected `prepare` / `attempt` operations. | +| Controller-owned selection, preparation, attempt, resend, and cancellation | Migrated | Explicit controller states and injected `prepare` / `attempt` operations. | | First-factor success can continue to second factor | Migrated | `needs_second_factor` replaces the challenge and returns through `starting`. | | Six-digit email, phone, and TOTP codes submit automatically | Migrated | `CHANGE_VALUE` normalizes to six digits and targets `submitting`. | | The unchanged prepared factor is not prepared again after opening alternatives and going Back | Migrated | The module-derived `preparedFactorKey` survives `SHOW_ALTERNATIVES` / `BACK`. | @@ -154,7 +154,7 @@ message. | Start verification, loading, level default, and cache lifecycle | Deferred | The view starts from an already-built `ReverificationChallenge`; no Clerk integration exists. | | Filter, capability-check, sort, and choose the starting factor | Deferred | The caller supplies ordered factors and an optional `initialFactor`; the module derives and validates identities. | | Preserve Clerk preparation data | Deferred | The custom first-factor phone type omits `default`; an adapter must retain or look up the original factor. | -| Field error versus card error based on Clerk error metadata | Deferred integration | The machine accepts semantic `answer` / `flow` errors. The future adapter must normalize Clerk error metadata into that vocabulary. | +| Field error versus card error based on Clerk error metadata | Deferred integration | The controller accepts semantic `answer` / `flow` errors. The future adapter must normalize Clerk error metadata into that vocabulary. | | Activate the completed session before retrying the protected action | Migrated contract | A complete attempt retains its `sessionId`; `completing` awaits `complete(result)` before entering the final state. | | Update and invalidate the verification cache | Deferred | No integration layer exists. | | Close without cancellation, then retry the protected operation once | Deferred | `onComplete` / `onCancel` remain injected operations until the integration adapter exists. | @@ -170,19 +170,19 @@ message. ## Interface review -The machine/view/renderer split is sound, but the current external interface is shallower than the delete-account +The controller/view/renderer split is sound, but the current external interface is shallower than the delete-account precedent. Delete account asks its caller for one operation. Reverification asks its caller to understand factor IDs, stage tags, initial-factor policy, lossy resource translation, preparation, attempt result normalization, activation, and modal completion semantics. Before production integration: -1. Add one integration wrapper that owns Clerk hooks/resources and translates them into the existing plain machine +1. Add one integration wrapper that owns Clerk hooks/resources and translates them into the existing plain controller dependencies. It does not need to be named or factored as a controller. 2. Make challenge construction one reusable function so filtering, capability checks, ordering, and initial-factor selection cannot drift across callers or tests. Factor identities are already derived and duplicate identities - are rejected by the machine. -3. Normalize Clerk errors into the machine's `answer` / `flow` vocabulary. Plain rejected errors deliberately fall + are rejected by the controller. +3. Normalize Clerk errors into the controller's `answer` / `flow` vocabulary. Plain rejected errors deliberately fall back to flow-level messages rather than inferring placement from strategy. 4. Implement `onComplete(result)` by activating `result.sessionId`, then retrying the protected operation. A failed completion now enters `completionFailed`; retry invokes only `complete(result)`, never the successful attempt. @@ -194,7 +194,7 @@ moving any of this policy would not. ## Verification snapshot -On the current branch, the three targeted files contain 49 tests: 19 machine, 16 actor-owning view, and 14 block tests. +On the current branch, the three targeted files contain 49 tests: 19 controller, 16 actor-owning view, and 14 block tests. The targeted Vitest run passes all 49. The run reports that Vite did not exit within its 10-second close timeout, although Vitest reports the tests themselves closed successfully. From 7345ae19c08d9f4ebe278fab7d685976dc08a750 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 26 Aug 2026 19:53:41 -0600 Subject: [PATCH 10/12] refactor(ui): compose reverification for standalone and dialogs --- .../blocks/reverification-dialog/index.ts | 20 - .../reverification-dialog.tsx | 406 ------------------ .../src/mosaic/blocks/reverification/index.ts | 27 ++ .../reverification-dialog-content.test.tsx} | 79 ++-- .../reverification-dialog-content.tsx | 173 ++++++++ .../reverification.controller.test.ts} | 8 +- .../reverification.controller.ts} | 36 +- .../reverification.messages.ts} | 2 +- .../reverification/reverification.test.tsx | 43 ++ .../blocks/reverification/reverification.tsx | 196 +++++++++ .../reverification.types.ts} | 0 .../reverification.view.test.tsx} | 8 +- .../reverification.view.tsx} | 84 ++-- references/reverification-architecture.md | 24 +- 14 files changed, 582 insertions(+), 524 deletions(-) delete mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/index.ts delete mode 100644 packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx create mode 100644 packages/ui/src/mosaic/blocks/reverification/index.ts rename packages/ui/src/mosaic/blocks/{reverification-dialog/reverification-dialog.test.tsx => reverification/reverification-dialog-content.test.tsx} (74%) create mode 100644 packages/ui/src/mosaic/blocks/reverification/reverification-dialog-content.tsx rename packages/ui/src/mosaic/blocks/{reverification-dialog/reverification-dialog.controller.test.ts => reverification/reverification.controller.test.ts} (98%) rename packages/ui/src/mosaic/blocks/{reverification-dialog/reverification-dialog.controller.ts => reverification/reverification.controller.ts} (91%) rename packages/ui/src/mosaic/blocks/{reverification-dialog/reverification-dialog.messages.ts => reverification/reverification.messages.ts} (98%) create mode 100644 packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx create mode 100644 packages/ui/src/mosaic/blocks/reverification/reverification.tsx rename packages/ui/src/mosaic/blocks/{reverification-dialog/reverification-dialog.types.ts => reverification/reverification.types.ts} (100%) rename packages/ui/src/mosaic/blocks/{reverification-dialog/reverification-dialog.view.test.tsx => reverification/reverification.view.test.tsx} (98%) rename packages/ui/src/mosaic/blocks/{reverification-dialog/reverification-dialog.view.tsx => reverification/reverification.view.tsx} (78%) diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts b/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts deleted file mode 100644 index a611c6e4176..00000000000 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export { ReverificationDialog } from './reverification-dialog'; -export type { - ReverificationDialogAction, - ReverificationDialogChooseProps, - ReverificationDialogField, - ReverificationDialogMessageProps, - ReverificationDialogMethod, - ReverificationDialogProps, - ReverificationDialogResend, - ReverificationDialogVerifyProps, -} from './reverification-dialog'; -export { reverificationDialogController, reverificationFactorKey } from './reverification-dialog.controller'; -export type { - ReverificationDialogControllerContext, - ReverificationDialogControllerEvent, - ReverificationDialogControllerSnapshot, -} from './reverification-dialog.controller'; -export type * from './reverification-dialog.types'; -export { ReverificationDialogView } from './reverification-dialog.view'; -export type { ReverificationDialogViewProps } from './reverification-dialog.view'; diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx b/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx deleted file mode 100644 index f254e27adfc..00000000000 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.tsx +++ /dev/null @@ -1,406 +0,0 @@ -import { Otp } from '@clerk/headless/otp'; -import type { FormEvent } from 'react'; -import { useId } from 'react'; - -import { Button, SubmitButton } from '../../components/button'; -import { Card } from '../../components/card'; -import { Dialog } from '../../components/dialog'; -import { Field } from '../../components/field'; -import { Heading } from '../../components/heading'; -import { Input } from '../../components/input'; -import { Text } from '../../components/text'; - -/** One selectable verification method. `id` is opaque to the block and handed straight back. */ -export interface ReverificationDialogMethod { - id: string; - label: string; -} - -/** A labelled callback the caller decides to offer — rendered only when supplied. */ -export interface ReverificationDialogAction { - label: string; - onClick: () => void; -} - -export interface ReverificationDialogField { - label: string; - /** `code` renders per-character slots; `password` masks the value; `text` is a plain field. */ - kind: 'code' | 'password' | 'text'; - value: string; - disabled: boolean; - /** Why this field's value was rejected. Renders under the field and marks it invalid. */ - error?: string; - onChange: (value: string) => void; -} - -export interface ReverificationDialogResend { - label: string; - disabled: boolean; - onResend: () => void; -} - -interface ReverificationDialogBaseProps { - /** Whether the dialog is open */ - open: boolean; - /** Callback when open state changes */ - onOpenChange: (open: boolean) => void; - /** Whether close requests and explicit close controls may dismiss the dialog. */ - dismissible: boolean; - /** Dialog heading */ - title: string; - /** What is being asked of the user */ - description: string; - /** Accessible name for the corner close button */ - closeLabel: string; - /** A failure that belongs to the step rather than to a field. Announced as an alert. */ - error?: string; -} - -/** Pick a verification method from a list. */ -export interface ReverificationDialogChooseProps extends ReverificationDialogBaseProps { - step: 'choose'; - methods: ReverificationDialogMethod[]; - onSelectMethod: (id: string) => void; - /** Returns to the method the user came from. Absent when there is nothing to go back to. */ - back?: ReverificationDialogAction; - cancelLabel: string; - help: ReverificationDialogAction; -} - -/** Satisfy one method: type a code or password, or present a passkey. */ -export interface ReverificationDialogVerifyProps extends ReverificationDialogBaseProps { - step: 'verify'; - /** The identity the code went to, e.g. a redacted phone number. */ - identifier?: string; - /** Absent for a method with nothing to type, such as a passkey. */ - field?: ReverificationDialogField; - resend?: ReverificationDialogResend; - submitLabel: string; - /** Accessible name for the pending indicator on the submit button */ - pendingLabel: string; - canSubmit: boolean; - isPending: boolean; - onSubmit: () => void; - cancelLabel: string; - /** The one escape this step offers — another method, or help. */ - secondary?: ReverificationDialogAction; -} - -/** A dead end: help, or no methods to offer. */ -export interface ReverificationDialogMessageProps extends ReverificationDialogBaseProps { - step: 'message'; - /** The way forward from a dead end — reaching a human. */ - action: ReverificationDialogAction; - /** An optional secondary action, such as returning or cancelling. */ - secondary?: ReverificationDialogAction; -} - -export type ReverificationDialogProps = - | ReverificationDialogChooseProps - | ReverificationDialogVerifyProps - | ReverificationDialogMessageProps; - -/** Every code this dialog asks for is six characters, the length the flow's controller normalizes to. */ -const CODE_LENGTH = 6; - -/** - * The code slots, straight off the headless primitive and unstyled for now — Mosaic has no - * styled OTP component yet. Typing advances, `Backspace` walks back, and a pasted code spreads - * across the slots. - */ -function CodeSlots({ baseId, invalid }: { baseId: string; invalid: boolean }) { - const { slots } = Otp.useOtp(); - - return slots.map(slot => ( - - )); -} - -/** - * The dialog that asks a user to prove who they are before a sensitive action. Renders one of - * three steps — pick a method, satisfy it, or a dead end — and owns none of the flow between - * them. - * - * Controlled and stateless: every label, every enabled/disabled decision, and `open` itself - * belong to the caller. The block holds nothing, so a step renders identically whether it was - * reached from a controller or from a story. - * - * @example - * !open && send({ type: 'CANCEL' })} - * dismissible={snapshot.can({ type: 'CANCEL' })} - * title='Verification required' - * description='Enter the code sent to your email to continue' - * closeLabel='Close' - * field={{ label: 'Verification code', kind: 'code', value, disabled: false, onChange }} - * submitLabel='Continue' - * pendingLabel='Verifying' - * canSubmit={canSubmit} - * isPending={snapshot.value === 'submitting'} - * onSubmit={() => send({ type: 'SUBMIT' })} - * cancelLabel='Cancel' - * /> - */ -export function ReverificationDialog(props: ReverificationDialogProps) { - const { open, onOpenChange, dismissible, title, description, closeLabel, error } = props; - - return ( - - - - - - } - > - {dismissible ? : null} - - }>{title} - }>{description} - - {error ? ( - - - {error} - - - ) : null} - - - - - - ); -} - -function StepContent(props: ReverificationDialogProps) { - switch (props.step) { - case 'choose': - return ; - case 'verify': - return ; - case 'message': - return ; - } -} - -function ChooseStep({ - methods, - onSelectMethod, - back, - cancelLabel, - help, - dismissible, -}: ReverificationDialogChooseProps) { - return ( - <> - - {methods.map(method => ( - - ))} - - - {back ? ( - - ) : ( - - } - > - {cancelLabel} - - )} - - - - ); -} - -function VerifyStep({ - identifier, - field, - resend, - submitLabel, - pendingLabel, - canSubmit, - isPending, - onSubmit, - cancelLabel, - secondary, - dismissible, -}: ReverificationDialogVerifyProps) { - const formId = useId(); - const fieldId = useId(); - - // The action sits in the footer, outside the form, so `form={formId}` associates the two. - // That is what makes Enter in the field submit. Both guards are re-checked here because - // neither spelling stops a native submit: `focusableWhenDisabled` only marks the button - // `aria-disabled`, and `isPending` only cancels the press. - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - if (canSubmit && !isPending) { - onSubmit(); - } - }; - - return ( - <> - - {identifier ? {identifier} : null} -
- {field ? ( - - {/* A ` - ) : null} -
- {resend ? ( - - ) : null} -
- - {secondary ? ( - - ) : null} - - } - > - {cancelLabel} - - - {submitLabel} - - - - ); -} - -function MessageStep({ action, secondary }: ReverificationDialogMessageProps) { - return ( - - - {secondary ? ( - - ) : null} - - ); -} diff --git a/packages/ui/src/mosaic/blocks/reverification/index.ts b/packages/ui/src/mosaic/blocks/reverification/index.ts new file mode 100644 index 00000000000..224dfa87005 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/index.ts @@ -0,0 +1,27 @@ +export { Reverification } from './reverification'; +export type { + ReverificationChooseProps, + ReverificationField, + ReverificationMessageProps, + ReverificationMethod, + ReverificationProps, + ReverificationResend, + ReverificationVerifyProps, +} from './reverification'; +export { ReverificationDialogContent } from './reverification-dialog-content'; +export type { + ReverificationDialogAction, + ReverificationDialogChooseProps, + ReverificationDialogContentProps, + ReverificationDialogMessageProps, + ReverificationDialogVerifyProps, +} from './reverification-dialog-content'; +export { reverificationController, reverificationFactorKey } from './reverification.controller'; +export type { + ReverificationControllerContext, + ReverificationControllerEvent, + ReverificationControllerSnapshot, +} from './reverification.controller'; +export type * from './reverification.types'; +export { ReverificationView } from './reverification.view'; +export type { ReverificationViewProps } from './reverification.view'; diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-dialog-content.test.tsx similarity index 74% rename from packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx rename to packages/ui/src/mosaic/blocks/reverification/reverification-dialog-content.test.tsx index a1d38a7901e..c27d99042dc 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.test.tsx +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-dialog-content.test.tsx @@ -2,30 +2,48 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { Card } from '../../components/card'; +import { Dialog } from '../../components/dialog'; import { MosaicProvider } from '../../MosaicProvider'; -import type { - ReverificationDialogChooseProps, - ReverificationDialogMessageProps, - ReverificationDialogVerifyProps, -} from './reverification-dialog'; -import { ReverificationDialog } from './reverification-dialog'; +import type { ReverificationDialogContentProps } from './reverification-dialog-content'; +import { ReverificationDialogContent } from './reverification-dialog-content'; const base = { - open: true as const, - onOpenChange: vi.fn(), dismissible: true, closeLabel: 'Close', }; -function renderBlock(props: Parameters[0]) { +type TestProps = ReverificationDialogContentProps & { + open?: boolean; + onOpenChange?: (open: boolean) => void; +}; +type ChooseProps = Extract; +type VerifyProps = Extract; +type MessageProps = Extract; + +function renderBlock({ open = true, onOpenChange = vi.fn(), ...props }: TestProps) { return render( - + + + + + }> + + + + + , ); } -const chooseProps = (overrides: Partial = {}): ReverificationDialogChooseProps => ({ +const chooseProps = (overrides: Partial = {}): ChooseProps => ({ ...base, step: 'choose', title: 'Use another method', @@ -35,12 +53,11 @@ const chooseProps = (overrides: Partial = {}): { id: 'email_1', label: 'Email code to a••••@clerk.dev' }, ], onSelectMethod: vi.fn(), - cancelLabel: 'Cancel', - help: { label: 'Get help', onClick: vi.fn() }, + help: { text: 'Don’t have any of these?', action: { label: 'Get help', onClick: vi.fn() } }, ...overrides, }); -const verifyProps = (overrides: Partial = {}): ReverificationDialogVerifyProps => ({ +const verifyProps = (overrides: Partial = {}): VerifyProps => ({ ...base, step: 'verify', title: 'Verification required', @@ -55,7 +72,7 @@ const verifyProps = (overrides: Partial = {}): ...overrides, }); -const messageProps = (overrides: Partial = {}): ReverificationDialogMessageProps => ({ +const messageProps = (overrides: Partial = {}): MessageProps => ({ ...base, step: 'message', title: 'Get help', @@ -64,7 +81,7 @@ const messageProps = (overrides: Partial = {}) ...overrides, }); -describe('ReverificationDialog', () => { +describe('ReverificationDialogContent', () => { it('renders nothing until the caller opens it', () => { renderBlock(verifyProps({ open: false })); @@ -91,19 +108,20 @@ describe('ReverificationDialog', () => { expect(onSelectMethod).toHaveBeenCalledWith('email_1'); }); - it('offers back in place of cancel only when the caller supplies it', () => { - const { rerender } = renderBlock(chooseProps()); - expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + it('keeps back with the methods and help in the card footer', () => { + const { unmount } = renderBlock(chooseProps()); expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); - rerender( - - - , - ); + unmount(); + renderBlock(chooseProps({ back: { label: 'Back', onClick: vi.fn() } })); + + const method = screen.getByRole('button', { name: 'Continue with your password' }); + const back = screen.getByRole('button', { name: 'Back' }); + const help = screen.getByRole('button', { name: 'Get help' }); - expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); + expect(back.parentElement).toBe(method.parentElement); + expect(help.parentElement).not.toBe(method.parentElement); + expect(within(help.parentElement as HTMLElement).getByText('Don’t have any of these?')).toBeInTheDocument(); }); it('submits the field with Enter, since the action sits outside the form', async () => { @@ -216,14 +234,11 @@ describe('ReverificationDialog', () => { it('offers a way back from a dead end only when the caller supplies one', async () => { const onClick = vi.fn(); - const { rerender } = renderBlock(messageProps()); + const { unmount } = renderBlock(messageProps()); expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); - rerender( - - - , - ); + unmount(); + renderBlock(messageProps({ secondary: { label: 'Back', onClick } })); await userEvent.setup().click(screen.getByRole('button', { name: 'Back' })); expect(onClick).toHaveBeenCalledOnce(); diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-dialog-content.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-dialog-content.tsx new file mode 100644 index 00000000000..17bc8db2b5c --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-dialog-content.tsx @@ -0,0 +1,173 @@ +import { useId } from 'react'; + +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { Dialog } from '../../components/dialog'; +import { Heading } from '../../components/heading'; +import { Text } from '../../components/text'; +import type { + ReverificationChooseProps, + ReverificationMessageProps, + ReverificationVerifyProps, +} from './reverification'; +import { Reverification } from './reverification'; + +export interface ReverificationDialogAction { + label: string; + onClick: () => void; +} + +export interface ReverificationDialogHelp { + text: string; + action: ReverificationDialogAction; +} + +interface ReverificationDialogContentBaseProps { + dismissible: boolean; + title: string; + description: string; + closeLabel: string; +} + +export type ReverificationDialogChooseProps = ReverificationChooseProps & + ReverificationDialogContentBaseProps & { + back?: ReverificationDialogAction; + help: ReverificationDialogHelp; + }; + +export type ReverificationDialogVerifyProps = ReverificationVerifyProps & + ReverificationDialogContentBaseProps & { + submitLabel: string; + pendingLabel: string; + cancelLabel: string; + alternative?: ReverificationDialogAction; + help?: ReverificationDialogHelp; + }; + +export type ReverificationDialogMessageProps = ReverificationMessageProps & + ReverificationDialogContentBaseProps & { + action: ReverificationDialogAction; + secondary?: ReverificationDialogAction; + }; + +export type ReverificationDialogContentProps = + | ReverificationDialogChooseProps + | ReverificationDialogVerifyProps + | ReverificationDialogMessageProps; + +export function ReverificationDialogContent(props: ReverificationDialogContentProps) { + const { dismissible, title, description, closeLabel } = props; + const formId = useId(); + + return ( + <> + {dismissible ? : null} + + }>{title} + }>{description} + + + + + + + + ); +} + +function ContentAction(props: ReverificationDialogContentProps) { + const action = props.step === 'choose' ? props.back : props.step === 'verify' ? props.alternative : undefined; + + return action ? ( + + ) : null; +} + +function HelpFooter({ text, action }: ReverificationDialogHelp) { + return ( + + {text} + + + ); +} + +function Actions(props: ReverificationDialogContentProps & { formId: string }) { + switch (props.step) { + case 'choose': + return ; + case 'verify': + return ( + <> + {props.help ? : null} + + + } + > + {props.cancelLabel} + + + {props.submitLabel} + + + + ); + case 'message': + return ( + + + {props.secondary ? ( + + ) : null} + + ); + } +} diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.controller.test.ts b/packages/ui/src/mosaic/blocks/reverification/reverification.controller.test.ts similarity index 98% rename from packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.controller.test.ts rename to packages/ui/src/mosaic/blocks/reverification/reverification.controller.test.ts index 78211e6e463..b7ee10b6ae6 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.controller.test.ts +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.controller.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createActor } from '../../machine/createActor'; -import { reverificationDialogController, reverificationFactorKey } from './reverification-dialog.controller'; +import { reverificationController, reverificationFactorKey } from './reverification.controller'; import type { ReverificationAttempt, ReverificationAttemptResult, @@ -13,7 +13,7 @@ import type { ReverificationPreparationFactor, ReverificationSecondFactorPhoneCodeFactor, ReverificationTOTPFactor, -} from './reverification-dialog.types'; +} from './reverification.types'; const passwordFactor: ReverificationPasswordFactor = { stage: 'first', @@ -69,7 +69,7 @@ function start({ complete?: (result: ReverificationCompleteResult) => Promise; cancel?: () => void; } = {}) { - const actor = createActor(reverificationDialogController, { + const actor = createActor(reverificationController, { context: { initialChallenge: challenge, prepare, attempt, complete, cancel }, }).start(); return { actor, prepare, attempt, complete, cancel }; @@ -79,7 +79,7 @@ afterEach(() => { vi.useRealTimers(); }); -describe('reverificationDialogController', () => { +describe('reverificationController', () => { it('starts at factor selection when no initial factor is provided', () => { const { actor } = start({ challenge: firstFactorChallenge() }); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.controller.ts b/packages/ui/src/mosaic/blocks/reverification/reverification.controller.ts similarity index 91% rename from packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.controller.ts rename to packages/ui/src/mosaic/blocks/reverification/reverification.controller.ts index be3c20f78bb..c592684ed8d 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.controller.ts +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.controller.ts @@ -1,6 +1,6 @@ import { setup } from '../../machine/setup'; import type { Snapshot } from '../../machine/types'; -import { reverificationDialogBase as m } from './reverification-dialog.messages'; +import { reverificationBase as m } from './reverification.messages'; import type { ReverificationAttempt, ReverificationAttemptResult, @@ -9,7 +9,7 @@ import type { ReverificationError, ReverificationFactor, ReverificationPreparationFactor, -} from './reverification-dialog.types'; +} from './reverification.types'; const RESEND_COOLDOWN_SECONDS = 30; @@ -18,7 +18,7 @@ const emptyChallenge: ReverificationChallenge = { factors: [], }; -export interface ReverificationDialogControllerContext { +export interface ReverificationControllerContext { /** The challenge injected by the view and captured when the actor starts. */ initialChallenge: ReverificationChallenge; /** The active challenge, replaced when first-factor verification requires a second factor. */ @@ -45,7 +45,7 @@ export interface ReverificationDialogControllerContext { cancel: () => void; } -export type ReverificationDialogControllerEvent = +export type ReverificationControllerEvent = | { type: 'CHANGE_VALUE'; value: string } | { type: 'SUBMIT' } | { type: 'RESEND' } @@ -56,13 +56,9 @@ export type ReverificationDialogControllerEvent = | { type: 'BACK' } | { type: 'RETRY_COMPLETE' }; -const { createMachine, assign, fromPromise } = setup< - ReverificationDialogControllerContext, - ReverificationDialogControllerEvent ->(); +const { createMachine, assign, fromPromise } = setup(); -const factorsFrom = (context: ReverificationDialogControllerContext): ReverificationFactor[] => - context.challenge.factors; +const factorsFrom = (context: ReverificationControllerContext): ReverificationFactor[] => context.challenge.factors; export const reverificationFactorKey = (factor: ReverificationFactor): string => { switch (factor.strategy) { @@ -82,7 +78,7 @@ const assertValidChallenge = (challenge: ReverificationChallenge) => { } }; -const factorFrom = (context: ReverificationDialogControllerContext, factorKey: string) => +const factorFrom = (context: ReverificationControllerContext, factorKey: string) => factorsFrom(context).find(factor => reverificationFactorKey(factor) === factorKey); const initialFactorFrom = (challenge: ReverificationChallenge): ReverificationFactor | null => { @@ -94,13 +90,13 @@ const initialFactorFrom = (challenge: ReverificationChallenge): ReverificationFa return challenge.factors.find(factor => reverificationFactorKey(factor) === initialFactorKey) ?? null; }; -const alternativesFrom = (context: ReverificationDialogControllerContext) => +const alternativesFrom = (context: ReverificationControllerContext) => factorsFrom(context).filter( factor => !context.currentFactor || reverificationFactorKey(factor) !== reverificationFactorKey(context.currentFactor), ); -const hasAlternatives = (context: ReverificationDialogControllerContext) => alternativesFrom(context).length > 0; +const hasAlternatives = (context: ReverificationControllerContext) => alternativesFrom(context).length > 0; const requiresPreparation = (factor: ReverificationFactor | null): factor is ReverificationPreparationFactor => factor?.strategy === 'email_code' || factor?.strategy === 'phone_code'; @@ -111,7 +107,7 @@ const isFixedLengthCode = (factor: ReverificationFactor | null) => const normalizeValue = (factor: ReverificationFactor | null, value: string) => isFixedLengthCode(factor) ? value.replace(/\D/g, '').slice(0, 6) : value; -const canSubmit = (context: ReverificationDialogControllerContext) => { +const canSubmit = (context: ReverificationControllerContext) => { const factor = context.currentFactor; if (!factor) { return false; @@ -125,7 +121,7 @@ const canSubmit = (context: ReverificationDialogControllerContext) => { return context.value.trim().length > 0; }; -const attemptFrom = (context: ReverificationDialogControllerContext): ReverificationAttempt => { +const attemptFrom = (context: ReverificationControllerContext): ReverificationAttempt => { const factor = context.currentFactor; if (!factor) { throw new Error(m.unstable__errors__generic); @@ -157,8 +153,8 @@ const changeValue = ({ context, event, }: { - context: ReverificationDialogControllerContext; - event: Extract; + context: ReverificationControllerContext; + event: Extract; }) => { const value = normalizeValue(context.currentFactor, event.value); return { @@ -167,8 +163,8 @@ const changeValue = ({ }; }; -export const reverificationDialogController = createMachine({ - id: 'reverificationDialog', +export const reverificationController = createMachine({ + id: 'reverification', initial: 'initializing', context: { initialChallenge: emptyChallenge, @@ -440,4 +436,4 @@ export const reverificationDialogController = createMachine({ }, }); -export type ReverificationDialogControllerSnapshot = Snapshot; +export type ReverificationControllerSnapshot = Snapshot; diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts b/packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts similarity index 98% rename from packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts rename to packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts index 0fc47398bbc..b03298d1eee 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.messages.ts +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts @@ -1,4 +1,4 @@ -export const reverificationDialogBase = { +export const reverificationBase = { alternativeMethods: { actionLink: 'Get help', actionText: 'Don’t have any of these?', diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx new file mode 100644 index 00000000000..7a0d6979deb --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { Reverification } from './reverification'; + +describe('Reverification', () => { + it('renders the interaction without owning a dialog', async () => { + const onSelectMethod = vi.fn(); + render( + + + , + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Continue with your password' })); + expect(onSelectMethod).toHaveBeenCalledWith('password'); + }); + + it('submits a standalone field with Enter', async () => { + const onSubmit = vi.fn(); + render( + + + , + ); + + await userEvent.setup().type(screen.getByLabelText('Password'), '{Enter}'); + expect(onSubmit).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification.tsx new file mode 100644 index 00000000000..62c49ec17b2 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.tsx @@ -0,0 +1,196 @@ +import { Otp } from '@clerk/headless/otp'; +import type { FormEvent } from 'react'; +import { useId } from 'react'; + +import { Button } from '../../components/button'; +import { Field } from '../../components/field'; +import { Input } from '../../components/input'; +import { Text } from '../../components/text'; + +export interface ReverificationMethod { + id: string; + label: string; +} + +export interface ReverificationField { + label: string; + kind: 'code' | 'password' | 'text'; + value: string; + disabled: boolean; + error?: string; + onChange: (value: string) => void; +} + +export interface ReverificationResend { + label: string; + disabled: boolean; + onResend: () => void; +} + +interface ReverificationBaseProps { + error?: string; +} + +export interface ReverificationChooseProps extends ReverificationBaseProps { + step: 'choose'; + methods: ReverificationMethod[]; + onSelectMethod: (id: string) => void; +} + +export interface ReverificationVerifyProps extends ReverificationBaseProps { + step: 'verify'; + identifier?: string; + field?: ReverificationField; + resend?: ReverificationResend; + canSubmit: boolean; + isPending: boolean; + onSubmit: () => void; +} + +export interface ReverificationMessageProps extends ReverificationBaseProps { + step: 'message'; +} + +export type ReverificationProps = ReverificationChooseProps | ReverificationVerifyProps | ReverificationMessageProps; + +const CODE_LENGTH = 6; + +function CodeSlots({ baseId, invalid }: { baseId: string; invalid: boolean }) { + const { slots } = Otp.useOtp(); + + return slots.map(slot => ( + + )); +} + +export interface ReverificationInternalProps { + formId?: string; +} + +export function Reverification(props: ReverificationProps & ReverificationInternalProps) { + const generatedFormId = useId(); + const formId = props.formId ?? generatedFormId; + + return ( + <> + {props.error ? ( + + {props.error} + + ) : null} + + + ); +} + +function ReverificationStep(props: ReverificationProps & { formId: string }) { + switch (props.step) { + case 'choose': + return ( + <> + {props.methods.map(method => ( + + ))} + + ); + case 'verify': + return ; + case 'message': + return null; + } +} + +function Verify({ + identifier, + field, + resend, + canSubmit, + isPending, + onSubmit, + formId, +}: ReverificationVerifyProps & { + formId: string; +}) { + const fieldId = useId(); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (canSubmit && !isPending) { + onSubmit(); + } + }; + + return ( + <> + {identifier ? {identifier} : null} +
+ {field ? ( + + {field.label} + {field.kind === 'code' ? ( + + + + ) : ( + field.onChange(event.target.value)} + /> + )} + {field.error ? {field.error} : null} + + ) : null} +
+ {resend ? ( + + ) : null} + + ); +} diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts b/packages/ui/src/mosaic/blocks/reverification/reverification.types.ts similarity index 100% rename from packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.types.ts rename to packages/ui/src/mosaic/blocks/reverification/reverification.types.ts diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification.view.test.tsx similarity index 98% rename from packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx rename to packages/ui/src/mosaic/blocks/reverification/reverification.view.test.tsx index e02ad251f09..f7951571e89 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.test.tsx +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.view.test.tsx @@ -12,8 +12,8 @@ import type { ReverificationPasskeyFactor, ReverificationPasswordFactor, ReverificationPreparationFactor, -} from './reverification-dialog.types'; -import { ReverificationDialogView } from './reverification-dialog.view'; +} from './reverification.types'; +import { ReverificationView } from './reverification.view'; const passwordFactor: ReverificationPasswordFactor = { stage: 'first', @@ -48,7 +48,7 @@ function renderView({ } = {}) { render( - within(screen.getByRole('group', { name: 'Verification code' })).getAllByRole('textbox'); -describe('ReverificationDialogView', () => { +describe('ReverificationView', () => { it('opens on the starting method and carries its answer to the attempt', async () => { const { attempt, onComplete } = renderView(); const user = userEvent.setup(); diff --git a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification.view.tsx similarity index 78% rename from packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx rename to packages/ui/src/mosaic/blocks/reverification/reverification.view.tsx index 5a7bcccdd11..1830122d877 100644 --- a/packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.view.tsx @@ -1,9 +1,10 @@ +import { Card } from '../../components/card'; +import { Dialog } from '../../components/dialog'; import { useMachine } from '../../machine/useMachine'; -import type { ReverificationDialogMethod, ReverificationDialogProps } from './reverification-dialog'; -import { ReverificationDialog } from './reverification-dialog'; -import type { ReverificationDialogControllerContext } from './reverification-dialog.controller'; -import { reverificationDialogController, reverificationFactorKey } from './reverification-dialog.controller'; -import { fill, reverificationDialogBase as m } from './reverification-dialog.messages'; +import type { ReverificationMethod } from './reverification'; +import type { ReverificationControllerContext } from './reverification.controller'; +import { reverificationController, reverificationFactorKey } from './reverification.controller'; +import { fill, reverificationBase as m } from './reverification.messages'; import type { ReverificationAttempt, ReverificationAttemptResult, @@ -11,9 +12,11 @@ import type { ReverificationCompleteResult, ReverificationFactor, ReverificationPreparationFactor, -} from './reverification-dialog.types'; +} from './reverification.types'; +import type { ReverificationDialogContentProps } from './reverification-dialog-content'; +import { ReverificationDialogContent } from './reverification-dialog-content'; -export interface ReverificationDialogViewProps { +export interface ReverificationViewProps { /** The methods this run may use, captured when the controller starts. */ initialChallenge: ReverificationChallenge; /** Sends a code for a method that delivers one. Reject to keep the user on the code step. */ @@ -78,33 +81,33 @@ function methodLabel(factor: ReverificationFactor): string { } } -const asMethod = (factor: ReverificationFactor): ReverificationDialogMethod => ({ +const asMethod = (factor: ReverificationFactor): ReverificationMethod => ({ id: reverificationFactorKey(factor), label: methodLabel(factor), }); -const alternativesTo = (context: ReverificationDialogControllerContext) => +const alternativesTo = (context: ReverificationControllerContext) => context.challenge.factors.filter( factor => !context.currentFactor || reverificationFactorKey(factor) !== reverificationFactorKey(context.currentFactor), ); /** - * Drives {@link ReverificationDialog} with {@link reverificationDialogController}. + * Drives {@link ReverificationDialogContent} with {@link reverificationController}. * * Every decision about what the flow does next lives in the controller; this layer only turns a * snapshot into the block's props and the block's callbacks into events. The Clerk work arrives * as `prepare` and `attempt`, so the whole flow runs against plain promises in a test or a story. */ -export function ReverificationDialogView({ +export function ReverificationView({ initialChallenge, prepare, attempt, onComplete, onCancel, supportEmail, -}: ReverificationDialogViewProps) { - const [snapshot, send, actor] = useMachine(reverificationDialogController, { +}: ReverificationViewProps) { + const [snapshot, send, actor] = useMachine(reverificationController, { context: { initialChallenge, prepare, attempt, complete: onComplete, cancel: onCancel }, }); const { context } = snapshot; @@ -126,18 +129,12 @@ export function ReverificationDialogView({ const canCancel = actor.can({ type: 'CANCEL' }); const base = { - open: snapshot.status === 'active', dismissible: canCancel, - onOpenChange: (open: boolean) => { - if (!open) { - send({ type: 'CANCEL' }); - } - }, closeLabel: m.closeButton, error: context.error?.scope === 'flow' ? context.error.message : undefined, }; - const props = ((): ReverificationDialogProps => { + const props = ((): ReverificationDialogContentProps => { // Legacy gave this card no way back — there is no method to go back to. if (snapshot.value === 'unavailable') { return { @@ -170,8 +167,10 @@ export function ReverificationDialogView({ methods: methods.map(asMethod), onSelectMethod: factorKey => send({ type: 'SELECT_FACTOR', factorKey }), back: actor.can({ type: 'BACK' }) ? { label: m.backButton, onClick: () => send({ type: 'BACK' }) } : undefined, - cancelLabel: m.formButtonReset, - help: { label: m.alternativeMethods.actionLink, onClick: () => send({ type: 'SHOW_HELP' }) }, + help: { + text: m.alternativeMethods.actionText, + action: { label: m.alternativeMethods.actionLink, onClick: () => send({ type: 'SHOW_HELP' }) }, + }, }; } @@ -234,13 +233,44 @@ export function ReverificationDialogView({ isPending, onSubmit: () => send({ type: 'SUBMIT' }), cancelLabel: m.formButtonReset, - secondary: actor.can({ type: 'SHOW_ALTERNATIVES' }) + alternative: actor.can({ type: 'SHOW_ALTERNATIVES' }) ? { label: m.footerActionLink__useAnotherMethod, onClick: () => send({ type: 'SHOW_ALTERNATIVES' }) } - : actor.can({ type: 'SHOW_HELP' }) - ? { label: m.alternativeMethods.actionLink, onClick: () => send({ type: 'SHOW_HELP' }) } - : undefined, + : undefined, + help: actor.can({ type: 'SHOW_HELP' }) + ? { + text: m.alternativeMethods.actionText, + action: { label: m.alternativeMethods.actionLink, onClick: () => send({ type: 'SHOW_HELP' }) }, + } + : undefined, }; })(); - return ; + return ( + { + if (!open) { + send({ type: 'CANCEL' }); + } + }} + > + + + + + } + > + + + + + + ); } diff --git a/references/reverification-architecture.md b/references/reverification-architecture.md index fbd04e8f671..d9411282dc2 100644 --- a/references/reverification-architecture.md +++ b/references/reverification-architecture.md @@ -4,7 +4,7 @@ This document records the legacy reverification behavior that the Mosaic replace deliberately change. It also records the architecture used by the current Mosaic work. The legacy implementation is under `packages/ui/src/components/UserVerification/`. The Mosaic implementation is one -block module under `packages/ui/src/mosaic/blocks/reverification-dialog/`. +block module under `packages/ui/src/mosaic/blocks/reverification/`. ## Architecture decision @@ -21,8 +21,12 @@ actor-owning view +----------> pure controller | v -controlled renderer - renders props; owns only transient state no outer layer can use +standalone interaction + renders the reverification body from controlled props + | + v +dialog content + adds dialog header and footer chrome without owning the root ``` This is the pattern implemented by `origin/carp/mosaic-user-profile-delete-account`: @@ -35,14 +39,14 @@ This is the pattern implemented by `origin/carp/mosaic-user-profile-delete-accou The reverification implementation follows the same shape: -- `ReverificationDialogView` accepts an initial challenge plus `prepare`, `attempt`, `onComplete`, and `onCancel`; it +- `ReverificationView` accepts an initial challenge plus `prepare`, `attempt`, `onComplete`, and `onCancel`; it owns the actor and derives `actor.can(...)` values. -- `reverificationDialogController` owns factor selection, preparation, submission, resend, help, completion, and +- `reverificationController` owns factor selection, preparation, submission, resend, help, completion, and cancellation transitions. -- `ReverificationDialog` is the block's controlled, stateless renderer. The answer belongs to the controller because +- `Reverification` is the block's standalone interaction and `ReverificationDialogContent` composes it with dialog chrome. The answer belongs to the controller because guards and attempts use it. -The controller, actor-owning view, renderer, messages, and shared vocabulary are internal roles of one cohesive block +The controller, actor-owning view, standalone interaction, dialog content, messages, and shared vocabulary are internal roles of one cohesive block module. They are colocated behind one `index.ts`. No separate controller is required to match that precedent. Before the flow becomes reachable, it will still need a @@ -170,7 +174,7 @@ message. ## Interface review -The controller/view/renderer split is sound, but the current external interface is shallower than the delete-account +The controller/view/interaction split is sound, but the current external interface is shallower than the delete-account precedent. Delete account asks its caller for one operation. Reverification asks its caller to understand factor IDs, stage tags, initial-factor policy, lossy resource translation, preparation, attempt result normalization, activation, and modal completion semantics. @@ -194,8 +198,8 @@ moving any of this policy would not. ## Verification snapshot -On the current branch, the three targeted files contain 49 tests: 19 controller, 16 actor-owning view, and 14 block tests. -The targeted Vitest run passes all 49. The run reports that Vite did not exit within its +On the current branch, the four targeted files contain 51 tests: 19 controller, 16 actor-owning view, 14 dialog-content, +and 2 standalone interaction tests. The targeted Vitest run passes all 51. The run reports that Vite did not exit within its 10-second close timeout, although Vitest reports the tests themselves closed successfully. Browser QA was not performed as part of this review. From a3e8a586a83e40404cf165f8d4efcc6efed89ac6 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 26 Aug 2026 19:55:05 -0600 Subject: [PATCH 11/12] docs(swingset): update reverification examples --- .../swingset/src/components/DocsViewer.tsx | 2 +- packages/swingset/src/lib/registry.ts | 13 ++-- ...fication-dialog.mdx => reverification.mdx} | 67 +++++++------------ ...stories.tsx => reverification.stories.tsx} | 12 ++-- 4 files changed, 37 insertions(+), 57 deletions(-) rename packages/swingset/src/stories/{reverification-dialog.mdx => reverification.mdx} (54%) rename packages/swingset/src/stories/{reverification-dialog.stories.tsx => reverification.stories.tsx} (93%) diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 7912339b031..c43d08a092e 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -39,7 +39,7 @@ const docModules: Record> = { }, blocks: { destructive: dynamic(() => import('../stories/destructive.mdx')), - 'reverification-dialog': dynamic(() => import('../stories/reverification-dialog.mdx')), + reverification: dynamic(() => import('../stories/reverification.mdx')), }, components: { avatar: dynamic(() => import('../stories/avatar.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 84ab5629e6a..743709bfc80 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -74,10 +74,7 @@ import { Placement as PopoverComponentPlacement, } from '../stories/popover.component.stories'; import { meta as popoverMeta } from '../stories/popover.stories'; -import { - Default as ReverificationDialogDefault, - meta as reverificationDialogMeta, -} from '../stories/reverification-dialog.stories'; +import { Default as ReverificationDefault, meta as reverificationMeta } from '../stories/reverification.stories'; import { Default as ScrollAreaDefault, Gutter as ScrollAreaGutter, @@ -292,9 +289,9 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; -const reverificationDialogModule: StoryModule = { - meta: reverificationDialogMeta, - Default: ReverificationDialogDefault, +const reverificationModule: StoryModule = { + meta: reverificationMeta, + Default: ReverificationDefault, }; const userProfileApiKeysPanelModule: StoryModule = { @@ -400,7 +397,7 @@ export const registry: StoryModule[] = [ userProfileDeleteSectionModule, // Blocks — flows assembled from components, wired by the caller's machine. destructiveModule, - reverificationDialogModule, + reverificationModule, // Components avatarModule, badgeModule, diff --git a/packages/swingset/src/stories/reverification-dialog.mdx b/packages/swingset/src/stories/reverification.mdx similarity index 54% rename from packages/swingset/src/stories/reverification-dialog.mdx rename to packages/swingset/src/stories/reverification.mdx index 647b10b3e92..73bcdadda0e 100644 --- a/packages/swingset/src/stories/reverification-dialog.mdx +++ b/packages/swingset/src/stories/reverification.mdx @@ -1,12 +1,12 @@ -import * as Stories from './reverification-dialog.stories'; +import * as Stories from './reverification.stories'; -# ReverificationDialog +# Reverification -The dialog that asks a user to prove who they are before a sensitive action. It renders one of three steps — pick a method, satisfy it, or a dead end — and owns none of the flow between them. +The interaction that asks a user to prove who they are before a sensitive action. `Reverification` renders the standalone interaction; `ReverificationDialogContent` adds dialog header and footer chrome without owning a dialog root. ## Example -The launch buttons are showcase controls, not part of the block. Each one mounts `ReverificationDialogView`, whose controller drives method selection, code delivery, automatic submission, errors, the resend cooldown, completion, and cancellation against stubbed operations. +The launch buttons are showcase controls, not part of the block. Each one mounts `ReverificationView`, whose controller drives method selection, code delivery, automatic submission, errors, the resend cooldown, completion, and cancellation against stubbed operations. 0} isPending={isPending} onSubmit={submit} - cancelLabel='Cancel' />; ``` -The action sits in the footer, outside the field's form, so pressing Enter in the field submits the same way the button does. +For dialog use, render `ReverificationDialogContent` inside the owning `Dialog.Root`. It composes the same interaction with the title, description, close control, and actions. The action sits in the footer outside the field's form, so pressing Enter submits the same way the button does. A reverification is raised by something the user has already started — deleting an account, revoking a session — so it opens over the dialog that asked, not over the page. That makes it a stacked surface, and per the [Dialog](/components/dialog) page's "Nested dialogs and stacks", the thing that opens is always a `prompt`. -This block is `size='card'`, which is a root-level surface. Opened inside another dialog it warns in development and -takes the nested treatment — its own scrim over the host's, with the surface beneath neither dimming nor receding, and -both surfaces at the same width so the one underneath is hidden rather than showing behind. Until the size is a prop, -compose it at the root only. +`ReverificationDialogContent` deliberately does not choose a size or create a portal. The owning dialog decides whether +the surface is root-level or stacked. ## Props Shared by every step: -| Prop | Type | Description | -| -------------- | ----------------------------------- | -------------------------------------------------------------------------------------- | -| `step` | `'choose' \| 'verify' \| 'message'` | Which step is showing. Picks the rest of the props. | -| `open` | `boolean` | Whether the dialog is showing. Controlled, the way any dialog is. | -| `onOpenChange` | `(open: boolean) => void` | Asks to open or close through an allowed close control or request. | -| `dismissible` | `boolean` | Enables close controls and close requests. | -| `title` | `string` | Names what is being asked. | -| `description` | `string` | Spells out what the user has to do. | -| `closeLabel` | `string` | Accessible name for the corner close button. | -| `error` | `string` | Optional. A failure that belongs to the step rather than to a field. Read as an alert. | +| Prop | Type | Description | +| ------- | ----------------------------------- | -------------------------------------------------------------------------------------- | +| `step` | `'choose' \| 'verify' \| 'message'` | Which step is showing. Picks the rest of the props. | +| `error` | `string` | Optional. A failure that belongs to the step rather than to a field. Read as an alert. | `step='choose'` — pick a method: -| Prop | Type | Description | -| ---------------- | ---------------------- | -------------------------------------------------------------------- | -| `methods` | `{ id, label }[]` | The methods to offer. `id` is opaque and handed straight back. | -| `onSelectMethod` | `(id: string) => void` | Asks the caller to switch to that method. | -| `back` | `{ label, onClick }` | Optional. Returns to the method the user came from. Replaces Cancel. | -| `cancelLabel` | `string` | Label for Cancel, shown when there is nothing to go back to. | -| `help` | `{ label, onClick }` | The way out for a user who has none of these methods. | +| Prop | Type | Description | +| ---------------- | ---------------------- | ------------------------------------------------------------------- | +| `methods` | `{ id, label }[]` | The methods to offer. `id` is opaque and handed straight back. | +| `onSelectMethod` | `(id: string) => void` | Asks the caller to switch to that method. | +| `back` | `{ label, onClick }` | Optional. Returns to the method the user came from. | +| `help` | `{ text, action }` | Support prompt and action for a user who has none of these methods. | `step='verify'` — satisfy one method: @@ -95,7 +77,8 @@ Shared by every step: | `isPending` | `boolean` | Renders the action pending and blocks a second submit. | | `onSubmit` | `() => void` | Asks the caller to check the answer. Reached by the button or by Enter. | | `cancelLabel` | `string` | The dismiss button's label. | -| `secondary` | `{ label, onClick }` | Optional. The one escape this step offers — another method, or help. | +| `alternative` | `{ label, onClick }` | Optional. Navigates to another verification method within the card content. | +| `help` | `{ text, action }` | Optional. Support escalation rendered separately in the card footer. | `step='message'` — a dead end: @@ -106,14 +89,14 @@ Shared by every step: ## Driving it from a controller -`ReverificationDialogView` wires the block to `reverificationDialogController`, which holds every rule about what happens next: which method starts, when a code is sent, when six digits submit on their own, how long resend stays inert, and where a first-factor success leads. The Clerk work arrives as `prepare` and `attempt`, so the whole flow runs against plain promises in a test or a story. +`ReverificationView` wires the block to `reverificationController`, which holds every rule about what happens next: which method starts, when a code is sent, when six digits submit on their own, how long resend stays inert, and where a first-factor success leads. The Clerk work arrives as `prepare` and `attempt`, so the whole flow runs against plain promises in a test or a story. `onComplete` is awaited: the dialog stays up and pending until it resolves, so the session is active before whatever asked for reverification runs again. If it rejects, the verified result is retained and the user can retry completion without answering the factor again. ```tsx -import { ReverificationDialogView } from '@clerk/ui/mosaic/blocks/reverification-dialog'; +import { ReverificationView } from '@clerk/ui/mosaic/blocks/reverification'; - session.prepareFirstFactorVerification(factor)} attempt={attempt => session.attemptFirstFactorVerification(attempt)} diff --git a/packages/swingset/src/stories/reverification-dialog.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx similarity index 93% rename from packages/swingset/src/stories/reverification-dialog.stories.tsx rename to packages/swingset/src/stories/reverification.stories.tsx index 38ff5d847b6..8a3a792ec87 100644 --- a/packages/swingset/src/stories/reverification-dialog.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -11,19 +11,19 @@ import type { ReverificationPreparationFactor, ReverificationSecondFactor, ReverificationSecondFactorPhoneCodeFactor, -} from '@clerk/ui/mosaic/blocks/reverification-dialog'; -import { ReverificationDialogView } from '@clerk/ui/mosaic/blocks/reverification-dialog'; +} from '@clerk/ui/mosaic/blocks/reverification'; +import { ReverificationView } from '@clerk/ui/mosaic/blocks/reverification'; import { Button } from '@clerk/ui/mosaic/components/button'; import React from 'react'; import type { StoryMeta } from '@/lib/types'; -export { default as __source } from './reverification-dialog.stories?raw'; +export { default as __source } from './reverification.stories?raw'; export const meta: StoryMeta = { group: 'Blocks', - title: 'ReverificationDialog', - source: 'packages/ui/src/mosaic/blocks/reverification-dialog/reverification-dialog.view.tsx', + title: 'Reverification', + source: 'packages/ui/src/mosaic/blocks/reverification/reverification.view.tsx', }; const passwordFactor: ReverificationPasswordFactor = { @@ -152,7 +152,7 @@ function ControllerDrivenDialog({ scenario, onFinished }: { scenario: Scenario; ); return ( - Date: Wed, 26 Aug 2026 21:08:54 -0600 Subject: [PATCH 12/12] fix(ui): align reverification cooldown with legacy behavior --- .../reverification.controller.test.ts | 157 ++++++++++++-- .../reverification.controller.ts | 95 +++----- .../reverification.view.test.tsx | 12 +- references/reverification-architecture.md | 205 ------------------ 4 files changed, 180 insertions(+), 289 deletions(-) delete mode 100644 references/reverification-architecture.md diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.controller.test.ts b/packages/ui/src/mosaic/blocks/reverification/reverification.controller.test.ts index b7ee10b6ae6..64ee7b1d0df 100644 --- a/packages/ui/src/mosaic/blocks/reverification/reverification.controller.test.ts +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.controller.test.ts @@ -5,6 +5,7 @@ import { reverificationController, reverificationFactorKey } from './reverificat import type { ReverificationAttempt, ReverificationAttemptResult, + ReverificationBackupCodeFactor, ReverificationChallenge, ReverificationCompleteResult, ReverificationEmailCodeFactor, @@ -39,6 +40,11 @@ const totpFactor: ReverificationTOTPFactor = { strategy: 'totp', }; +const backupCodeFactor: ReverificationBackupCodeFactor = { + stage: 'second', + strategy: 'backup_code', +}; + const secondPhoneFactor: ReverificationSecondFactorPhoneCodeFactor = { stage: 'second', strategy: 'phone_code', @@ -339,7 +345,8 @@ describe('reverificationController', () => { expect(prepare).toHaveBeenNthCalledWith(3, emailFactor); }); - it('stays on the current factor when preparation fails, and retries through resend', async () => { + it('holds the send cooldown when preparation fails, and retries through resend', async () => { + vi.useFakeTimers(); const prepare = vi .fn<(factor: ReverificationPreparationFactor) => Promise>() .mockRejectedValueOnce(new Error('Could not send the code.')) @@ -351,26 +358,95 @@ describe('reverificationController', () => { expect(actor.getSnapshot()).toMatchObject({ value: 'preparing', - context: { currentFactor: emailFactor, resendSecondsRemaining: 0 }, + context: { currentFactor: emailFactor, resendSecondsRemaining: 30 }, }); expect(actor.can({ type: 'RESEND' })).toBe(false); expect(actor.can({ type: 'SHOW_ALTERNATIVES' })).toBe(true); - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('preparationFailed')); - expect(actor.getSnapshot().context).toMatchObject({ - currentFactor: emailFactor, - error: { scope: 'flow', message: 'Could not send the code.' }, - resendSecondsRemaining: 0, + await vi.runAllTicks(); + expect(actor.getSnapshot()).toMatchObject({ + value: 'verifyingCooldown', + context: { + currentFactor: emailFactor, + error: { scope: 'flow', message: 'Could not send the code.' }, + resendSecondsRemaining: 30, + }, }); - expect(actor.can({ type: 'RESEND' })).toBe(true); - expect(actor.can({ type: 'SHOW_ALTERNATIVES' })).toBe(true); + expect(actor.can({ type: 'RESEND' })).toBe(false); + await vi.advanceTimersByTimeAsync(30_000); + expect(actor.getSnapshot().value).toBe('verifying'); actor.send({ type: 'RESEND' }); - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); + await vi.runAllTicks(); + expect(actor.getSnapshot().value).toBe('verifyingCooldown'); expect(prepare).toHaveBeenCalledTimes(2); }); - it('returns verification failures to the field and clears them on input', async () => { + it('does not resend inside the cooldown when alternatives are opened after a failed send', async () => { + vi.useFakeTimers(); + const prepare = vi + .fn<(factor: ReverificationPreparationFactor) => Promise>() + .mockRejectedValueOnce(new Error('Could not send the code.')) + .mockResolvedValue(undefined); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactor: emailFactor }), + prepare, + }); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(10_000); + + actor.send({ type: 'SHOW_ALTERNATIVES' }); + expect(actor.getSnapshot().value).toBe('selectingFactor'); + actor.send({ type: 'BACK' }); + await vi.runAllTicks(); + + // The factor is still unprepared, but the cooldown from the failed send outranks that. + expect(actor.getSnapshot()).toMatchObject({ + value: 'verifyingCooldown', + context: { preparedFactorKey: null, resendSecondsRemaining: 20 }, + }); + expect(prepare).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(20_000); + expect(actor.getSnapshot().value).toBe('verifying'); + expect(prepare).toHaveBeenCalledOnce(); + }); + + it('throttles from when the send was issued, not from when it landed', async () => { + vi.useFakeTimers(); + const prepare = vi + .fn<(factor: ReverificationPreparationFactor) => Promise>() + .mockImplementation(() => new Promise(resolve => setTimeout(resolve, 5_000))); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactor: emailFactor }), + prepare, + }); + expect(actor.getSnapshot().value).toBe('preparing'); + + await vi.advanceTimersByTimeAsync(5_000); + expect(actor.getSnapshot()).toMatchObject({ + value: 'verifyingCooldown', + context: { resendSecondsRemaining: 25 }, + }); + + await vi.advanceTimersByTimeAsync(25_000); + expect(actor.getSnapshot().value).toBe('verifying'); + }); + + it('clears a half-entered code when a new one is sent', async () => { + vi.useFakeTimers(); + const { actor } = start({ challenge: firstFactorChallenge({ initialFactor: emailFactor }) }); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(30_000); + + actor.send({ type: 'CHANGE_VALUE', value: '123' }); + expect(actor.getSnapshot().context.value).toBe('123'); + + actor.send({ type: 'RESEND' }); + expect(actor.getSnapshot().context.value).toBe(''); + }); + + it('keeps a rejected password in the field and clears the error on input', async () => { const attempt = vi .fn<(attempt: ReverificationAttempt) => Promise>() .mockRejectedValue({ scope: 'answer', message: 'Incorrect password.' }); @@ -381,13 +457,56 @@ describe('reverificationController', () => { await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifying')); expect(actor.getSnapshot().context).toMatchObject({ - value: '', + value: 'wrong', error: { scope: 'answer', message: 'Incorrect password.' }, }); actor.send({ type: 'CHANGE_VALUE', value: 'new value' }); expect(actor.getSnapshot().context.error).toBeNull(); }); + it('keeps a rejected backup code in the field', async () => { + const attempt = vi + .fn<(attempt: ReverificationAttempt) => Promise>() + .mockRejectedValue({ scope: 'answer', message: 'Incorrect backup code.' }); + const { actor } = start({ + challenge: { + status: 'needs_second_factor', + factors: [backupCodeFactor], + initialFactor: backupCodeFactor, + }, + attempt, + }); + + actor.send({ type: 'CHANGE_VALUE', value: 'abcd-efgh' }); + actor.send({ type: 'SUBMIT' }); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifying')); + + expect(actor.getSnapshot().context).toMatchObject({ + value: 'abcd-efgh', + error: { scope: 'answer', message: 'Incorrect backup code.' }, + }); + }); + + it('clears a rejected one-time code so the next one can be typed', async () => { + vi.useFakeTimers(); + const attempt = vi + .fn<(attempt: ReverificationAttempt) => Promise>() + .mockRejectedValue({ scope: 'answer', message: 'Incorrect code.' }); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactor: emailFactor }), + attempt, + }); + await vi.runAllTicks(); + + actor.send({ type: 'CHANGE_VALUE', value: '123456' }); + await vi.runAllTicks(); + + expect(actor.getSnapshot().context).toMatchObject({ + value: '', + error: { scope: 'answer', message: 'Incorrect code.' }, + }); + }); + it('owns resend cooldown and only retries after it expires', async () => { vi.useFakeTimers(); const prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined); @@ -407,7 +526,7 @@ describe('reverificationController', () => { await vi.advanceTimersByTimeAsync(30_000); expect(actor.getSnapshot().value).toBe('verifying'); actor.send({ type: 'RESEND' }); - expect(actor.getSnapshot().value).toBe('resending'); + expect(actor.getSnapshot().value).toBe('preparing'); await vi.runAllTicks(); expect(actor.getSnapshot()).toMatchObject({ value: 'verifyingCooldown', @@ -416,7 +535,7 @@ describe('reverificationController', () => { expect(prepare).toHaveBeenCalledTimes(2); }); - it('allows immediate resend retry after a resend failure', async () => { + it('holds the cooldown after a failed resend', async () => { vi.useFakeTimers(); const prepare = vi .fn<(factor: ReverificationPreparationFactor) => Promise>() @@ -433,15 +552,19 @@ describe('reverificationController', () => { actor.send({ type: 'RESEND' }); await vi.runAllTicks(); expect(actor.getSnapshot()).toMatchObject({ - value: 'verifying', + value: 'verifyingCooldown', context: { - resendSecondsRemaining: 0, + resendSecondsRemaining: 30, error: { scope: 'flow', message: 'Rate limited.' }, }, }); + expect(actor.can({ type: 'RESEND' })).toBe(false); + expect(prepare).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(30_000); + expect(actor.getSnapshot().value).toBe('verifying'); actor.send({ type: 'RESEND' }); - expect(actor.getSnapshot().value).toBe('resending'); expect(prepare).toHaveBeenCalledTimes(3); }); diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.controller.ts b/packages/ui/src/mosaic/blocks/reverification/reverification.controller.ts index c592684ed8d..abc6227890a 100644 --- a/packages/ui/src/mosaic/blocks/reverification/reverification.controller.ts +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.controller.ts @@ -33,7 +33,9 @@ export interface ReverificationControllerContext { preparedFactorKey: string | null; /** The successful verification retained while completion runs or retries. */ verification: ReverificationCompleteResult | null; - /** Seconds until another delivered code may be requested. */ + /** Timestamp after which another delivered code may be requested. */ + resendAvailableAt: number | null; + /** Seconds left on {@link ReverificationControllerContext.resendAvailableAt}, for display. */ resendSecondsRemaining: number; /** Sends a code. Injected by the view from its `prepare` prop. */ prepare: (factor: ReverificationPreparationFactor) => Promise; @@ -60,6 +62,11 @@ const { createMachine, assign, fromPromise } = setup context.challenge.factors; +const secondsUntilResend = (context: ReverificationControllerContext) => + context.resendAvailableAt === null ? 0 : Math.max(0, Math.ceil((context.resendAvailableAt - Date.now()) / 1000)); + +const isCoolingDown = (context: ReverificationControllerContext) => secondsUntilResend(context) > 0; + export const reverificationFactorKey = (factor: ReverificationFactor): string => { switch (factor.strategy) { case 'email_code': @@ -174,6 +181,7 @@ export const reverificationController = createMachine({ error: null, preparedFactorKey: null, verification: null, + resendAvailableAt: null, resendSecondsRemaining: 0, prepare: () => Promise.resolve(), attempt: () => Promise.resolve({ status: 'complete', sessionId: '' }), @@ -197,6 +205,7 @@ export const reverificationController = createMachine({ error: null, preparedFactorKey: null, verification: null, + resendAvailableAt: null, resendSecondsRemaining: 0, }; }), @@ -216,6 +225,7 @@ export const reverificationController = createMachine({ value: '', error: null, preparedFactorKey: null, + resendAvailableAt: null, resendSecondsRemaining: 0, })), }, @@ -232,20 +242,28 @@ export const reverificationController = createMachine({ routingFactor: { always: [ { target: 'unavailable', guard: context => !context.currentFactor }, + // Ahead of the preparation guard: a failed send leaves the factor unprepared, and + // routing straight back into `preparing` would resend inside its own cooldown. + { target: 'verifyingCooldown', guard: isCoolingDown }, { target: 'preparing', guard: context => requiresPreparation(context.currentFactor) && context.preparedFactorKey !== reverificationFactorKey(context.currentFactor), }, - { - target: 'verifyingCooldown', - guard: context => context.resendSecondsRemaining > 0, - }, { target: 'verifying' }, ], }, preparing: { + // The cooldown is committed when the request goes out, not when it lands, so a slow or + // failing send cannot be retried sooner than a fast one. Matches legacy's TimerButton, + // which disabled itself on click rather than on response. + entry: assign(() => ({ + resendAvailableAt: Date.now() + RESEND_COOLDOWN_SECONDS * 1000, + resendSecondsRemaining: RESEND_COOLDOWN_SECONDS, + value: '', + error: null, + })), invoke: fromPromise( context => { if (!requiresPreparation(context.currentFactor)) { @@ -258,12 +276,13 @@ export const reverificationController = createMachine({ target: 'verifyingCooldown', actions: assign(context => ({ preparedFactorKey: context.currentFactor ? reverificationFactorKey(context.currentFactor) : null, - resendSecondsRemaining: RESEND_COOLDOWN_SECONDS, error: null, })), }, + // Legacy had no failure screen: the error lands on the code card the user is already + // looking at, and an earlier code stays submittable. onError: { - target: 'preparationFailed', + target: 'verifyingCooldown', actions: assign((_, event) => ({ error: errorFrom(event.error) })), }, }, @@ -273,21 +292,11 @@ export const reverificationController = createMachine({ CANCEL: 'cancelled', }, }, - preparationFailed: { - on: { - RESEND: 'preparing', - SHOW_ALTERNATIVES: { - target: 'selectingFactor', - guard: hasAlternatives, - }, - CANCEL: 'cancelled', - }, - }, verifying: { on: { CHANGE_VALUE: changeValue, SUBMIT: { target: 'submitting', guard: canSubmit }, - RESEND: { target: 'resending', guard: context => requiresPreparation(context.currentFactor) }, + RESEND: { target: 'preparing', guard: context => requiresPreparation(context.currentFactor) }, SHOW_ALTERNATIVES: { target: 'selectingFactor', guard: hasAlternatives }, SHOW_HELP: { target: 'helpFromFactor', @@ -297,6 +306,7 @@ export const reverificationController = createMachine({ }, }, verifyingCooldown: { + entry: assign(context => ({ resendSecondsRemaining: secondsUntilResend(context) })), on: { CHANGE_VALUE: changeValue, SUBMIT: { target: 'submitting', guard: canSubmit }, @@ -309,17 +319,8 @@ export const reverificationController = createMachine({ }, after: { 1000: [ - { - target: 'verifyingCooldown', - guard: context => context.resendSecondsRemaining > 1, - actions: assign(context => ({ - resendSecondsRemaining: context.resendSecondsRemaining - 1, - })), - }, - { - target: 'verifying', - actions: assign(() => ({ resendSecondsRemaining: 0 })), - }, + { target: 'verifyingCooldown', guard: isCoolingDown }, + { target: 'verifying', actions: assign(() => ({ resendSecondsRemaining: 0 })) }, ], }, }, @@ -351,47 +352,17 @@ export const reverificationController = createMachine({ }), }, ], + // Legacy reset only the OTP control; password and backup code kept what was typed. onError: ({ context, event }) => ({ - target: context.resendSecondsRemaining > 0 ? 'verifyingCooldown' : 'verifying', + target: isCoolingDown(context) ? 'verifyingCooldown' : 'verifying', context: { - value: '', + value: isFixedLengthCode(context.currentFactor) ? '' : context.value, error: errorFrom(event.error), }, }), }), on: { CANCEL: 'cancelled' }, }, - resending: { - invoke: fromPromise( - context => { - if (!requiresPreparation(context.currentFactor)) { - return Promise.reject(new Error(m.unstable__errors__generic)); - } - return context.prepare(context.currentFactor); - }, - { - onDone: { - target: 'verifyingCooldown', - actions: assign(context => ({ - preparedFactorKey: context.currentFactor ? reverificationFactorKey(context.currentFactor) : null, - resendSecondsRemaining: RESEND_COOLDOWN_SECONDS, - error: null, - })), - }, - onError: { - target: 'verifying', - actions: assign((_, event) => ({ - resendSecondsRemaining: 0, - error: errorFrom(event.error), - })), - }, - }, - ), - on: { - SHOW_ALTERNATIVES: { target: 'selectingFactor', guard: hasAlternatives }, - CANCEL: 'cancelled', - }, - }, helpFromSelection: { on: { BACK: 'selectingFactor', diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.view.test.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification.view.test.tsx index f7951571e89..2ab31fe41c6 100644 --- a/packages/ui/src/mosaic/blocks/reverification/reverification.view.test.tsx +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.view.test.tsx @@ -141,7 +141,7 @@ describe('ReverificationView', () => { await waitFor(() => expect(prepare).toHaveBeenCalledWith(emailFactor)); }); - it('keeps the code step when the code could not be sent, and resends from there', async () => { + it('keeps the code step usable when the code could not be sent, and holds the resend', async () => { const prepare = vi .fn<(factor: ReverificationPreparationFactor) => Promise>() .mockRejectedValueOnce(new Error('Could not send the code.')) @@ -156,11 +156,13 @@ describe('ReverificationView', () => { }); expect(await screen.findByRole('alert')).toHaveTextContent('Could not send the code.'); - expect(codeSlots()).toHaveLength(6); - - await userEvent.setup().click(screen.getByRole('button', { name: /Resend/ })); - await waitFor(() => expect(prepare).toHaveBeenCalledTimes(2)); + // Legacy showed the failure on the code card rather than replacing it: an earlier code + // stays submittable, and the resend sits behind the cooldown the failed send started. + expect(codeSlots()).toHaveLength(6); + expect(codeSlots()[0]).toBeEnabled(); + expect(screen.getByRole('button', { name: /Resend/ })).toHaveAttribute('aria-disabled', 'true'); + expect(prepare).toHaveBeenCalledOnce(); }); it('counts the resend cooldown down in the label and holds the button inert', async () => { diff --git a/references/reverification-architecture.md b/references/reverification-architecture.md deleted file mode 100644 index d9411282dc2..00000000000 --- a/references/reverification-architecture.md +++ /dev/null @@ -1,205 +0,0 @@ -# Legacy reverification flow and Mosaic migration review - -This document records the legacy reverification behavior that the Mosaic replacement must either preserve or -deliberately change. It also records the architecture used by the current Mosaic work. - -The legacy implementation is under `packages/ui/src/components/UserVerification/`. The Mosaic implementation is one -block module under `packages/ui/src/mosaic/blocks/reverification/`. - -## Architecture decision - -For this work, the established implementation pattern is: - -```text -integration caller - supplies plain data and async operations - | - v -actor-owning view - creates the controller actor, renders snapshots, emits events - | - +----------> pure controller - | - v -standalone interaction - renders the reverification body from controlled props - | - v -dialog content - adds dialog header and footer chrome without owning the root -``` - -This is the pattern implemented by `origin/carp/mosaic-user-profile-delete-account`: - -- `UserProfileDeleteSectionView` accepts `onDelete`, calls `useMachine`, derives block props from the snapshot, and - sends events. -- `userProfileDeleteSectionMachine` owns the flow and invokes the injected delete operation. -- `Destructive` is a controlled block. It owns only the half-typed confirmation phrase because nothing outside the - block can use it. - -The reverification implementation follows the same shape: - -- `ReverificationView` accepts an initial challenge plus `prepare`, `attempt`, `onComplete`, and `onCancel`; it - owns the actor and derives `actor.can(...)` values. -- `reverificationController` owns factor selection, preparation, submission, resend, help, completion, and - cancellation transitions. -- `Reverification` is the block's standalone interaction and `ReverificationDialogContent` composes it with dialog chrome. The answer belongs to the controller because - guards and attempts use it. - -The controller, actor-owning view, standalone interaction, dialog content, messages, and shared vocabulary are internal roles of one cohesive block -module. They are colocated behind one `index.ts`. - -No separate controller is required to match that precedent. Before the flow becomes reachable, it will still need a -production integration wrapper that translates Clerk resources into the plain interface above. That wrapper is an -adapter, regardless of whether the codebase calls it a controller. - -This convention conflicts with the current `references/mosaic-architecture.md`, which describes a controller owning -the actor and a view receiving a fake snapshot and `send`. The implementation and its tests should be reviewed against -one convention consistently. Under the convention selected here, asking the view to own the actor is intentional. - -## Legacy end-to-end lifecycle - -The dialog is only one part of reverification. The full legacy lifecycle is: - -1. `useReverification(fetcher)` calls the protected operation. -2. A `session_reverification_required` result opens the internal reverification modal with a required level and two - callbacks. -3. Closing the modal calls `afterVerificationCancelled`, rejects the protected operation with - `reverification_cancelled`, and does not retry it. -4. The UI calls `session.startVerification({ level })`. An absent level defaults to `second_factor`. The request is - cached by level and the cache is invalidated when the flow unmounts. -5. The returned `SessionVerificationResource.status` selects first-factor or second-factor UI. -6. The chosen method is prepared when necessary and attempted. -7. `needs_second_factor` updates the cached verification resource and routes to the second-factor step. -8. `complete` updates the cache, awaits `clerk.setActive({ session: response.session.id })`, then calls - `afterVerification`. The modal closes without firing cancellation, and `useReverification` retries the original - protected operation once. - -The ordering in step 8 is load-bearing. Completion is not merely a notification that an attempt returned -`complete`; session activation must finish before the protected operation is retried. - -## Legacy factor selection - -### First factor - -The legacy flow: - -- keeps only `password`, `email_code`, `phone_code`, and `passkey`; -- gives a primary email address or phone number priority among otherwise equivalent factors; -- uses the instance's preferred sign-in strategy to choose between password and one-time-code ordering; -- prefers a supported passkey before either ordering; -- compares email and phone factors by their resource ID, rather than treating every factor with the same strategy as - identical; -- filters passkeys from the alternatives list when WebAuthn is unavailable; and -- sorts alternatives as email code, phone code, passkey, then password. - -The initial-factor helper has an edge case: it can still fall back to a passkey in some unsupported-WebAuthn factor -sets because passkeys remain in the array used by the fallback sort. The Mosaic integration should preserve the -intended capability check, not that bug. - -If no first factor can be selected, legacy renders an unavailable `ErrorCard` rather than an alternatives list. - -### Second factor - -The legacy flow: - -- keeps `phone_code`, `totp`, and `backup_code`; -- starts with TOTP, otherwise phone code, otherwise the first remaining factor; -- compares phone factors by phone-number ID and other factors by strategy; and -- sorts alternatives as TOTP, phone code, then backup code. - -Landing on the wrong route is corrected from the verification resource status: first factor routes forward to second -factor, while second factor routes back to first factor. - -## Legacy behavior by method - -| Method | Stage | Prepare | Attempt | Other behavior | -| ----------- | ------ | ---------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| Password | First | No | `attemptFirstFactorVerification({ strategy: 'password', password })` | API field errors can land on the password field; global errors land on the card. | -| Email code | First | On entry and resend | Six digits automatically call `attemptFirstFactorVerification` | A successfully prepared factor is remembered to avoid preparing the unchanged factor again. | -| Phone code | First | On entry and resend | Six digits automatically call `attemptFirstFactorVerification` | Preparation carries the phone-number ID and the factor's `default` value. | -| Passkey | First | Inside `verifyWithPasskey()` | `verifyWithPasskey()` prepares WebAuthn, gets a credential, then attempts it | Alternatives omit passkey when WebAuthn is unavailable. | -| Phone code | Second | On entry and resend | Six digits automatically call `attemptSecondFactorVerification` | Preparation uses the phone-number ID. | -| TOTP | Second | No | Six digits automatically call `attemptSecondFactorVerification` | No resend action. | -| Backup code | Second | No | Form submission calls `attemptSecondFactorVerification` | API field errors can land on the backup-code field. | - -Code resend is throttled for 30 seconds by `TimerButton`. The legacy timer decrements an in-memory counter with -`setInterval`; it is not a wall-clock deadline. Moving to a `Date.now()` deadline would be a reliability improvement, -not legacy parity. The interval also remains mounted and continues decrementing while an attempt is in flight. The -Mosaic delayed transition belongs to `verifyingCooldown`, so entering `submitting` cancels that timer and returning -after a failed attempt resumes from the frozen count. - -## Errors, help, and unavailable states - -Legacy `handleError` inspects Clerk errors rather than choosing error placement from the active strategy: - -- errors with `meta.paramName` are mapped to the matching form control; -- the first global API error is rendered at card level; -- Clerk runtime errors render at card level; -- `reverification_cancelled` is ignored by general error UI; and -- unknown errors are rethrown. - -Preparation errors are sent to card-level error handling. OTP attempt errors reset the input after the error feedback -has been shown. - -Both help and unavailable states render `ErrorCard`. That surface includes an Email support action using the instance's -support email. Help also offers Back. The unavailable state includes all three pieces of copy: title, subtitle, and -message. - -## Mosaic parity audit - -| Legacy behavior | Status | Current Mosaic evidence or gap | -| --------------------------------------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Controller-owned selection, preparation, attempt, resend, and cancellation | Migrated | Explicit controller states and injected `prepare` / `attempt` operations. | -| First-factor success can continue to second factor | Migrated | `needs_second_factor` replaces the challenge and returns through `starting`. | -| Six-digit email, phone, and TOTP codes submit automatically | Migrated | `CHANGE_VALUE` normalizes to six digits and targets `submitting`. | -| The unchanged prepared factor is not prepared again after opening alternatives and going Back | Migrated | The module-derived `preparedFactorKey` survives `SHOW_ALTERNATIVES` / `BACK`. | -| Cancel calls its completion exactly once | Migrated | `cancelled` is a final state with a cancel entry action. | -| Start verification, loading, level default, and cache lifecycle | Deferred | The view starts from an already-built `ReverificationChallenge`; no Clerk integration exists. | -| Filter, capability-check, sort, and choose the starting factor | Deferred | The caller supplies ordered factors and an optional `initialFactor`; the module derives and validates identities. | -| Preserve Clerk preparation data | Deferred | The custom first-factor phone type omits `default`; an adapter must retain or look up the original factor. | -| Field error versus card error based on Clerk error metadata | Deferred integration | The controller accepts semantic `answer` / `flow` errors. The future adapter must normalize Clerk error metadata into that vocabulary. | -| Activate the completed session before retrying the protected action | Migrated contract | A complete attempt retains its `sessionId`; `completing` awaits `complete(result)` before entering the final state. | -| Update and invalidate the verification cache | Deferred | No integration layer exists. | -| Close without cancellation, then retry the protected operation once | Deferred | `onComplete` / `onCancel` remain injected operations until the integration adapter exists. | -| Email support from help and unavailable states | Migrated | Both message paths expose the injected support email as their primary action. | -| Unavailable title, subtitle, message, and support action | Partially migrated | The new message renders title, message, and support action, but still omits the subtitle. | -| Alternative-method explanatory text | Partially migrated | Legacy renders “Don’t have any of these?” next to Get help; the new choose footer renders only Get help. | -| Identifier formatting | Deferred | The new labels use `safeIdentifier` directly instead of `formatSafeIdentifier`. | -| Localization | Deferred | The block accepts strings, but the actor-owning view currently reads an English base object directly. | -| Resend timing | Deliberately changed | Mosaic starts the cooldown after successful prepare instead of mount/click. Its state-local timer also freezes during `submitting`; legacy's mounted interval keeps decrementing. Neither implementation uses a wall-clock deadline. | -| Empty or invalid `initialFactor` | Deliberately changed | Mosaic opens factor selection when factors exist; legacy first factor shows unavailable and second factor remains loading when no current factor is selected. | -| Submit empty or incomplete answers | Deliberately changed | Mosaic guards submit and automatically submits fixed-length codes; legacy's Continue path can invoke an OTP attempt with an empty value. | -| Password-only help path | Deliberately changed | Mosaic links directly to help; legacy reaches help through its alternatives surface. | - -## Interface review - -The controller/view/interaction split is sound, but the current external interface is shallower than the delete-account -precedent. Delete account asks its caller for one operation. Reverification asks its caller to understand factor IDs, -stage tags, initial-factor policy, lossy resource translation, preparation, attempt result normalization, activation, -and modal completion semantics. - -Before production integration: - -1. Add one integration wrapper that owns Clerk hooks/resources and translates them into the existing plain controller - dependencies. It does not need to be named or factored as a controller. -2. Make challenge construction one reusable function so filtering, capability checks, ordering, and initial-factor - selection cannot drift across callers or tests. Factor identities are already derived and duplicate identities - are rejected by the controller. -3. Normalize Clerk errors into the controller's `answer` / `flow` vocabulary. Plain rejected errors deliberately fall - back to flow-level messages rather than inferring placement from strategy. -4. Implement `onComplete(result)` by activating `result.sessionId`, then retrying the protected operation. A failed - completion now enters `completionFailed`; retry invokes only `complete(result)`, never the successful attempt. -5. Restore the unavailable subtitle and alternative-method explanatory text. -6. Wire the existing localization namespace before the flow is reachable. - -These items deepen the module by moving policy out of every future caller. Adding a pass-through controller without -moving any of this policy would not. - -## Verification snapshot - -On the current branch, the four targeted files contain 51 tests: 19 controller, 16 actor-owning view, 14 dialog-content, -and 2 standalone interaction tests. The targeted Vitest run passes all 51. The run reports that Vite did not exit within its -10-second close timeout, although Vitest reports the tests themselves closed successfully. - -Browser QA was not performed as part of this review.