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/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..c43d08a092e 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: 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 966e912370b..743709bfc80 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -74,6 +74,7 @@ import { Placement as PopoverComponentPlacement, } from '../stories/popover.component.stories'; import { meta as popoverMeta } from '../stories/popover.stories'; +import { Default as ReverificationDefault, meta as reverificationMeta } from '../stories/reverification.stories'; import { Default as ScrollAreaDefault, Gutter as ScrollAreaGutter, @@ -288,6 +289,11 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const reverificationModule: StoryModule = { + meta: reverificationMeta, + Default: ReverificationDefault, +}; + const userProfileApiKeysPanelModule: StoryModule = { meta: userProfileApiKeysPanelMeta, Default: UserProfileApiKeysPanelDefault, @@ -391,6 +397,7 @@ export const registry: StoryModule[] = [ userProfileDeleteSectionModule, // Blocks — flows assembled from components, wired by the caller's machine. destructiveModule, + reverificationModule, // Components avatarModule, badgeModule, diff --git a/packages/swingset/src/stories/reverification.mdx b/packages/swingset/src/stories/reverification.mdx new file mode 100644 index 00000000000..73bcdadda0e --- /dev/null +++ b/packages/swingset/src/stories/reverification.mdx @@ -0,0 +1,110 @@ +import * as Stories from './reverification.stories'; + +# Reverification + +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 `ReverificationView`, whose controller drives method selection, code delivery, automatic submission, errors, the resend cooldown, completion, and cancellation against stubbed operations. + + + +## Usage + +The block holds nothing. Every label and enabled/disabled decision belongs to the caller, so a step renders identically whether it was reached from a controller or from a story. `step` picks which one is showing. + +```tsx +import { Reverification } from '@clerk/ui/mosaic/blocks/reverification'; + + 0} + isPending={isPending} + onSubmit={submit} +/>; +``` + +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`. + +`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. | +| `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. | +| `help` | `{ text, action }` | Support prompt and action 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. | +| `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: + +| 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 controller + +`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 { ReverificationView } from '@clerk/ui/mosaic/blocks/reverification'; + + session.prepareFirstFactorVerification(factor)} + attempt={attempt => session.attemptFirstFactorVerification(attempt)} + onComplete={async result => { + await setActive({ session: result.sessionId }); + afterVerification(); + }} + onCancel={closeModal} + supportEmail={supportEmail} +/>; +``` diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx new file mode 100644 index 00000000000..8a3a792ec87 --- /dev/null +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -0,0 +1,198 @@ +import type { + ReverificationAttempt, + ReverificationAttemptResult, + ReverificationChallenge, + ReverificationCompleteResult, + ReverificationEmailCodeFactor, + ReverificationFirstFactor, + ReverificationFirstFactorPhoneCodeFactor, + ReverificationPasskeyFactor, + ReverificationPasswordFactor, + ReverificationPreparationFactor, + ReverificationSecondFactor, + ReverificationSecondFactorPhoneCodeFactor, +} 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.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + title: 'Reverification', + source: 'packages/ui/src/mosaic/blocks/reverification/reverification.view.tsx', +}; + +const passwordFactor: ReverificationPasswordFactor = { + stage: 'first', + strategy: 'password', +}; + +const emailFactor: ReverificationEmailCodeFactor = { + stage: 'first', + strategy: 'email_code', + emailAddressId: 'email_1', + safeIdentifier: 'a••••@clerk.dev', +}; + +const firstPhoneFactor: ReverificationFirstFactorPhoneCodeFactor = { + stage: 'first', + strategy: 'phone_code', + phoneNumberId: 'phone_1', + safeIdentifier: '••••4242', +}; + +const passkeyFactor: ReverificationPasskeyFactor = { + stage: 'first', + strategy: 'passkey', +}; + +const secondPhoneFactor: ReverificationSecondFactorPhoneCodeFactor = { + stage: 'second', + strategy: 'phone_code', + phoneNumberId: 'phone_2', + safeIdentifier: '••••8675', +}; + +const secondFactors: ReverificationSecondFactor[] = [ + secondPhoneFactor, + { 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 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 { + 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 => { + 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, initialFactor: passwordFactor }, + continuesToSecondFactor: true, + }, + { + id: 'choose-second', + label: 'Second factor — choose method', + challenge: { status: 'needs_second_factor', factors: secondFactors }, + }, + ...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)); + +function ControllerDrivenDialog({ 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', sessionId: 'sess_story' }; + }, + [scenario.continuesToSecondFactor], + ); + // The view finishes in a final state, so the story unmounts it to make the demo repeatable. + // 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( + async (_result: ReverificationCompleteResult) => { + await settleAfter(800); + finish(); + }, + [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/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/reverification-dialog-content.test.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-dialog-content.test.tsx new file mode 100644 index 00000000000..c27d99042dc --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-dialog-content.test.tsx @@ -0,0 +1,246 @@ +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 { ReverificationDialogContentProps } from './reverification-dialog-content'; +import { ReverificationDialogContent } from './reverification-dialog-content'; + +const base = { + dismissible: true, + closeLabel: 'Close', +}; + +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 = {}): ChooseProps => ({ + ...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(), + help: { text: 'Don’t have any of these?', action: { label: 'Get help', onClick: vi.fn() } }, + ...overrides, +}); + +const verifyProps = (overrides: Partial = {}): VerifyProps => ({ + ...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 = {}): MessageProps => ({ + ...base, + step: 'message', + title: 'Get help', + description: 'Email us and we will work with you to restore access.', + action: { label: 'Email support', onClick: vi.fn() }, + ...overrides, +}); + +describe('ReverificationDialogContent', () => { + it('renders nothing until the caller opens it', () => { + renderBlock(verifyProps({ open: false })); + + 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 })); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Email code to a••••@clerk.dev' })); + + expect(onSelectMethod).toHaveBeenCalledWith('email_1'); + }); + + it('keeps back with the methods and help in the card footer', () => { + const { unmount } = renderBlock(chooseProps()); + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); + + 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(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 () => { + 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('leads a dead end with the way forward, not the way back', async () => { + const onClick = vi.fn(); + renderBlock(messageProps({ action: { label: 'Email support', onClick } })); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + 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 { unmount } = renderBlock(messageProps()); + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); + + 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/reverification.controller.test.ts b/packages/ui/src/mosaic/blocks/reverification/reverification.controller.test.ts new file mode 100644 index 00000000000..64ee7b1d0df --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.controller.test.ts @@ -0,0 +1,581 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { reverificationController, reverificationFactorKey } from './reverification.controller'; +import type { + ReverificationAttempt, + ReverificationAttemptResult, + ReverificationBackupCodeFactor, + ReverificationChallenge, + ReverificationCompleteResult, + ReverificationEmailCodeFactor, + ReverificationFirstFactorPhoneCodeFactor, + ReverificationPasswordFactor, + ReverificationPreparationFactor, + ReverificationSecondFactorPhoneCodeFactor, + ReverificationTOTPFactor, +} from './reverification.types'; + +const passwordFactor: ReverificationPasswordFactor = { + stage: 'first', + strategy: 'password', +}; + +const emailFactor: ReverificationEmailCodeFactor = { + stage: 'first', + strategy: 'email_code', + emailAddressId: 'email_1', + safeIdentifier: 'a••••@clerk.dev', +}; + +const phoneFactor: ReverificationFirstFactorPhoneCodeFactor = { + stage: 'first', + strategy: 'phone_code', + phoneNumberId: 'phone_1', + safeIdentifier: '••••1234', +}; + +const totpFactor: ReverificationTOTPFactor = { + stage: 'second', + strategy: 'totp', +}; + +const backupCodeFactor: ReverificationBackupCodeFactor = { + stage: 'second', + strategy: 'backup_code', +}; + +const secondPhoneFactor: ReverificationSecondFactorPhoneCodeFactor = { + 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({ initialFactor: passwordFactor }), + prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined), + attempt = vi + .fn<(attempt: ReverificationAttempt) => Promise>() + .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?: (result: ReverificationCompleteResult) => Promise; + cancel?: () => void; +} = {}) { + const actor = createActor(reverificationController, { + context: { initialChallenge: challenge, prepare, attempt, complete, cancel }, + }).start(); + return { actor, prepare, attempt, complete, cancel }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('reverificationController', () => { + it('starts at factor selection when no initial factor is provided', () => { + const { actor } = start({ challenge: firstFactorChallenge() }); + + expect(actor.getSnapshot().value).toBe('selectingFactor'); + expect(actor.getSnapshot().context.challenge.factors).toEqual([passwordFactor, emailFactor, phoneFactor]); + expect(actor.can({ type: 'BACK' })).toBe(false); + + actor.send({ type: 'SELECT_FACTOR', factorKey: reverificationFactorKey(passwordFactor) }); + expect(actor.getSnapshot()).toMatchObject({ + value: 'verifying', + context: { currentFactor: passwordFactor }, + }); + }); + + it('treats an invalid initial factor as no selection', () => { + 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', sessionId: 'sess_1' }); + 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).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<(result: ReverificationCompleteResult) => 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'); + expect(actor.can({ type: 'CANCEL' })).toBe(false); + + finish(); + await vi.waitFor(() => expect(actor.getSnapshot().status).toBe('done')); + }); + + 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('completionFailed')); + expect(actor.getSnapshot().context).toMatchObject({ + 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', sessionId: 'sess_1' }); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactor: emailFactor }), + prepare, + attempt, + }); + + 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('continues from first-factor success into a normalized second-factor challenge', async () => { + const initialChallenge = firstFactorChallenge({ initialFactor: passwordFactor }); + const attempt = vi + .fn<(attempt: ReverificationAttempt) => Promise>() + .mockResolvedValue({ + status: 'needs_second_factor', + factors: [totpFactor, secondPhoneFactor], + initialFactor: totpFactor, + }); + const { actor } = start({ challenge: initialChallenge, attempt }); + + 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({ + challenge: { status: 'needs_second_factor' }, + currentFactor: totpFactor, + value: '', + }); + + actor.setContext({ initialChallenge }); + actor.send({ type: 'SHOW_ALTERNATIVES' }); + actor.send({ type: 'SELECT_FACTOR', factorKey: reverificationFactorKey(secondPhoneFactor) }); + expect(actor.getSnapshot()).toMatchObject({ + value: 'preparing', + context: { + challenge: { status: 'needs_second_factor' }, + currentFactor: secondPhoneFactor, + }, + }); + }); + + it('can begin directly at second-factor selection', () => { + const { actor } = start({ + challenge: { + status: 'needs_second_factor', + factors: [totpFactor, secondPhoneFactor], + }, + }); + + expect(actor.getSnapshot()).toMatchObject({ + value: 'selectingFactor', + context: { challenge: { status: 'needs_second_factor', factors: [totpFactor, secondPhoneFactor] } }, + }); + }); + + it('matches legacy help visibility outside factor selection', async () => { + const { actor: passwordActor } = start({ + 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], initialFactor: emailFactor }), + }); + await vi.waitFor(() => expect(emailActor.getSnapshot().value).toBe('verifyingCooldown')); + 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'); + }); + + 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({ initialFactor: emailFactor }), + prepare, + }); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); + + actor.send({ type: 'SHOW_ALTERNATIVES' }); + expect(actor.getSnapshot()).toMatchObject({ + value: 'selectingFactor', + context: { currentFactor: emailFactor }, + }); + + 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({ initialFactor: emailFactor }), + prepare, + }); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifyingCooldown')); + + actor.send({ type: 'SHOW_ALTERNATIVES' }); + 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', factorKey: reverificationFactorKey(emailFactor) }); + await vi.waitFor(() => expect(prepare).toHaveBeenCalledTimes(3)); + + expect(prepare).toHaveBeenNthCalledWith(1, emailFactor); + expect(prepare).toHaveBeenNthCalledWith(2, phoneFactor); + expect(prepare).toHaveBeenNthCalledWith(3, emailFactor); + }); + + 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.')) + .mockResolvedValue(undefined); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactor: emailFactor }), + prepare, + }); + + expect(actor.getSnapshot()).toMatchObject({ + value: 'preparing', + context: { currentFactor: emailFactor, resendSecondsRemaining: 30 }, + }); + expect(actor.can({ type: 'RESEND' })).toBe(false); + expect(actor.can({ type: 'SHOW_ALTERNATIVES' })).toBe(true); + + 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(false); + + await vi.advanceTimersByTimeAsync(30_000); + expect(actor.getSnapshot().value).toBe('verifying'); + actor.send({ type: 'RESEND' }); + await vi.runAllTicks(); + expect(actor.getSnapshot().value).toBe('verifyingCooldown'); + expect(prepare).toHaveBeenCalledTimes(2); + }); + + 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.' }); + 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: '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); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactor: emailFactor }), + 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('preparing'); + await vi.runAllTicks(); + expect(actor.getSnapshot()).toMatchObject({ + value: 'verifyingCooldown', + context: { resendSecondsRemaining: 30 }, + }); + expect(prepare).toHaveBeenCalledTimes(2); + }); + + it('holds the cooldown after a failed resend', async () => { + vi.useFakeTimers(); + const prepare = vi + .fn<(factor: ReverificationPreparationFactor) => Promise>() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Rate limited.')) + .mockResolvedValue(undefined); + const { actor } = start({ + challenge: firstFactorChallenge({ initialFactor: emailFactor }), + prepare, + }); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(30_000); + + actor.send({ type: 'RESEND' }); + await vi.runAllTicks(); + expect(actor.getSnapshot()).toMatchObject({ + value: 'verifyingCooldown', + context: { + 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(prepare).toHaveBeenCalledTimes(3); + }); + + it('finishes cancellation and reports it once', () => { + const cancel = vi.fn(); + const { actor } = start({ cancel }); + + actor.send({ type: 'CANCEL' }); + + expect(cancel).toHaveBeenCalledOnce(); + expect(actor.getSnapshot()).toMatchObject({ value: 'cancelled', status: 'done' }); + expect(actor.getSnapshot().status).toBe('done'); + }); +}); diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.controller.ts b/packages/ui/src/mosaic/blocks/reverification/reverification.controller.ts new file mode 100644 index 00000000000..abc6227890a --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.controller.ts @@ -0,0 +1,410 @@ +import { setup } from '../../machine/setup'; +import type { Snapshot } from '../../machine/types'; +import { reverificationBase as m } from './reverification.messages'; +import type { + ReverificationAttempt, + ReverificationAttemptResult, + ReverificationChallenge, + ReverificationCompleteResult, + ReverificationError, + ReverificationFactor, + ReverificationPreparationFactor, +} from './reverification.types'; + +const RESEND_COOLDOWN_SECONDS = 30; + +const emptyChallenge: ReverificationChallenge = { + status: 'needs_first_factor', + factors: [], +}; + +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. */ + challenge: ReverificationChallenge; + /** The factor currently being prepared or verified. */ + currentFactor: ReverificationFactor | null; + /** The answer entered for the current factor. */ + value: string; + /** 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; + /** 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; + /** Checks an answer. Injected by the view from its `attempt` prop. */ + attempt: (attempt: ReverificationAttempt) => 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; +} + +export type ReverificationControllerEvent = + | { type: 'CHANGE_VALUE'; value: string } + | { type: 'SUBMIT' } + | { type: 'RESEND' } + | { type: 'CANCEL' } + | { type: 'SELECT_FACTOR'; factorKey: string } + | { type: 'SHOW_ALTERNATIVES' } + | { type: 'SHOW_HELP' } + | { type: 'BACK' } + | { type: 'RETRY_COMPLETE' }; + +const { createMachine, assign, fromPromise } = setup(); + +const factorsFrom = (context: ReverificationControllerContext): ReverificationFactor[] => 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': + return `email_code:${factor.emailAddressId}`; + case 'phone_code': + return `phone_code:${factor.phoneNumberId}`; + default: + return factor.strategy; + } +}; + +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: ReverificationControllerContext, 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: ReverificationControllerContext) => + factorsFrom(context).filter( + factor => + !context.currentFactor || reverificationFactorKey(factor) !== reverificationFactorKey(context.currentFactor), + ); + +const hasAlternatives = (context: ReverificationControllerContext) => alternativesFrom(context).length > 0; + +const requiresPreparation = (factor: ReverificationFactor | null): factor is ReverificationPreparationFactor => + factor?.strategy === 'email_code' || factor?.strategy === 'phone_code'; + +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: ReverificationControllerContext) => { + 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; +}; + +const attemptFrom = (context: ReverificationControllerContext): ReverificationAttempt => { + const factor = context.currentFactor; + if (!factor) { + throw new Error(m.unstable__errors__generic); + } + if (factor.strategy === 'password') { + return { factor, password: context.value }; + } + if (factor.strategy === 'passkey') { + return { factor }; + } + return { factor, code: context.value }; +}; + +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, + event, +}: { + context: ReverificationControllerContext; + event: Extract; +}) => { + const value = normalizeValue(context.currentFactor, event.value); + return { + target: isFixedLengthCode(context.currentFactor) && value.length === 6 ? 'submitting' : undefined, + context: { value, error: null }, + }; +}; + +export const reverificationController = createMachine({ + id: 'reverification', + initial: 'initializing', + context: { + initialChallenge: emptyChallenge, + challenge: emptyChallenge, + currentFactor: null, + value: '', + error: null, + preparedFactorKey: null, + verification: null, + resendAvailableAt: null, + resendSecondsRemaining: 0, + prepare: () => Promise.resolve(), + attempt: () => Promise.resolve({ status: 'complete', sessionId: '' }), + complete: () => Promise.resolve(), + cancel: () => {}, + }, + states: { + initializing: { + entry: assign(context => { + assertValidChallenge(context.initialChallenge); + return { challenge: context.initialChallenge }; + }), + always: 'starting', + }, + starting: { + entry: assign(context => { + assertValidChallenge(context.challenge); + return { + currentFactor: initialFactorFrom(context.challenge), + value: '', + error: null, + preparedFactorKey: null, + verification: null, + resendAvailableAt: null, + resendSecondsRemaining: 0, + }; + }), + 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.factorKey)), + actions: assign((context, event) => ({ + currentFactor: factorFrom(context, event.factorKey) ?? context.currentFactor, + value: '', + error: null, + preparedFactorKey: null, + resendAvailableAt: null, + resendSecondsRemaining: 0, + })), + }, + BACK: { + target: 'routingFactor', + guard: context => Boolean(context.currentFactor), + }, + SHOW_HELP: { + target: 'helpFromSelection', + }, + CANCEL: 'cancelled', + }, + }, + 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: '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)) { + 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, + 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: 'verifyingCooldown', + actions: assign((_, event) => ({ error: errorFrom(event.error) })), + }, + }, + ), + on: { + SHOW_ALTERNATIVES: { target: 'selectingFactor', guard: hasAlternatives }, + CANCEL: 'cancelled', + }, + }, + verifying: { + on: { + CHANGE_VALUE: changeValue, + SUBMIT: { target: 'submitting', guard: canSubmit }, + RESEND: { target: 'preparing', guard: context => requiresPreparation(context.currentFactor) }, + SHOW_ALTERNATIVES: { target: 'selectingFactor', guard: hasAlternatives }, + SHOW_HELP: { + target: 'helpFromFactor', + guard: context => context.currentFactor?.strategy === 'password' && !hasAlternatives(context), + }, + CANCEL: 'cancelled', + }, + }, + verifyingCooldown: { + entry: assign(context => ({ resendSecondsRemaining: secondsUntilResend(context) })), + on: { + CHANGE_VALUE: changeValue, + SUBMIT: { target: 'submitting', guard: canSubmit }, + SHOW_ALTERNATIVES: { target: 'selectingFactor', guard: hasAlternatives }, + SHOW_HELP: { + target: 'helpFromFactor', + guard: context => context.currentFactor?.strategy === 'password' && !hasAlternatives(context), + }, + CANCEL: 'cancelled', + }, + after: { + 1000: [ + { target: 'verifyingCooldown', guard: isCoolingDown }, + { target: 'verifying', actions: assign(() => ({ resendSecondsRemaining: 0 })) }, + ], + }, + }, + submitting: { + invoke: fromPromise(context => context.attempt(attemptFrom(context)), { + onDone: [ + { + target: 'completing', + guard: (_, event) => event.output.status === 'complete', + actions: assign((_, event) => ({ + verification: event.output.status === 'complete' ? event.output : null, + error: null, + })), + }, + { + 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, + initialFactor: event.output.initialFactor, + }, + }; + }), + }, + ], + // Legacy reset only the OTP control; password and backup code kept what was typed. + onError: ({ context, event }) => ({ + target: isCoolingDown(context) ? 'verifyingCooldown' : 'verifying', + context: { + value: isFixedLengthCode(context.currentFactor) ? '' : context.value, + error: errorFrom(event.error), + }, + }), + }), + on: { CANCEL: 'cancelled' }, + }, + helpFromSelection: { + on: { + BACK: 'selectingFactor', + CANCEL: 'cancelled', + }, + }, + helpFromFactor: { + on: { + BACK: 'routingFactor', + CANCEL: 'cancelled', + }, + }, + unavailable: { on: { CANCEL: 'cancelled' } }, + completing: { + 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: { + type: 'final', + entry: context => context.cancel(), + }, + }, +}); + +export type ReverificationControllerSnapshot = Snapshot; diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts b/packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts new file mode 100644 index 00000000000..b03298d1eee --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts @@ -0,0 +1,91 @@ +export const reverificationBase = { + 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: { + 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', + }, + 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', + }, + 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', + 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/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/reverification.types.ts b/packages/ui/src/mosaic/blocks/reverification/reverification.types.ts new file mode 100644 index 00000000000..b2341c0a252 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.types.ts @@ -0,0 +1,94 @@ +export interface ReverificationPasswordFactor { + stage: 'first'; + strategy: 'password'; +} + +export interface ReverificationEmailCodeFactor { + stage: 'first'; + strategy: 'email_code'; + emailAddressId: string; + safeIdentifier: string; +} + +export interface ReverificationFirstFactorPhoneCodeFactor { + stage: 'first'; + strategy: 'phone_code'; + phoneNumberId: string; + safeIdentifier: string; +} + +export interface ReverificationPasskeyFactor { + stage: 'first'; + strategy: 'passkey'; +} + +export interface ReverificationSecondFactorPhoneCodeFactor { + stage: 'second'; + strategy: 'phone_code'; + phoneNumberId: string; + safeIdentifier: string; +} + +export interface ReverificationTOTPFactor { + stage: 'second'; + strategy: 'totp'; +} + +export interface ReverificationBackupCodeFactor { + 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 ReverificationChallenge = + | { + status: 'needs_first_factor'; + factors: ReverificationFirstFactor[]; + initialFactor?: ReverificationFirstFactor; + } + | { + status: 'needs_second_factor'; + factors: ReverificationSecondFactor[]; + initialFactor?: ReverificationSecondFactor; + }; + +export type ReverificationPreparationFactor = Extract; + +export type ReverificationAttempt = + | { factor: ReverificationPasswordFactor; password: string } + | { + factor: Exclude; + code: string; + } + | { 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 = + | ReverificationCompleteResult + | { + status: 'needs_second_factor'; + factors: ReverificationSecondFactor[]; + initialFactor?: ReverificationSecondFactor; + }; diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.view.test.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification.view.test.tsx new file mode 100644 index 00000000000..2ab31fe41c6 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.view.test.tsx @@ -0,0 +1,297 @@ +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, + ReverificationCompleteResult, + ReverificationEmailCodeFactor, + ReverificationPasskeyFactor, + ReverificationPasswordFactor, + ReverificationPreparationFactor, +} from './reverification.types'; +import { ReverificationView } from './reverification.view'; + +const passwordFactor: ReverificationPasswordFactor = { + stage: 'first', + strategy: 'password', +}; + +const emailFactor: ReverificationEmailCodeFactor = { + stage: 'first', + strategy: 'email_code', + emailAddressId: 'email_1', + safeIdentifier: 'a••••@clerk.dev', +}; + +const passkeyFactor: ReverificationPasskeyFactor = { + stage: 'first', + strategy: 'passkey', +}; + +function renderView({ + initialChallenge = { + status: 'needs_first_factor', + factors: [passwordFactor, emailFactor], + initialFactor: passwordFactor, + } as ReverificationChallenge, + prepare = vi.fn<(factor: ReverificationPreparationFactor) => Promise>().mockResolvedValue(undefined), + attempt = vi + .fn<(attempt: ReverificationAttempt) => Promise>() + .mockResolvedValue({ status: 'complete', sessionId: 'sess_1' }), + onComplete = vi.fn<(result: ReverificationCompleteResult) => Promise>().mockResolvedValue(undefined), + onCancel = vi.fn(), + supportEmail = 'support@clerk.dev', +} = {}) { + render( + + + , + ); + return { prepare, attempt, onComplete, onCancel, supportEmail }; +} + +/** 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('ReverificationView', () => { + 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).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({ + initialChallenge: { + status: 'needs_first_factor', + factors: [passwordFactor, emailFactor], + initialFactor: emailFactor, + }, + }); + + 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({ + initialChallenge: { + status: 'needs_first_factor', + factors: [passwordFactor, emailFactor], + initialFactor: emailFactor, + }, + prepare, + }); + + // The controller accepts 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 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.')) + .mockResolvedValue(undefined); + renderView({ + initialChallenge: { + status: 'needs_first_factor', + factors: [passwordFactor, emailFactor], + initialFactor: emailFactor, + }, + prepare, + }); + + expect(await screen.findByRole('alert')).toHaveTextContent('Could not send the code.'); + + // 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 () => { + renderView({ + initialChallenge: { + status: 'needs_first_factor', + factors: [passwordFactor, emailFactor], + initialFactor: emailFactor, + }, + }); + + 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({ + initialChallenge: { status: 'needs_first_factor', factors: [passkeyFactor], initialFactor: passkeyFactor }, + }); + + 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({ + initialChallenge: { status: 'needs_first_factor', factors: [passwordFactor], initialFactor: passwordFactor }, + }); + 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(); + 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({ initialChallenge: { 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({ initialChallenge: { 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<(result: ReverificationCompleteResult) => Promise>().mockReturnValue( + new Promise(resolve => { + finish = resolve; + }), + ); + const { onCancel } = 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'); + 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({ initialChallenge: { 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({ scope: 'answer', message: '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/reverification.view.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification.view.tsx new file mode 100644 index 00000000000..1830122d877 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.view.tsx @@ -0,0 +1,276 @@ +import { Card } from '../../components/card'; +import { Dialog } from '../../components/dialog'; +import { useMachine } from '../../machine/useMachine'; +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, + ReverificationChallenge, + ReverificationCompleteResult, + ReverificationFactor, + ReverificationPreparationFactor, +} from './reverification.types'; +import type { ReverificationDialogContentProps } from './reverification-dialog-content'; +import { ReverificationDialogContent } from './reverification-dialog-content'; + +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. */ + prepare: (factor: ReverificationPreparationFactor) => Promise; + /** 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: (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`. */ + supportEmail: string; +} + +/** + * 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 methodLabel(factor: ReverificationFactor): string { + const alternatives = m.alternativeMethods; + switch (factor.strategy) { + case 'password': + return alternatives.blockButton__password; + case 'passkey': + return alternatives.blockButton__passkey; + case 'email_code': + return fill(alternatives.blockButton__emailCode, { identifier: factor.safeIdentifier }); + case 'phone_code': + return fill(alternatives.blockButton__phoneCode, { identifier: factor.safeIdentifier }); + case 'totp': + return alternatives.blockButton__totp; + case 'backup_code': + return alternatives.blockButton__backupCode; + } +} + +const asMethod = (factor: ReverificationFactor): ReverificationMethod => ({ + id: reverificationFactorKey(factor), + label: methodLabel(factor), +}); + +const alternativesTo = (context: ReverificationControllerContext) => + context.challenge.factors.filter( + factor => + !context.currentFactor || reverificationFactorKey(factor) !== reverificationFactorKey(context.currentFactor), + ); + +/** + * 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 ReverificationView({ + initialChallenge, + prepare, + attempt, + onComplete, + onCancel, + supportEmail, +}: ReverificationViewProps) { + const [snapshot, send, actor] = useMachine(reverificationController, { + context: { initialChallenge, prepare, attempt, complete: onComplete, cancel: onCancel }, + }); + const { context } = snapshot; + + // `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; + } + + // 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 canCancel = actor.can({ type: 'CANCEL' }); + const base = { + dismissible: canCancel, + closeLabel: m.closeButton, + error: context.error?.scope === 'flow' ? context.error.message : undefined, + }; + + const props = ((): ReverificationDialogContentProps => { + // 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: emailSupport, + }; + } + + if (snapshot.value === 'helpFromSelection' || snapshot.value === 'helpFromFactor') { + return { + ...base, + step: 'message', + title: m.alternativeMethods.getHelp.title, + description: m.alternativeMethods.getHelp.content, + action: emailSupport, + secondary: { label: m.backButton, onClick: () => send({ type: 'BACK' }) }, + }; + } + + 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: factorKey => send({ type: 'SELECT_FACTOR', factorKey }), + back: actor.can({ type: 'BACK' }) ? { label: m.backButton, onClick: () => send({ type: 'BACK' }) } : undefined, + help: { + text: m.alternativeMethods.actionText, + action: { 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; + if (!factor) { + return { + ...base, + step: 'message', + title: m.noAvailableMethods.title, + description: m.noAvailableMethods.message, + action: emailSupport, + }; + } + + const copy = copyFor(factor); + const { resendButton } = copy; + 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'; + + 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?.scope === 'answer' ? 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, + alternative: actor.can({ type: 'SHOW_ALTERNATIVES' }) + ? { label: m.footerActionLink__useAnotherMethod, onClick: () => send({ type: 'SHOW_ALTERNATIVES' }) } + : undefined, + help: actor.can({ type: 'SHOW_HELP' }) + ? { + text: m.alternativeMethods.actionText, + action: { label: m.alternativeMethods.actionLink, onClick: () => send({ type: 'SHOW_HELP' }) }, + } + : undefined, + }; + })(); + + return ( + { + if (!open) { + send({ type: 'CANCEL' }); + } + }} + > + + + + + } + > + + + + + + ); +} 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'],