diff --git a/.changeset/reverification-feature-stack.md b/.changeset/reverification-feature-stack.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/reverification-feature-stack.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/swingset/src/stories/reverification.mdx b/packages/swingset/src/stories/reverification.mdx
index 2f40511fae3..c6113d1a8ca 100644
--- a/packages/swingset/src/stories/reverification.mdx
+++ b/packages/swingset/src/stories/reverification.mdx
@@ -19,12 +19,17 @@ The example pauses briefly in each pending state. Enter `error` in password or b
## Usage
-The controller supplies one model containing the active view, transition direction, resolved messages, form state, and event handlers. Spread that model directly into the prop-driven `Reverification` view.
+The feature wrapper reads Clerk state and drives `ReverificationView`. Isolated panel stories below are for visual development only.
```tsx
-import { Reverification } from '@clerk/ui/mosaic/blocks/reverification';
-
-;
+import { Reverification } from '@clerk/ui/mosaic/features/reverification';
+
+ {}}
+ cancel={() => {}}
+ level='first_factor'
+/>;
```
## Panels
diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx
index 07bb97e8d55..bdd6d9d1d7b 100644
--- a/packages/swingset/src/stories/reverification.stories.tsx
+++ b/packages/swingset/src/stories/reverification.stories.tsx
@@ -1,14 +1,13 @@
-import type { ReverificationModel } from '@clerk/ui/mosaic/blocks/reverification';
-import {
- Reverification,
- ReverificationBackupCode,
- ReverificationHelp,
- ReverificationMethodPicker,
- ReverificationOTP,
- ReverificationPasskey,
- ReverificationPassword,
-} from '@clerk/ui/mosaic/blocks/reverification';
+import { Button } from '@clerk/ui/mosaic/components/button';
import { Card } from '@clerk/ui/mosaic/components/card';
+import type { ReverificationMethod, ReverificationStep } from '@clerk/ui/mosaic/features/reverification';
+import { ReverificationBackupCode } from '@clerk/ui/mosaic/features/reverification/panels/reverification-backup-code';
+import { ReverificationHelp } from '@clerk/ui/mosaic/features/reverification/panels/reverification-help';
+import { ReverificationMethodPicker } from '@clerk/ui/mosaic/features/reverification/panels/reverification-method-picker';
+import { ReverificationOTP } from '@clerk/ui/mosaic/features/reverification/panels/reverification-otp';
+import { ReverificationPasskey } from '@clerk/ui/mosaic/features/reverification/panels/reverification-passkey';
+import { ReverificationPassword } from '@clerk/ui/mosaic/features/reverification/panels/reverification-password';
+import { ReverificationView } from '@clerk/ui/mosaic/features/reverification/reverification.view';
import { useState } from 'react';
import type { StoryMeta } from '@/lib/types';
@@ -18,52 +17,11 @@ export { default as __source } from './reverification.stories?raw';
export const meta: StoryMeta = {
group: 'Blocks',
title: 'Reverification',
- source: 'packages/ui/src/mosaic/blocks/reverification/reverification.tsx',
+ source: 'packages/ui/src/mosaic/features/reverification/reverification.view.tsx',
};
const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
-const actions = {
- secondaryActionLabel: 'Use another method',
- primaryActionLabel: 'Continue',
- pendingLabel: 'Verifying',
-};
-
-const passwordMessages = {
- title: 'Verification required',
- description: 'Enter your current password to continue',
- fieldLabel: 'Password',
- fieldPlaceholder: 'Enter your password',
- ...actions,
-};
-
-const passkeyMessages = {
- title: 'Use your passkey',
- description:
- 'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.',
- ...actions,
- primaryActionLabel: 'Use your passkey',
-};
-
-const phoneOtpMessages = {
- title: 'Verification required',
- description: 'Enter the code sent to your phone to continue',
- fieldLabel: 'Verification code',
- ...actions,
-};
-
-const authenticatorOtpMessages = {
- ...phoneOtpMessages,
- description: 'Enter the code generated by your authenticator app to continue',
-};
-
-const backupCodeMessages = {
- title: 'Enter a backup code',
- description: 'Enter the backup code you received when setting up two-step authentication',
- fieldLabel: 'Backup code',
- ...actions,
-};
-
const methodPickerMessages = {
title: 'Use another method',
description: 'Facing issues? You can use any of these methods for verification.',
@@ -80,190 +38,203 @@ const helpMessages = {
supportButton: 'Email support',
};
-const methodPresentation = {
- password: { label: 'Continue with your password', icon: 'security-lock-square' },
- otp: { label: 'Use your authenticator app', icon: 'security-authenticator' },
- 'backup-code': { label: 'Use a backup code', icon: 'security-phone' },
- passkey: { label: 'Use your passkey', icon: 'security-passkey' },
-} satisfies Record>;
-
-type MethodId = keyof typeof methodPresentation;
+const allMethods: ReverificationMethod[] = [
+ { id: 'password', strategy: 'password' },
+ { id: 'passkey', strategy: 'passkey' },
+ { id: 'totp', strategy: 'totp' },
+ { id: 'backup_code', strategy: 'backup_code' },
+];
+
+function stepFor(id: string): ReverificationStep {
+ if (id === 'passkey') {
+ return 'passkey';
+ }
+ if (id === 'backup_code') {
+ return 'backup-code';
+ }
+ if (id === 'totp') {
+ return 'otp';
+ }
+ return 'password';
+}
-function usePrototype(): ReverificationModel {
- const [status, setStatus] = useState('password');
- const [direction, setDirection] = useState(1);
- const [methodId, setMethodId] = useState('password');
+function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element {
+ const [step, setStep] = useState('password');
+ const [direction, setDirection] = useState<-1 | 1>(1);
+ const [methodId, setMethodId] = useState('password');
const [value, setValue] = useState('');
- const [fieldError, setFieldError] = useState();
- const [pendingMethod, setPendingMethod] = useState();
- const [pendingMethodId, setPendingMethodId] = useState();
- const [isResending, setIsResending] = useState(false);
+ const [errorMessage, setErrorMessage] = useState();
+ const [isPending, setIsPending] = useState(false);
+ const [supportRequested, setSupportRequested] = useState(false);
- const navigate = (nextStatus: ReverificationModel['status'], nextDirection: ReverificationModel['direction']) => {
+ const navigate = (next: ReverificationStep, nextDirection: -1 | 1) => {
setDirection(nextDirection);
- setStatus(nextStatus);
+ setStep(next);
setValue('');
- setFieldError(undefined);
+ setErrorMessage(undefined);
};
const onValueChange = (nextValue: string) => {
setValue(nextValue);
- setFieldError(undefined);
- };
-
- const complete = () => {
- setDirection(1);
- setStatus('password');
- setMethodId('password');
- setValue('');
- setFieldError(undefined);
+ setErrorMessage(undefined);
};
const submitPassword = async () => {
if (!value.trim()) {
- setFieldError('This field is required.');
+ setErrorMessage('This field is required.');
return;
}
- setPendingMethod('password');
+ setIsPending(true);
await settleAfter(700);
- setPendingMethod(undefined);
+ setIsPending(false);
if (value.toLowerCase() === 'error') {
- setFieldError('That password is incorrect. Try again.');
+ setErrorMessage('That password is incorrect. Try again.');
return;
}
- complete();
+ setMethodId('totp');
+ navigate('otp', 1);
};
const submitOtp = async (code: string) => {
if (code.length !== 6) {
- setFieldError('Enter the complete verification code.');
+ setErrorMessage('Enter the complete verification code.');
return;
}
- setPendingMethod('otp');
+ setIsPending(true);
await settleAfter(700);
- setPendingMethod(undefined);
+ setIsPending(false);
if (code === '000000') {
- setFieldError('That verification code is incorrect. Try again.');
+ setErrorMessage('That verification code is incorrect. Try again.');
return;
}
- complete();
+ onComplete();
};
const submitBackupCode = async () => {
if (!value.trim()) {
- setFieldError('This field is required.');
+ setErrorMessage('This field is required.');
return;
}
- setPendingMethod('backup-code');
+ setIsPending(true);
await settleAfter(700);
- setPendingMethod(undefined);
+ setIsPending(false);
if (value.toLowerCase() === 'error') {
- setFieldError('That backup code is incorrect. Try again.');
+ setErrorMessage('That backup code is incorrect. Try again.');
return;
}
- complete();
+ onComplete();
};
const submitPasskey = async () => {
- setPendingMethod('passkey');
+ setIsPending(true);
await settleAfter(700);
- setPendingMethod(undefined);
- complete();
+ setIsPending(false);
+ onComplete();
};
const selectMethod = async (id: string) => {
- const nextMethodId = id as MethodId;
- setPendingMethodId(id);
- await settleAfter(700);
- setPendingMethodId(undefined);
- setMethodId(nextMethodId);
- navigate(nextMethodId, 1);
- };
-
- const resend = async () => {
- setIsResending(true);
+ setIsPending(true);
await settleAfter(700);
- setIsResending(false);
- onValueChange('');
+ setIsPending(false);
+ setMethodId(id);
+ navigate(stepFor(id), 1);
};
- const showMethods = () => navigate('method-picker', -1);
- const model: ReverificationModel = {
- status,
- direction,
- password: {
- messages: passwordMessages,
- value,
- errorMessage: fieldError,
- isPending: pendingMethod === 'password',
- onValueChange,
- onSubmit: () => void submitPassword(),
- onCancel: showMethods,
- },
- passkey: {
- messages: passkeyMessages,
- isPending: pendingMethod === 'passkey',
- onVerify: () => void submitPasskey(),
- onCancel: showMethods,
- },
- otp: {
- messages: authenticatorOtpMessages,
- value,
- errorMessage: fieldError,
- isPending: pendingMethod === 'otp',
- resend: {
- label: isResending ? 'Sending a new code…' : 'Didn’t receive a code? Resend',
- disabled: isResending || pendingMethod === 'otp',
- onClick: () => void resend(),
- },
- onValueChange,
- onComplete: code => void submitOtp(code),
- onSubmit: () => void submitOtp(value),
- onCancel: showMethods,
- },
- backupCode: {
- messages: backupCodeMessages,
- value,
- errorMessage: fieldError,
- isPending: pendingMethod === 'backup-code',
- onValueChange,
- onSubmit: () => void submitBackupCode(),
- onCancel: showMethods,
- },
- methodPicker: {
- messages: methodPickerMessages,
- methods: (Object.keys(methodPresentation) as MethodId[])
- .filter(id => id !== methodId)
- .map(id => ({ id, ...methodPresentation[id] })),
- pendingMethodId,
- onSelect: id => void selectMethod(id),
- onHelp: () => navigate('help', 1),
- onBack: () => navigate(methodId, 1),
- },
- help: {
- messages: helpMessages,
- onEmailSupport: () => undefined,
- onBack: () => navigate('method-picker', -1),
- },
+ const onSubmit = () => {
+ if (step === 'backup-code') {
+ void submitBackupCode();
+ return;
+ }
+ if (step === 'otp') {
+ void submitOtp(value);
+ return;
+ }
+ void submitPassword();
};
- return model;
+ return (
+ <>
+ void submitPasskey()}
+ onShowMethods={() => navigate('method-picker', 1)}
+ onShowHelp={() => navigate('help', 1)}
+ onBack={
+ step === 'help'
+ ? () => navigate('method-picker', -1)
+ : step === 'method-picker'
+ ? () => navigate(stepFor(methodId), -1)
+ : undefined
+ }
+ onEmailSupport={() => setSupportRequested(true)}
+ methods={allMethods.filter(method => method.id !== methodId)}
+ onSelectMethod={id => void selectMethod(id)}
+ otpChannel='totp'
+ />
+ {supportRequested ? Email support requested.
: null}
+ >
+ );
}
export function Default(): JSX.Element {
- const prototype = usePrototype();
+ const [runId, setRunId] = useState(0);
+ const [complete, setComplete] = useState(false);
+
+ if (complete) {
+ return (
+
+
Reverification complete.
+
+
+ );
+ }
- return ;
+ return (
+ setComplete(true)}
+ />
+ );
}
-export function Password(): JSX.Element {
- const [value, setValue] = useState('');
+function PasswordPanel({
+ isPending = false,
+ errorMessage,
+}: {
+ isPending?: boolean;
+ errorMessage?: string;
+}): JSX.Element {
+ const [value, setValue] = useState(errorMessage ? 'incorrect-password' : '');
return (
undefined}
onCancel={() => setValue('')}
@@ -272,41 +243,38 @@ export function Password(): JSX.Element {
);
}
+export function Password(): JSX.Element {
+ return ;
+}
+
export function PasswordPending(): JSX.Element {
- return (
-
- undefined}
- onSubmit={() => undefined}
- onCancel={() => undefined}
- />
-
- );
+ return ;
}
export function PasswordError(): JSX.Element {
- return (
-
- undefined}
- onSubmit={() => undefined}
- onCancel={() => undefined}
- />
-
- );
+ return ;
}
-export function Passkey(): JSX.Element {
+function PasskeyPanel({
+ isPending = false,
+ errorMessage,
+}: {
+ isPending?: boolean;
+ errorMessage?: string;
+}): JSX.Element {
return (
undefined}
onCancel={() => undefined}
/>
@@ -314,44 +282,56 @@ export function Passkey(): JSX.Element {
);
}
+export function Passkey(): JSX.Element {
+ return ;
+}
+
export function PasskeyPending(): JSX.Element {
- return (
-
- undefined}
- onCancel={() => undefined}
- />
-
- );
+ return ;
}
export function PasskeyError(): JSX.Element {
- return (
-
- undefined}
- onCancel={() => undefined}
- />
-
- );
+ return ;
}
-export function OTP(): JSX.Element {
- const [value, setValue] = useState('');
+function OTPPanel({
+ description,
+ isPending = false,
+ errorMessage,
+ isResending = false,
+ renderResend = true,
+}: {
+ description: string;
+ isPending?: boolean;
+ errorMessage?: string;
+ isResending?: boolean;
+ renderResend?: boolean;
+}): JSX.Element {
+ const [value, setValue] = useState(isPending || errorMessage ? '123456' : '');
return (
setValue(''),
+ messages={{
+ title: 'Verification required',
+ description,
+ fieldLabel: 'Verification code',
+ secondaryActionLabel: 'Cancel',
+ primaryActionLabel: 'Continue',
+ pendingLabel: 'Verifying',
}}
+ value={value}
+ errorMessage={errorMessage}
+ isPending={isPending}
+ resend={
+ renderResend
+ ? {
+ label: isResending ? 'Sending a new code…' : 'Didn’t receive a code? Resend',
+ disabled: isResending,
+ onClick: () => setValue(''),
+ }
+ : undefined
+ }
onValueChange={setValue}
onSubmit={() => undefined}
onCancel={() => setValue('')}
@@ -360,88 +340,69 @@ export function OTP(): JSX.Element {
);
}
-export function AuthenticatorOTP(): JSX.Element {
- const [value, setValue] = useState('');
+export function OTP(): JSX.Element {
+ return ;
+}
+export function AuthenticatorOTP(): JSX.Element {
return (
-
- undefined}
- onCancel={() => setValue('')}
- />
-
+
);
}
export function OTPPending(): JSX.Element {
return (
-
- undefined,
- }}
- onValueChange={() => undefined}
- onSubmit={() => undefined}
- onCancel={() => undefined}
- />
-
+
);
}
export function OTPError(): JSX.Element {
return (
-
- undefined,
- }}
- onValueChange={() => undefined}
- onSubmit={() => undefined}
- onCancel={() => undefined}
- />
-
+
);
}
export function OTPResending(): JSX.Element {
return (
-
- undefined,
- }}
- onValueChange={() => undefined}
- onSubmit={() => undefined}
- onCancel={() => undefined}
- />
-
+
);
}
-export function BackupCode(): JSX.Element {
- const [value, setValue] = useState('');
+function BackupCodePanel({
+ isPending = false,
+ errorMessage,
+}: {
+ isPending?: boolean;
+ errorMessage?: string;
+}): JSX.Element {
+ const [value, setValue] = useState(errorMessage ? 'invalid-code' : '');
return (
undefined}
onCancel={() => setValue('')}
@@ -450,34 +411,16 @@ export function BackupCode(): JSX.Element {
);
}
+export function BackupCode(): JSX.Element {
+ return ;
+}
+
export function BackupCodePending(): JSX.Element {
- return (
-
- undefined}
- onSubmit={() => undefined}
- onCancel={() => undefined}
- />
-
- );
+ return ;
}
export function BackupCodeError(): JSX.Element {
- return (
-
- undefined}
- onSubmit={() => undefined}
- onCancel={() => undefined}
- />
-
- );
+ return ;
}
export function MethodPicker(): JSX.Element {
diff --git a/packages/ui/src/mosaic/blocks/reverification/index.ts b/packages/ui/src/mosaic/blocks/reverification/index.ts
deleted file mode 100644
index f315bfee6c1..00000000000
--- a/packages/ui/src/mosaic/blocks/reverification/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export { Reverification } from './reverification';
-export type { ReverificationModel, ReverificationProps, ReverificationStatus } from './reverification';
-export { ReverificationBackupCode } from './reverification-backup-code';
-export type { ReverificationBackupCodeMessages, ReverificationBackupCodeProps } from './reverification-backup-code';
-export { ReverificationHelp } from './reverification-help';
-export type { ReverificationHelpMessages, ReverificationHelpProps } from './reverification-help';
-export { ReverificationMethodPicker } from './reverification-method-picker';
-export type {
- ReverificationMethod,
- ReverificationMethodPickerMessages,
- ReverificationMethodPickerProps,
-} from './reverification-method-picker';
-export { ReverificationOTP } from './reverification-otp';
-export type { ReverificationOtpMessages, ReverificationOTPProps, ReverificationOtpResend } from './reverification-otp';
-export { ReverificationPasskey } from './reverification-passkey';
-export type { ReverificationPasskeyMessages, ReverificationPasskeyProps } from './reverification-passkey';
-export { ReverificationPassword } from './reverification-password';
-export type { ReverificationPasswordMessages, ReverificationPasswordProps } from './reverification-password';
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx
deleted file mode 100644
index 9d173802cd7..00000000000
--- a/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
-
-import type { ReverificationModel } from './reverification';
-import { Reverification } from './reverification';
-
-const actions = {
- secondaryActionLabel: 'Use another method',
- primaryActionLabel: 'Continue',
- pendingLabel: 'Verifying',
-};
-
-function model(
- status: ReverificationModel['status'],
- direction: ReverificationModel['direction'] = 1,
-): ReverificationModel {
- const onValueChange = vi.fn();
- const onSubmit = vi.fn();
-
- return {
- status,
- direction,
- password: {
- messages: {
- title: 'Verification required',
- description: 'Enter your password.',
- fieldLabel: 'Password',
- fieldPlaceholder: 'Enter your password',
- ...actions,
- },
- value: '',
- onValueChange,
- onSubmit,
- },
- passkey: {
- messages: {
- title: 'Use your passkey',
- description: 'Verify with your passkey.',
- ...actions,
- },
- onVerify: onSubmit,
- },
- otp: {
- messages: {
- title: 'Verification required',
- description: 'Enter the code from your authenticator.',
- fieldLabel: 'Verification code',
- ...actions,
- },
- value: '',
- onValueChange,
- onSubmit,
- },
- backupCode: {
- messages: {
- title: 'Enter a backup code',
- description: 'Enter one of your backup codes.',
- fieldLabel: 'Backup code',
- ...actions,
- },
- value: '',
- onValueChange,
- onSubmit,
- },
- methodPicker: {
- messages: {
- title: 'Use another method',
- description: 'Choose another way to verify.',
- backButton: 'Back',
- helpText: 'Need help?',
- helpButton: 'Get help',
- },
- methods: [],
- onSelect: vi.fn(),
- onHelp: vi.fn(),
- },
- help: {
- messages: {
- title: 'Get help',
- description: 'Contact support for help.',
- backButton: 'Back',
- supportButton: 'Email support',
- },
- onEmailSupport: vi.fn(),
- onBack: vi.fn(),
- },
- };
-}
-
-describe('Reverification', () => {
- it('keeps one Card and Flow surface while view props and panels change', () => {
- const { container, rerender } = render();
-
- const card = container.querySelector('.cl-card-root');
- const flow = container.querySelector('.cl-flow-root');
- const passwordStep = screen.getByLabelText('Password').closest('.cl-flow-step');
-
- expect(card).not.toBeNull();
- expect(card).toContainElement(flow);
- expect(flow).toHaveAttribute('data-value', 'password');
-
- const pendingPassword = model('password');
- pendingPassword.password.isPending = true;
- rerender();
-
- expect(container.querySelector('.cl-card-root')).toBe(card);
- expect(container.querySelector('.cl-flow-root')).toBe(flow);
- expect(screen.getByLabelText('Password').closest('.cl-flow-step')).toBe(passwordStep);
-
- rerender();
-
- expect(container.querySelector('.cl-card-root')).toBe(card);
- expect(container.querySelector('.cl-flow-root')).toBe(flow);
- expect(screen.queryByLabelText('Password')).not.toBeInTheDocument();
- const otpStep = screen.getByRole('group', { name: 'Verification code' }).closest('.cl-flow-step');
- expect(otpStep).toBeInTheDocument();
- expect(otpStep?.style.getPropertyValue('--cl-flow-transition-direction')).toBe('-1');
- });
-
- it('does not render Card branding', () => {
- render();
-
- expect(screen.queryByText('Secured by')).not.toBeInTheDocument();
- });
-
- it('renders a passkey attempt error in a negative Banner', () => {
- const errorModel = model('passkey');
- errorModel.passkey.errorMessage = 'We couldn’t verify that passkey. Try again.';
-
- render();
-
- const banner = screen.getByRole('alert');
- expect(banner).toHaveClass('cl-banner-root');
- expect(banner).toHaveAttribute('data-color', 'negative');
- expect(banner).toHaveTextContent('We couldn’t verify that passkey. Try again.');
- });
-});
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification.tsx
deleted file mode 100644
index cf0657fb324..00000000000
--- a/packages/ui/src/mosaic/blocks/reverification/reverification.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { Card } from '../../components/card';
-import type { FlowDirection } from '../../components/flow';
-import { Flow } from '../../components/flow';
-import type { ReverificationBackupCodeProps } from './reverification-backup-code';
-import { ReverificationBackupCode } from './reverification-backup-code';
-import type { ReverificationHelpProps } from './reverification-help';
-import { ReverificationHelp } from './reverification-help';
-import type { ReverificationMethodPickerProps } from './reverification-method-picker';
-import { ReverificationMethodPicker } from './reverification-method-picker';
-import type { ReverificationOTPProps } from './reverification-otp';
-import { ReverificationOTP } from './reverification-otp';
-import type { ReverificationPasskeyProps } from './reverification-passkey';
-import { ReverificationPasskey } from './reverification-passkey';
-import type { ReverificationPasswordProps } from './reverification-password';
-import { ReverificationPassword } from './reverification-password';
-
-export type ReverificationStatus = 'password' | 'passkey' | 'otp' | 'backup-code' | 'method-picker' | 'help';
-
-/** Controlled rendering model produced by a reverification controller. */
-export interface ReverificationModel {
- status: ReverificationStatus;
- direction: FlowDirection;
- password: ReverificationPasswordProps;
- passkey: ReverificationPasskeyProps;
- otp: ReverificationOTPProps;
- backupCode: ReverificationBackupCodeProps;
- methodPicker: ReverificationMethodPickerProps;
- help: ReverificationHelpProps;
-}
-
-export type ReverificationProps = ReverificationModel;
-
-export function Reverification(model: ReverificationProps): JSX.Element {
- return (
-
-
- {current => (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
-
-
- );
-}
diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx
new file mode 100644
index 00000000000..1d1be5cfe89
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx
@@ -0,0 +1,360 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { createActor } from '../../../machine/createActor';
+import { deferred, tick } from '../../../machines/__tests__/test-utils';
+import {
+ reverificationMachine,
+ useReverificationController,
+ type ReverificationDeps,
+} from '../reverification.controller';
+import type { ReverificationModel, ReverificationReadyModel } from '../reverification.model';
+import type { ReverificationMethod, ReverificationResult } from '../reverification.types';
+
+const password: ReverificationMethod = { id: 'password', strategy: 'password' };
+const email: ReverificationMethod = {
+ id: 'email_code:idn_1',
+ strategy: 'email_code',
+ identifier: 'a***@ex.com',
+ emailAddressId: 'idn_1',
+};
+const totp: ReverificationMethod = { id: 'totp', strategy: 'totp' };
+
+function firstFactorResult(overrides: Partial = {}): ReverificationResult {
+ return {
+ status: 'needs_first_factor',
+ methods: [password, email],
+ startingMethod: password,
+ ...overrides,
+ };
+}
+
+function seatedDeps(overrides: Partial = {}): ReverificationDeps {
+ return {
+ start: vi.fn(async () => firstFactorResult()),
+ prepare: vi.fn(async () => {}),
+ attempt: vi.fn(async () => firstFactorResult({ status: 'complete' })),
+ verifyPasskey: vi.fn(async () => firstFactorResult({ status: 'complete' })),
+ finish: vi.fn(async () => {}),
+ cancel: vi.fn(),
+ ...overrides,
+ };
+}
+
+function startActor(deps: ReverificationDeps = seatedDeps()) {
+ const actor = createActor(reverificationMachine, { context: { deps } }).start();
+ actor.send({ type: 'START' });
+ return actor;
+}
+
+function readyModel(overrides: Partial = {}): ReverificationReadyModel {
+ return {
+ status: 'ready',
+ isActive: true,
+ supportEmail: 'support@example.com',
+ start: vi.fn(async () => firstFactorResult()),
+ prepare: vi.fn(async () => {}),
+ attempt: vi.fn(async () => firstFactorResult({ status: 'complete' })),
+ verifyPasskey: vi.fn(async () => firstFactorResult({ status: 'complete' })),
+ finish: vi.fn(async () => {}),
+ cancel: vi.fn(),
+ ...overrides,
+ };
+}
+
+describe('reverificationMachine', () => {
+ it('starts verification and lands on the starting method', async () => {
+ const actor = startActor();
+ expect(actor.getSnapshot().value).toBe('starting');
+ await tick();
+ expect(actor.getSnapshot().value).toBe('verifying');
+ expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('password');
+ });
+
+ it('returns to verifying with the error when an attempt fails', async () => {
+ const actor = startActor(
+ seatedDeps({ attempt: vi.fn(async () => Promise.reject(new Error('That password is incorrect.'))) }),
+ );
+ await tick();
+ actor.send({ type: 'TYPE', value: 'bad' });
+ actor.send({ type: 'SUBMIT' });
+ await tick();
+ expect(actor.getSnapshot().value).toBe('verifying');
+ expect(actor.getSnapshot().context.errorMessage).toBe('That password is incorrect.');
+ });
+
+ it('prepares an email code once when that method is selected', async () => {
+ const prepare = vi.fn(async () => {});
+ const actor = startActor(seatedDeps({ prepare }));
+ await tick();
+ actor.send({ type: 'SHOW_METHODS' });
+ expect(actor.getSnapshot().value).toBe('methodPicker');
+ actor.send({ type: 'SELECT_METHOD', id: email.id });
+ expect(actor.getSnapshot().value).toBe('preparing');
+ await tick();
+ expect(prepare).toHaveBeenCalledOnce();
+ expect(actor.getSnapshot().value).toBe('verifying');
+ expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('email_code');
+
+ actor.send({ type: 'SHOW_METHODS' });
+ actor.send({ type: 'BACK' });
+ expect(actor.getSnapshot().value).toBe('verifying');
+ expect(prepare).toHaveBeenCalledOnce();
+ });
+
+ it('routes to second factor after a successful first-factor attempt', async () => {
+ const actor = startActor(
+ seatedDeps({
+ attempt: vi.fn(async () =>
+ firstFactorResult({
+ status: 'needs_second_factor',
+ methods: [totp],
+ startingMethod: totp,
+ }),
+ ),
+ }),
+ );
+ await tick();
+ actor.send({ type: 'TYPE', value: 'secret' });
+ actor.send({ type: 'SUBMIT' });
+ await tick();
+ expect(actor.getSnapshot().value).toBe('verifying');
+ expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('totp');
+ });
+
+ it('opens help from the method picker and returns to it', async () => {
+ const actor = startActor();
+ await tick();
+ actor.send({ type: 'SHOW_METHODS' });
+ actor.send({ type: 'SHOW_HELP' });
+ expect(actor.getSnapshot().value).toBe('help');
+ actor.send({ type: 'BACK' });
+ expect(actor.getSnapshot().value).toBe('methodPicker');
+ });
+
+ it('does not leave submitting when abort is requested until the attempt settles', async () => {
+ const attempt = deferred();
+ const cancel = vi.fn();
+ const actor = startActor(seatedDeps({ attempt: () => attempt.promise, cancel }));
+ await tick();
+ actor.send({ type: 'TYPE', value: 'secret' });
+ actor.send({ type: 'SUBMIT' });
+ expect(actor.getSnapshot().value).toBe('submitting');
+
+ actor.send({ type: 'ABORT' });
+ expect(actor.getSnapshot().value).toBe('submitting');
+ expect(cancel).not.toHaveBeenCalled();
+
+ attempt.reject(new Error('cancelled'));
+ await tick();
+ expect(actor.getSnapshot().value).toBe('done');
+ expect(cancel).toHaveBeenCalledOnce();
+ });
+
+ it('finishes on success without replacing the active method', async () => {
+ const finish = deferred();
+ const actor = startActor(
+ seatedDeps({
+ attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })),
+ finish: () => finish.promise,
+ }),
+ );
+ await tick();
+ actor.send({ type: 'TYPE', value: 'secret' });
+ actor.send({ type: 'SUBMIT' });
+ await tick();
+ expect(actor.getSnapshot().value).toBe('completing');
+ expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('password');
+ finish.resolve();
+ await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('done'));
+ });
+});
+
+describe('useReverificationController', () => {
+ it('is idle when reverification is not active', () => {
+ const { result } = renderHook(() =>
+ useReverificationController(readyModel({ isActive: false })),
+ );
+ expect(result.current.status).toBe('idle');
+ });
+
+ it('is loading while the model is still waiting on Clerk', () => {
+ const loading: ReverificationModel = {
+ status: 'loading',
+ isActive: true,
+ };
+ const { result } = renderHook(() => useReverificationController(loading));
+ expect(result.current.status).toBe('loading');
+ });
+
+ it('is unavailable when start fails', async () => {
+ const { result } = renderHook(() =>
+ useReverificationController(readyModel({ start: vi.fn(async () => Promise.reject(new Error('no session'))) })),
+ );
+ await waitFor(() => expect(result.current.status).toBe('unavailable'));
+ });
+
+ it('is unavailable when start returns no methods', async () => {
+ const { result } = renderHook(() =>
+ useReverificationController(
+ readyModel({
+ start: vi.fn(async () => firstFactorResult({ methods: [], startingMethod: null })),
+ }),
+ ),
+ );
+ await waitFor(() => expect(result.current.status).toBe('unavailable'));
+ });
+
+ it('omits onShowMethods when only one method is available', async () => {
+ const { result } = renderHook(() =>
+ useReverificationController(
+ readyModel({
+ start: vi.fn(async () => firstFactorResult({ methods: [password], startingMethod: password })),
+ }),
+ ),
+ );
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ if (result.current.status !== 'ready') {
+ throw new Error('expected ready');
+ }
+ expect(result.current.onShowMethods).toBeUndefined();
+ expect(result.current.step).toBe('password');
+ });
+
+ it('marks the current step pending while an attempt is in flight', async () => {
+ const attempt = deferred();
+ const { result } = renderHook(() =>
+ useReverificationController(readyModel({ attempt: () => attempt.promise })),
+ );
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ act(() => {
+ if (result.current.status === 'ready') {
+ result.current.onValueChange('secret');
+ result.current.onSubmit();
+ }
+ });
+
+ await waitFor(() => {
+ if (result.current.status === 'ready') {
+ expect(result.current.isPending).toBe(true);
+ }
+ });
+
+ act(() => {
+ attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null }));
+ });
+ });
+
+ it('stays on the current step pending while finish runs', async () => {
+ const finish = deferred();
+ const { result } = renderHook(() =>
+ useReverificationController(
+ readyModel({
+ attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })),
+ finish: () => finish.promise,
+ }),
+ ),
+ );
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ act(() => {
+ if (result.current.status === 'ready') {
+ result.current.onValueChange('secret');
+ result.current.onSubmit();
+ }
+ });
+
+ await waitFor(() => {
+ expect(result.current.status).toBe('ready');
+ if (result.current.status === 'ready') {
+ expect(result.current.step).toBe('password');
+ expect(result.current.isPending).toBe(true);
+ }
+ });
+
+ act(() => {
+ finish.resolve();
+ });
+ await waitFor(() => expect(result.current.status).toBe('loading'));
+ });
+
+ it('keeps the current step when the model flickers to loading', async () => {
+ const start = vi.fn(async () => firstFactorResult());
+ const { result, rerender } = renderHook(
+ ({ model }: { model: ReverificationModel }) => useReverificationController(model),
+ { initialProps: { model: readyModel({ start }) } },
+ );
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(start).toHaveBeenCalledOnce();
+
+ rerender({ model: { status: 'loading', isActive: true } });
+ expect(result.current.status).toBe('ready');
+ if (result.current.status === 'ready') {
+ expect(result.current.step).toBe('password');
+ }
+ expect(start).toHaveBeenCalledOnce();
+ });
+
+ it('keeps finish seated when the model flickers to loading', async () => {
+ const finish = deferred();
+ const finishFn = vi.fn(() => finish.promise);
+ const { result, rerender } = renderHook(
+ ({ model }: { model: ReverificationModel }) => useReverificationController(model),
+ {
+ initialProps: {
+ model: readyModel({
+ attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })),
+ finish: finishFn,
+ }),
+ },
+ },
+ );
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ act(() => {
+ if (result.current.status === 'ready') {
+ result.current.onValueChange('secret');
+ result.current.onSubmit();
+ }
+ });
+
+ await waitFor(() => {
+ expect(result.current.status).toBe('ready');
+ if (result.current.status === 'ready') {
+ expect(result.current.isPending).toBe(true);
+ }
+ });
+
+ rerender({ model: { status: 'loading', isActive: true } });
+ expect(result.current.status).toBe('ready');
+ if (result.current.status === 'ready') {
+ expect(result.current.step).toBe('password');
+ expect(result.current.isPending).toBe(true);
+ }
+
+ act(() => {
+ finish.resolve();
+ });
+ await waitFor(() => expect(result.current.status).toBe('loading'));
+ expect(finishFn).toHaveBeenCalledOnce();
+ });
+
+ it('starts again only when the handshake ends and reopens', async () => {
+ const start = vi.fn(async () => firstFactorResult());
+ const { result, rerender } = renderHook(
+ ({ model }: { model: ReverificationModel }) => useReverificationController(model),
+ { initialProps: { model: readyModel({ start }) } },
+ );
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ rerender({ model: readyModel({ start, isActive: false }) });
+ expect(result.current.status).toBe('idle');
+
+ rerender({ model: readyModel({ start, isActive: true }) });
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(start).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx
new file mode 100644
index 00000000000..187af8f5ad1
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx
@@ -0,0 +1,249 @@
+import type * as SharedReact from '@clerk/shared/react';
+import { ClerkAPIResponseError } from '@clerk/shared/error';
+import type { PreferredSignInStrategy, SessionVerificationResource } from '@clerk/shared/types';
+import { renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useReverificationModel, type ReverificationModel, type ReverificationReadyModel } from '../reverification.model';
+
+function ready(model: ReverificationModel): ReverificationReadyModel {
+ expect(model.status).toBe('ready');
+ if (model.status !== 'ready') {
+ throw new Error('expected ready');
+ }
+ return model;
+}
+
+let session: {
+ id: string;
+ startVerification: ReturnType;
+ prepareFirstFactorVerification: ReturnType;
+ prepareSecondFactorVerification: ReturnType;
+ attemptFirstFactorVerification: ReturnType;
+ attemptSecondFactorVerification: ReturnType;
+ verifyWithPasskey: ReturnType;
+} | null | undefined;
+let environmentHydrated: boolean;
+let preferredSignInStrategy: PreferredSignInStrategy;
+let supportEmail: string;
+let webAuthnSupported: boolean;
+let setActive: ReturnType;
+
+function environment() {
+ return environmentHydrated
+ ? { displayConfig: { preferredSignInStrategy, supportEmail } }
+ : undefined;
+}
+
+vi.mock('@clerk/shared/react', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useSession: () => ({ session }),
+ useClerk: () => ({ setActive }),
+ };
+});
+
+vi.mock('../../../hooks/useMosaicEnvironment', () => ({
+ useMosaicEnvironment: () => environment(),
+}));
+
+vi.mock('@clerk/shared/webauthn', () => ({
+ isWebAuthnSupported: () => webAuthnSupported,
+}));
+
+function resource(overrides: Partial = {}): SessionVerificationResource {
+ return {
+ status: 'needs_first_factor',
+ level: 'first_factor',
+ session: { id: 'sess_1' },
+ supportedFirstFactors: [{ strategy: 'password' }],
+ supportedSecondFactors: null,
+ ...overrides,
+ } as SessionVerificationResource;
+}
+
+function activeProps() {
+ return {
+ isActive: true as const,
+ complete: vi.fn(),
+ cancel: vi.fn(),
+ level: 'first_factor' as const,
+ };
+}
+
+describe('useReverificationModel', () => {
+ beforeEach(() => {
+ session = {
+ id: 'sess_1',
+ startVerification: vi.fn(),
+ prepareFirstFactorVerification: vi.fn(),
+ prepareSecondFactorVerification: vi.fn(),
+ attemptFirstFactorVerification: vi.fn(),
+ attemptSecondFactorVerification: vi.fn(),
+ verifyWithPasskey: vi.fn(),
+ };
+ environmentHydrated = true;
+ preferredSignInStrategy = 'password';
+ supportEmail = 'support@example.com';
+ webAuthnSupported = true;
+ setActive = vi.fn().mockResolvedValue(undefined);
+ });
+
+ it('is loading until the session and environment are both present', () => {
+ session = null;
+ const { result } = renderHook(() => useReverificationModel(activeProps()));
+ expect(result.current.status).toBe('loading');
+ expect(result.current.isActive).toBe(true);
+ });
+
+ it('is active when props are active', () => {
+ const { result } = renderHook(() => useReverificationModel(activeProps()));
+ expect(result.current.isActive).toBe(true);
+ });
+
+ it('is inactive when props are idle', () => {
+ const { result } = renderHook(() => useReverificationModel({ isActive: false }));
+ expect(result.current.isActive).toBe(false);
+ });
+
+ it('maps first-factor strategies and drops enterprise_sso and passkey without WebAuthn', async () => {
+ webAuthnSupported = false;
+ session?.startVerification.mockResolvedValue(
+ resource({
+ supportedFirstFactors: [
+ { strategy: 'password' },
+ { strategy: 'passkey' },
+ { strategy: 'email_code', emailAddressId: 'idn_1', safeIdentifier: 'a***@ex.com' },
+ { strategy: 'enterprise_sso', emailAddressId: 'idn_2', enterpriseConnectionId: 'ec_1', safeIdentifier: 'sso' },
+ ],
+ }),
+ );
+
+ const { result } = renderHook(() => useReverificationModel(activeProps()));
+ const started = await ready(result.current).start();
+
+ expect(session?.startVerification).toHaveBeenCalledWith({ level: 'first_factor' });
+ expect(started.methods.map(method => method.strategy)).toEqual(['password', 'email_code']);
+ expect(started.startingMethod?.strategy).toBe('password');
+ expect(started.methods.find(method => method.strategy === 'email_code')).toEqual({
+ id: 'email_code:idn_1',
+ strategy: 'email_code',
+ identifier: 'a***@ex.com',
+ emailAddressId: 'idn_1',
+ });
+ });
+
+ it('prefers passkey when WebAuthn is available', async () => {
+ session?.startVerification.mockResolvedValue(
+ resource({
+ supportedFirstFactors: [{ strategy: 'password' }, { strategy: 'passkey' }],
+ }),
+ );
+
+ const { result } = renderHook(() => useReverificationModel(activeProps()));
+ const started = await ready(result.current).start();
+ expect(started.startingMethod?.strategy).toBe('passkey');
+ });
+
+ it('starts second factor on totp then phone then the first remaining method', async () => {
+ session?.startVerification.mockResolvedValue(
+ resource({
+ status: 'needs_second_factor',
+ supportedFirstFactors: null,
+ supportedSecondFactors: [
+ { strategy: 'backup_code' },
+ { strategy: 'phone_code', phoneNumberId: 'pn_1', safeIdentifier: '+1••••1' },
+ { strategy: 'totp' },
+ ],
+ }),
+ );
+
+ const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: 'second_factor' }));
+ const started = await ready(result.current).start();
+ expect(started.status).toBe('needs_second_factor');
+ expect(started.startingMethod?.strategy).toBe('totp');
+ });
+
+ it('prepares and attempts with the Clerk param shape for the active method', async () => {
+ session?.startVerification.mockResolvedValue(resource());
+ session?.prepareFirstFactorVerification.mockResolvedValue(resource());
+ session?.attemptFirstFactorVerification.mockResolvedValue(resource({ status: 'complete' }));
+
+ const { result } = renderHook(() => useReverificationModel(activeProps()));
+ await ready(result.current).start();
+ await ready(result.current).prepare(
+ {
+ id: 'email_code:idn_1',
+ strategy: 'email_code',
+ identifier: 'a***@ex.com',
+ emailAddressId: 'idn_1',
+ },
+ 'needs_first_factor',
+ );
+ expect(session?.prepareFirstFactorVerification).toHaveBeenCalledWith({
+ strategy: 'email_code',
+ emailAddressId: 'idn_1',
+ });
+
+ await ready(result.current).attempt({ id: 'password', strategy: 'password' }, 'secret', 'needs_first_factor');
+ expect(session?.attemptFirstFactorVerification).toHaveBeenCalledWith({
+ strategy: 'password',
+ password: 'secret',
+ });
+ });
+
+ it('prepares second-factor phone codes and attempts totp against the second-factor API', async () => {
+ session?.startVerification.mockResolvedValue(
+ resource({
+ status: 'needs_second_factor',
+ supportedFirstFactors: null,
+ supportedSecondFactors: [{ strategy: 'totp' }, { strategy: 'phone_code', phoneNumberId: 'pn_1' }],
+ }),
+ );
+ session?.prepareSecondFactorVerification.mockResolvedValue(resource({ status: 'needs_second_factor' }));
+ session?.attemptSecondFactorVerification.mockResolvedValue(resource({ status: 'complete' }));
+
+ const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: 'second_factor' }));
+ await ready(result.current).start();
+ await ready(result.current).prepare(
+ { id: 'phone_code:pn_1', strategy: 'phone_code', phoneNumberId: 'pn_1' },
+ 'needs_second_factor',
+ );
+ expect(session?.prepareSecondFactorVerification).toHaveBeenCalledWith({
+ strategy: 'phone_code',
+ phoneNumberId: 'pn_1',
+ });
+
+ await ready(result.current).attempt({ id: 'totp', strategy: 'totp' }, '123456', 'needs_second_factor');
+ expect(session?.attemptSecondFactorVerification).toHaveBeenCalledWith({ strategy: 'totp', code: '123456' });
+ });
+
+ it('rewrites Clerk API errors to plain Error messages', async () => {
+ session?.attemptFirstFactorVerification.mockRejectedValue(
+ new ClerkAPIResponseError('nope', {
+ data: [
+ { code: 'form_password_incorrect', message: 'Incorrect password', long_message: 'That password is incorrect.' },
+ ],
+ status: 422,
+ }),
+ );
+
+ const { result } = renderHook(() => useReverificationModel(activeProps()));
+ await expect(
+ ready(result.current).attempt({ id: 'password', strategy: 'password' }, 'bad', 'needs_first_factor'),
+ ).rejects.toThrow(
+ 'That password is incorrect.',
+ );
+ });
+
+ it('activates the verified session and calls complete', async () => {
+ session?.startVerification.mockResolvedValue(resource({ status: 'complete' }));
+ const props = activeProps();
+ const { result } = renderHook(() => useReverificationModel(props));
+ await ready(result.current).start();
+ await ready(result.current).finish();
+ expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' });
+ expect(props.complete).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx
new file mode 100644
index 00000000000..0d3495d067d
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx
@@ -0,0 +1,81 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { Reverification } from '../reverification';
+import type { ReverificationController } from '../reverification.controller';
+import type { ReverificationModel } from '../reverification.model';
+import type { ReverificationViewProps } from '../reverification.types';
+
+const model: ReverificationModel = {
+ status: 'ready',
+ isActive: true,
+ supportEmail: '',
+ start: vi.fn(),
+ prepare: vi.fn(),
+ attempt: vi.fn(),
+ verifyPasskey: vi.fn(),
+ finish: vi.fn(),
+ cancel: vi.fn(),
+};
+
+let controller: ReverificationController = { status: 'idle' };
+
+vi.mock('../reverification.model', () => ({
+ useReverificationModel: () => model,
+}));
+
+vi.mock('../reverification.controller', () => ({
+ useReverificationController: () => controller,
+}));
+
+vi.mock('../reverification.view', () => ({
+ ReverificationView: ({ step }: { step: string }) => ,
+}));
+
+const active = {
+ isActive: true as const,
+ complete: vi.fn(),
+ cancel: vi.fn(),
+ level: 'first_factor' as const,
+};
+
+function ready(overrides: Partial = {}): ReverificationController {
+ return {
+ status: 'ready',
+ step: 'password',
+ value: '',
+ onValueChange: vi.fn(),
+ isPending: false,
+ onSubmit: vi.fn(),
+ onVerifyPasskey: vi.fn(),
+ onShowHelp: vi.fn(),
+ onEmailSupport: vi.fn(),
+ methods: [],
+ onSelectMethod: vi.fn(),
+ ...overrides,
+ };
+}
+
+describe('Reverification', () => {
+ it('renders nothing when reverification is not active', () => {
+ controller = { status: 'idle' };
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders nothing while the controller is loading or unavailable', () => {
+ controller = { status: 'loading' };
+ const loading = render();
+ expect(loading.container).toBeEmptyDOMElement();
+
+ controller = { status: 'unavailable' };
+ const unavailable = render();
+ expect(unavailable.container).toBeEmptyDOMElement();
+ });
+
+ it('renders the view once the controller is ready', () => {
+ controller = ready({ step: 'otp', otpChannel: 'email' });
+ render();
+ expect(screen.getByTestId('view')).toHaveTextContent('otp');
+ });
+});
diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx
new file mode 100644
index 00000000000..bc17987a3f7
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx
@@ -0,0 +1,86 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { MosaicProvider } from '../../../MosaicProvider';
+import type { ReverificationViewProps } from '../reverification.types';
+import { ReverificationView } from '../reverification.view';
+
+function viewProps(overrides: Partial = {}): ReverificationViewProps {
+ return {
+ step: 'password',
+ value: '',
+ onValueChange: vi.fn(),
+ isPending: false,
+ onSubmit: vi.fn(),
+ onVerifyPasskey: vi.fn(),
+ onShowMethods: vi.fn(),
+ onShowHelp: vi.fn(),
+ onEmailSupport: vi.fn(),
+ methods: [],
+ onSelectMethod: vi.fn(),
+ ...overrides,
+ };
+}
+
+function renderView(overrides: Partial = {}) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe('ReverificationView', () => {
+ it('keeps one Card and Flow surface while view props and panels change', () => {
+ const { container, rerender } = renderView();
+
+ const card = container.querySelector('.cl-card-root');
+ const flow = container.querySelector('.cl-flow-root');
+ const passwordStep = screen.getByLabelText('Password').closest('.cl-flow-step');
+
+ expect(card).not.toBeNull();
+ expect(card).toContainElement(flow);
+ expect(flow).toHaveAttribute('data-value', 'password');
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(container.querySelector('.cl-card-root')).toBe(card);
+ expect(container.querySelector('.cl-flow-root')).toBe(flow);
+ expect(screen.getByLabelText('Password').closest('.cl-flow-step')).toBe(passwordStep);
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(container.querySelector('.cl-card-root')).toBe(card);
+ expect(container.querySelector('.cl-flow-root')).toBe(flow);
+ expect(screen.queryByLabelText('Password')).not.toBeInTheDocument();
+ const otpStep = screen.getByRole('group', { name: 'Verification code' }).closest('.cl-flow-step');
+ expect(otpStep).toBeInTheDocument();
+ expect(otpStep?.style.getPropertyValue('--cl-flow-transition-direction')).toBe('-1');
+ });
+
+ it('does not render Card branding', () => {
+ renderView();
+
+ expect(screen.queryByText('Secured by')).not.toBeInTheDocument();
+ });
+
+ it('renders a passkey attempt error in a negative Banner', () => {
+ renderView({
+ step: 'passkey',
+ errorMessage: 'We couldn’t verify that passkey. Try again.',
+ });
+
+ const banner = screen.getByRole('alert');
+ expect(banner).toHaveClass('cl-banner-root');
+ expect(banner).toHaveAttribute('data-color', 'negative');
+ expect(banner).toHaveTextContent('We couldn’t verify that passkey. Try again.');
+ });
+});
diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx
new file mode 100644
index 00000000000..45f6d14c861
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx
@@ -0,0 +1,139 @@
+import type * as SharedReact from '@clerk/shared/react';
+import type { SessionVerificationLevel } from '@clerk/shared/types';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useReverificationWithState } from '../use-reverification-with-state';
+
+type NeedsReverificationParameters = {
+ complete: () => void;
+ cancel: () => void;
+ level: SessionVerificationLevel | undefined;
+};
+
+let capturedOnNeeds: ((params: NeedsReverificationParameters) => void) | undefined;
+let session: { id: string } | null | undefined = { id: 'sess_1' };
+const wrapped = vi.fn();
+
+vi.mock('@clerk/shared/react', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useSession: () => ({ session }),
+ useReverification: (_fetcher: unknown, options?: { onNeedsReverification?: (params: NeedsReverificationParameters) => void }) => {
+ capturedOnNeeds = options?.onNeedsReverification;
+ return wrapped;
+ },
+ };
+});
+
+const fetcher = async (id: string) => id;
+
+describe('useReverificationWithState', () => {
+ beforeEach(() => {
+ capturedOnNeeds = undefined;
+ session = { id: 'sess_1' };
+ wrapped.mockReset();
+ });
+
+ it('returns the enhanced fetcher and is idle until reverification is needed', () => {
+ const { result } = renderHook(() => useReverificationWithState(fetcher));
+ const [callback, state] = result.current;
+
+ expect(callback).toBe(wrapped);
+ expect(state.isActive).toBe(false);
+ expect(state.complete).toBeUndefined();
+ expect(state.cancel).toBeUndefined();
+ expect(state.level).toBeUndefined();
+ });
+
+ it('surfaces complete, cancel, and level when useReverification needs reverification', () => {
+ const { result } = renderHook(() => useReverificationWithState(fetcher));
+ const complete = vi.fn();
+ const cancel = vi.fn();
+
+ act(() => {
+ capturedOnNeeds?.({ complete, cancel, level: 'first_factor' });
+ });
+
+ const [, state] = result.current;
+ expect(state.isActive).toBe(true);
+ if (!state.isActive) {
+ throw new Error('expected active reverification state');
+ }
+ expect(state.level).toBe('first_factor');
+
+ act(() => {
+ state.complete();
+ });
+
+ expect(complete).toHaveBeenCalledOnce();
+ expect(result.current[1].isActive).toBe(false);
+ });
+
+ it('returns to idle after cancel and calls the state cancel', () => {
+ const { result } = renderHook(() => useReverificationWithState(fetcher));
+ const complete = vi.fn();
+ const cancel = vi.fn();
+
+ act(() => {
+ capturedOnNeeds?.({ complete, cancel, level: undefined });
+ });
+
+ act(() => {
+ const [, state] = result.current;
+ if (state.isActive) {
+ state.cancel();
+ }
+ });
+
+ expect(cancel).toHaveBeenCalledOnce();
+ expect(complete).not.toHaveBeenCalled();
+ expect(result.current[1].isActive).toBe(false);
+ });
+
+ it('cancels when the session changes after reverification opens', async () => {
+ const { result, rerender } = renderHook(() => useReverificationWithState(fetcher));
+ const cancel = vi.fn();
+
+ act(() => {
+ capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' });
+ });
+ expect(result.current[1].isActive).toBe(true);
+
+ session = { id: 'sess_2' };
+ rerender();
+ await waitFor(() => expect(cancel).toHaveBeenCalledOnce());
+ expect(result.current[1].isActive).toBe(false);
+ });
+
+ it('does not cancel when the session is briefly unloaded', async () => {
+ const { rerender } = renderHook(() => useReverificationWithState(fetcher));
+ const cancel = vi.fn();
+
+ act(() => {
+ capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' });
+ });
+
+ const previous = session;
+ session = undefined;
+ rerender();
+ session = previous;
+ rerender();
+ expect(cancel).not.toHaveBeenCalled();
+ });
+
+ it('cancels when the session is signed out', async () => {
+ const { result, rerender } = renderHook(() => useReverificationWithState(fetcher));
+ const cancel = vi.fn();
+
+ act(() => {
+ capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' });
+ });
+
+ session = null;
+ rerender();
+ await waitFor(() => expect(cancel).toHaveBeenCalledOnce());
+ expect(result.current[1].isActive).toBe(false);
+ });
+});
diff --git a/packages/ui/src/mosaic/features/reverification/index.ts b/packages/ui/src/mosaic/features/reverification/index.ts
new file mode 100644
index 00000000000..fbb031b5636
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/index.ts
@@ -0,0 +1,11 @@
+export { Reverification } from './reverification';
+export type {
+ ReverificationMethod,
+ ReverificationOtpChannel,
+ ReverificationProps,
+ ReverificationStep,
+ ReverificationStrategy,
+ ReverificationViewProps,
+} from './reverification.types';
+export type { UseReverificationWithStateOptions, UseReverificationWithStateResult } from './use-reverification-with-state';
+export { useReverificationWithState } from './use-reverification-with-state';
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx
similarity index 91%
rename from packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx
rename to packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx
index d18e8d51ee3..3bf52f7a921 100644
--- a/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx
+++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx
@@ -1,10 +1,10 @@
import type { FormEvent } from 'react';
import { useId } from 'react';
-import { Button, SubmitButton } from '../../components/button';
-import { Card } from '../../components/card';
-import { Field } from '../../components/field';
-import { Input } from '../../components/input';
+import { Button, SubmitButton } from '../../../components/button';
+import { Card } from '../../../components/card';
+import { Field } from '../../../components/field';
+import { Input } from '../../../components/input';
export interface ReverificationBackupCodeMessages {
title: string;
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-help.tsx
similarity index 90%
rename from packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx
rename to packages/ui/src/mosaic/features/reverification/panels/reverification-help.tsx
index 11a883fcff6..7d4a2577fb3 100644
--- a/packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx
+++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-help.tsx
@@ -1,5 +1,5 @@
-import { Button } from '../../components/button';
-import { Card } from '../../components/card';
+import { Button } from '../../../components/button';
+import { Card } from '../../../components/card';
export interface ReverificationHelpMessages {
title: string;
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx
similarity index 86%
rename from packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx
rename to packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx
index 9d481bbe504..b41947b6d6a 100644
--- a/packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx
+++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-method-picker.tsx
@@ -1,11 +1,11 @@
-import { Button } from '../../components/button';
-import { Card } from '../../components/card';
-import type { IconProps } from '../../components/icon';
-import { Icon, IconFrame } from '../../components/icon';
-import { Item } from '../../components/item';
-import { Spinner } from '../../components/spinner';
-import { Text } from '../../components/text';
-import { space } from '../../tokens.stylex';
+import { Button } from '../../../components/button';
+import { Card } from '../../../components/card';
+import type { IconProps } from '../../../components/icon';
+import { Icon, IconFrame } from '../../../components/icon';
+import { Item } from '../../../components/item';
+import { Spinner } from '../../../components/spinner';
+import { Text } from '../../../components/text';
+import { space } from '../../../tokens.stylex';
export interface ReverificationMethod {
id: string;
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-otp.tsx
similarity index 93%
rename from packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx
rename to packages/ui/src/mosaic/features/reverification/panels/reverification-otp.tsx
index 685e38e02f2..fa0d297d188 100644
--- a/packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx
+++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-otp.tsx
@@ -1,10 +1,10 @@
import type { FormEvent } from 'react';
import { useId } from 'react';
-import { Button, SubmitButton } from '../../components/button';
-import { Card } from '../../components/card';
-import { Field } from '../../components/field';
-import { Otp } from '../../components/otp';
+import { Button, SubmitButton } from '../../../components/button';
+import { Card } from '../../../components/card';
+import { Field } from '../../../components/field';
+import { Otp } from '../../../components/otp';
export interface ReverificationOtpMessages {
title: string;
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-passkey.tsx
similarity index 90%
rename from packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx
rename to packages/ui/src/mosaic/features/reverification/panels/reverification-passkey.tsx
index 7609b921742..943be1f3dde 100644
--- a/packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx
+++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-passkey.tsx
@@ -1,6 +1,6 @@
-import { Banner } from '../../components/banner';
-import { Button, SubmitButton } from '../../components/button';
-import { Card } from '../../components/card';
+import { Banner } from '../../../components/banner';
+import { Button, SubmitButton } from '../../../components/button';
+import { Card } from '../../../components/card';
export interface ReverificationPasskeyMessages {
title: string;
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-password.tsx
similarity index 91%
rename from packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx
rename to packages/ui/src/mosaic/features/reverification/panels/reverification-password.tsx
index f229fe2e44d..2a569903e45 100644
--- a/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx
+++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-password.tsx
@@ -1,10 +1,10 @@
import type { FormEvent } from 'react';
import { useId } from 'react';
-import { Button, SubmitButton } from '../../components/button';
-import { Card } from '../../components/card';
-import { Field } from '../../components/field';
-import { Input } from '../../components/input';
+import { Button, SubmitButton } from '../../../components/button';
+import { Card } from '../../../components/card';
+import { Field } from '../../../components/field';
+import { Input } from '../../../components/input';
export interface ReverificationPasswordMessages {
title: string;
diff --git a/packages/ui/src/mosaic/features/reverification/reverification.controller.ts b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts
new file mode 100644
index 00000000000..af7c682e554
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts
@@ -0,0 +1,430 @@
+import { useEffect } from 'react';
+
+import type { FlowDirection } from '../../components/flow';
+import { setup } from '../../machine/setup';
+import type { DoneInvokeEvent } from '../../machine/types';
+import { useMachine } from '../../machine/useMachine';
+import type { ReverificationModel } from './reverification.model';
+import type { ReverificationMethod, ReverificationResult, ReverificationViewProps } from './reverification.types';
+import { needsPrepare, otpChannelFor } from './reverification.utils';
+
+export type ReverificationController =
+ | { status: 'idle' }
+ | { status: 'loading' }
+ | { status: 'unavailable' }
+ | ({ status: 'ready' } & ReverificationViewProps);
+
+type OverlayFrom = 'factor' | 'method-picker';
+
+export type ReverificationDeps = {
+ start: () => Promise;
+ prepare: (method: ReverificationMethod, verificationStatus: ReverificationResult['status']) => Promise;
+ attempt: (
+ method: ReverificationMethod,
+ value: string,
+ verificationStatus: ReverificationResult['status'],
+ ) => Promise;
+ verifyPasskey: () => Promise;
+ finish: () => Promise;
+ cancel: () => void;
+};
+
+interface ReverificationContext {
+ inputValue: string;
+ errorMessage: string | undefined;
+ direction: FlowDirection;
+ activeMethod: ReverificationMethod | null;
+ methods: readonly ReverificationMethod[];
+ canResend: boolean;
+ abortRequested: boolean;
+ overlayFrom: OverlayFrom;
+ supportEmail: string;
+ verificationStatus: ReverificationResult['status'] | null;
+ deps: ReverificationDeps;
+}
+
+type ReverificationEvent =
+ | { type: 'START' }
+ | { type: 'RESET' }
+ | { type: 'TYPE'; value: string }
+ | { type: 'SUBMIT' }
+ | { type: 'SHOW_METHODS' }
+ | { type: 'SHOW_HELP' }
+ | { type: 'SELECT_METHOD'; id: string }
+ | { type: 'BACK' }
+ | { type: 'RESEND' }
+ | { type: 'ABORT' };
+
+const { createMachine, assign, fromPromise } = setup();
+
+const unseatedDeps: ReverificationDeps = {
+ start: () => Promise.reject(new Error('start is not seated')),
+ prepare: () => Promise.resolve(),
+ attempt: () => Promise.reject(new Error('attempt is not seated')),
+ verifyPasskey: () => Promise.reject(new Error('verifyPasskey is not seated')),
+ finish: () => Promise.resolve(),
+ cancel: () => {},
+};
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : 'Something went wrong. Please try again.';
+}
+
+function selectMethod(ctx: ReverificationContext, id: string): Partial {
+ return {
+ activeMethod: ctx.methods.find(method => method.id === id) ?? ctx.activeMethod,
+ inputValue: '',
+ errorMessage: undefined,
+ direction: 1,
+ };
+}
+
+function prepareActive(ctx: ReverificationContext) {
+ return ctx.deps.prepare(
+ ctx.activeMethod as ReverificationMethod,
+ ctx.verificationStatus ?? 'needs_first_factor',
+ );
+}
+
+function submit(ctx: ReverificationContext): Promise {
+ const method = ctx.activeMethod as ReverificationMethod;
+ return method.strategy === 'passkey'
+ ? ctx.deps.verifyPasskey()
+ : ctx.deps.attempt(method, ctx.inputValue, ctx.verificationStatus ?? 'needs_first_factor');
+}
+
+const applyResult = assign>((_, event) => ({
+ methods: event.output.methods,
+ activeMethod: event.output.startingMethod,
+ verificationStatus: event.output.status,
+ inputValue: '',
+ errorMessage: undefined,
+ canResend: true,
+}));
+const afterResult = [
+ {
+ guard: (_: ReverificationContext, event: DoneInvokeEvent) =>
+ event.output.status === 'complete',
+ target: 'completing' as const,
+ // We don't apply the result here as we want to keep the old one visible as we are completing
+ },
+ {
+ guard: (_: ReverificationContext, event: DoneInvokeEvent) =>
+ event.output.startingMethod === null,
+ target: 'unavailable' as const,
+ actions: applyResult,
+ },
+ {
+ guard: (_: ReverificationContext, event: DoneInvokeEvent) => {
+ const strategy = event.output.startingMethod?.strategy;
+ return Boolean(strategy && needsPrepare(strategy));
+ },
+ target: 'preparing' as const,
+ actions: applyResult,
+ },
+ { target: 'verifying' as const, actions: applyResult },
+];
+
+const abortNow = {
+ target: 'done' as const,
+ actions: (ctx: ReverificationContext) => {
+ ctx.deps.cancel();
+ },
+};
+
+const abortAfterInvoke = {
+ target: 'done' as const,
+ guard: (ctx: ReverificationContext) => ctx.abortRequested,
+ actions: [
+ (ctx: ReverificationContext) => {
+ ctx.deps.cancel();
+ },
+ assign(() => ({ abortRequested: false })),
+ ],
+};
+
+export const reverificationMachine = createMachine({
+ id: 'reverification',
+ initial: 'inactive',
+ context: {
+ inputValue: '',
+ errorMessage: undefined,
+ direction: 1,
+ activeMethod: null,
+ methods: [],
+ canResend: true,
+ abortRequested: false,
+ overlayFrom: 'factor',
+ supportEmail: '',
+ verificationStatus: null,
+ deps: unseatedDeps,
+ },
+ states: {
+ inactive: {
+ entry: assign(() => ({
+ inputValue: '',
+ errorMessage: undefined,
+ abortRequested: false,
+ canResend: true,
+ verificationStatus: null,
+ })),
+ on: { START: 'starting' },
+ },
+
+ starting: {
+ on: { RESET: 'inactive' },
+ invoke: fromPromise(ctx => ctx.deps.start(), {
+ onDone: afterResult,
+ onError: 'unavailable',
+ }),
+ },
+
+ preparing: {
+ on: { RESET: 'inactive', ABORT: abortNow },
+ invoke: fromPromise(prepareActive, {
+ onDone: 'verifying',
+ onError: {
+ target: 'verifying',
+ actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })),
+ },
+ }),
+ },
+
+ verifying: {
+ after: {
+ 30_000: { guard: ctx => !ctx.canResend, actions: assign(() => ({ canResend: true })) },
+ },
+ on: {
+ TYPE: {
+ actions: assign((_, event) => ({ inputValue: event.value, errorMessage: undefined })),
+ },
+ SUBMIT: 'submitting',
+ RESEND: {
+ target: 'resending',
+ guard: ctx => Boolean(ctx.activeMethod && needsPrepare(ctx.activeMethod.strategy) && ctx.canResend),
+ },
+ SHOW_METHODS: {
+ target: 'methodPicker',
+ guard: ctx => ctx.methods.filter(method => method.id !== ctx.activeMethod?.id).length > 0,
+ actions: assign(() => ({ direction: 1 as const, overlayFrom: 'factor' as const })),
+ },
+ SHOW_HELP: {
+ target: 'help',
+ actions: assign(() => ({ direction: 1 as const, overlayFrom: 'factor' as const })),
+ },
+ ABORT: abortNow,
+ RESET: 'inactive',
+ },
+ },
+
+ submitting: {
+ on: {
+ ABORT: { actions: assign(() => ({ abortRequested: true })) },
+ RESET: { actions: assign(() => ({ abortRequested: true })) },
+ },
+ invoke: fromPromise(submit, {
+ onDone: afterResult,
+ onError: [
+ abortAfterInvoke,
+ {
+ target: 'verifying',
+ actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })),
+ },
+ ],
+ }),
+ },
+
+ resending: {
+ on: { RESET: 'inactive' },
+ invoke: fromPromise(prepareActive, {
+ onDone: {
+ target: 'verifying',
+ actions: assign(() => ({ canResend: false, inputValue: '', errorMessage: undefined })),
+ },
+ onError: {
+ target: 'verifying',
+ actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })),
+ },
+ }),
+ },
+
+ methodPicker: {
+ on: {
+ SELECT_METHOD: [
+ {
+ target: 'preparing',
+ guard: (ctx, event) => {
+ const method = ctx.methods.find(candidate => candidate.id === event.id);
+ return Boolean(method && needsPrepare(method.strategy));
+ },
+ actions: assign((ctx, event) => selectMethod(ctx, event.id)),
+ },
+ { target: 'verifying', actions: assign((ctx, event) => selectMethod(ctx, event.id)) },
+ ],
+ SHOW_HELP: {
+ target: 'help',
+ actions: assign(() => ({ direction: 1 as const, overlayFrom: 'method-picker' as const })),
+ },
+ BACK: { target: 'verifying', actions: assign(() => ({ direction: -1 as const })) },
+ ABORT: abortNow,
+ RESET: 'inactive',
+ },
+ },
+
+ help: {
+ on: {
+ BACK: [
+ {
+ target: 'methodPicker',
+ guard: ctx => ctx.overlayFrom === 'method-picker',
+ actions: assign(() => ({ direction: -1 as const })),
+ },
+ { target: 'verifying', actions: assign(() => ({ direction: -1 as const })) },
+ ],
+ ABORT: abortNow,
+ RESET: 'inactive',
+ },
+ },
+
+ unavailable: {
+ on: { ABORT: abortNow, RESET: 'inactive' },
+ },
+
+ completing: {
+ on: { RESET: 'inactive' },
+ invoke: fromPromise(ctx => ctx.deps.finish(), {
+ onDone: 'done',
+ onError: 'done',
+ }),
+ },
+
+ done: {
+ on: { RESET: 'inactive' },
+ },
+ },
+});
+
+const pendingStates = new Set(['starting', 'preparing', 'submitting', 'resending', 'completing']);
+
+function viewStep(value: string, method: ReverificationMethod | null): ReverificationViewProps['step'] | undefined {
+ if (value === 'methodPicker') {
+ return 'method-picker';
+ }
+ if (value === 'help') {
+ return 'help';
+ }
+ if (
+ value === 'verifying' ||
+ value === 'submitting' ||
+ value === 'preparing' ||
+ value === 'resending' ||
+ value === 'completing'
+ ) {
+ if (!method) {
+ return undefined;
+ }
+ if (method.strategy === 'password') {
+ return 'password';
+ }
+ if (method.strategy === 'passkey') {
+ return 'passkey';
+ }
+ if (method.strategy === 'backup_code') {
+ return 'backup-code';
+ }
+ return 'otp';
+ }
+ return undefined;
+}
+
+/**
+ * Machine - State internal to the controller, not all steps are exposed to the UI
+ * Return 'ReverificationController' - The view state
+ * - status: idle | loading | unavailable | ready
+ * - When ready
+ * - step: The visible reverification step
+ *
+ * Note that there are two loading states.
+ * - status: 'loading' - Full card spinner
+ * - status: 'ready' && isPending: true - Current action is pending, inline loading state
+ */
+export function useReverificationController(model: ReverificationModel): ReverificationController {
+ const ready = model.status === 'ready' ? model : null;
+
+ const [snapshot, send] = useMachine(
+ reverificationMachine,
+ ready
+ ? {
+ context: {
+ supportEmail: ready.supportEmail,
+ deps: {
+ start: ready.start,
+ prepare: ready.prepare,
+ attempt: ready.attempt,
+ verifyPasskey: ready.verifyPasskey,
+ finish: ready.finish,
+ cancel: ready.cancel,
+ },
+ },
+ }
+ : undefined,
+ );
+
+ useEffect(() => {
+ if (model.isActive && ready && snapshot.value === 'inactive') {
+ send({ type: 'START' });
+ } else if (!model.isActive && snapshot.value !== 'inactive') {
+ send({ type: 'RESET' });
+ }
+ });
+
+ if (!model.isActive) {
+ return { status: 'idle' };
+ }
+
+ if (snapshot.value === 'inactive' || snapshot.value === 'starting' || snapshot.value === 'done') {
+ return { status: 'loading' };
+ }
+
+ if (snapshot.value === 'unavailable') {
+ return { status: 'unavailable' };
+ }
+
+ const { context } = snapshot;
+ const step = viewStep(snapshot.value, context.activeMethod);
+ if (!step) {
+ return { status: 'unavailable' };
+ }
+
+ const activeMethod = context.activeMethod;
+ const alternativeMethods = context.methods.filter(method => method.id !== activeMethod?.id);
+
+ return {
+ status: 'ready',
+ step,
+ direction: context.direction,
+ value: context.inputValue,
+ onValueChange: value => send({ type: 'TYPE', value }),
+ errorMessage: context.errorMessage,
+ isPending: pendingStates.has(snapshot.value),
+ onSubmit: () => send({ type: 'SUBMIT' }),
+ onVerifyPasskey: () => send({ type: 'SUBMIT' }),
+ onShowMethods: alternativeMethods.length > 0 ? () => send({ type: 'SHOW_METHODS' }) : undefined,
+ onShowHelp: () => send({ type: 'SHOW_HELP' }),
+ onBack: step === 'method-picker' || step === 'help' ? () => send({ type: 'BACK' }) : undefined,
+ onEmailSupport: () => {
+ if (context.supportEmail) {
+ window.location.assign(`mailto:${context.supportEmail}`);
+ }
+ },
+ methods: alternativeMethods,
+ onSelectMethod: id => send({ type: 'SELECT_METHOD', id }),
+ otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined,
+ identifier: activeMethod?.identifier,
+ onResend:
+ activeMethod && needsPrepare(activeMethod.strategy) && context.canResend
+ ? () => send({ type: 'RESEND' })
+ : undefined,
+ canResend: context.canResend,
+ };
+}
diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts b/packages/ui/src/mosaic/features/reverification/reverification.messages.ts
similarity index 100%
rename from packages/ui/src/mosaic/blocks/reverification/reverification.messages.ts
rename to packages/ui/src/mosaic/features/reverification/reverification.messages.ts
diff --git a/packages/ui/src/mosaic/features/reverification/reverification.model.tsx b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx
new file mode 100644
index 00000000000..59726da7b2c
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx
@@ -0,0 +1,190 @@
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+import { useClerk, useSession } from '@clerk/shared/react';
+import type {
+ PreferredSignInStrategy,
+ SessionVerificationFirstFactor,
+ SessionVerificationResource,
+ SessionVerificationSecondFactor,
+} from '@clerk/shared/types';
+import { isWebAuthnSupported } from '@clerk/shared/webauthn';
+
+import { useMosaicEnvironment } from '../../hooks/useMosaicEnvironment';
+import type { ReverificationMethod, ReverificationProps, ReverificationResult } from './reverification.types';
+import { pickStartingMethod } from './reverification.utils';
+
+export type ReverificationReadyModel = {
+ status: 'ready';
+ isActive: boolean;
+ supportEmail: string;
+ start: () => Promise;
+ prepare: (method: ReverificationMethod, verificationStatus: ReverificationResult['status']) => Promise;
+ attempt: (
+ method: ReverificationMethod,
+ value: string,
+ verificationStatus: ReverificationResult['status'],
+ ) => Promise;
+ verifyPasskey: () => Promise;
+ finish: () => Promise;
+ cancel: () => void;
+};
+
+export type ReverificationModel = { status: 'loading'; isActive: boolean } | ReverificationReadyModel;
+
+function toError(error: unknown): Error {
+ if (isClerkAPIResponseError(error)) {
+ const first = error.errors[0];
+ return new Error(first?.longMessage || first?.message || error.message);
+ }
+ return error instanceof Error ? error : new Error('Something went wrong. Please try again.');
+}
+
+function toMethod(
+ factor: SessionVerificationFirstFactor | SessionVerificationSecondFactor,
+ webAuthnSupported: boolean,
+): ReverificationMethod | null {
+ if (factor.strategy === 'passkey' && !webAuthnSupported) {
+ return null;
+ }
+
+ if (factor.strategy === 'email_code') {
+ return {
+ id: `email_code:${factor.emailAddressId}`,
+ strategy: 'email_code',
+ identifier: factor.safeIdentifier,
+ emailAddressId: factor.emailAddressId,
+ };
+ }
+
+ if (factor.strategy === 'phone_code') {
+ return {
+ id: `phone_code:${factor.phoneNumberId}`,
+ strategy: 'phone_code',
+ identifier: factor.safeIdentifier,
+ phoneNumberId: factor.phoneNumberId,
+ };
+ }
+
+ switch (factor.strategy) {
+ case 'password':
+ case 'passkey':
+ case 'totp':
+ case 'backup_code':
+ return { id: factor.strategy, strategy: factor.strategy };
+ default:
+ return null;
+ }
+}
+
+function toResult(
+ resource: SessionVerificationResource,
+ preferredSignInStrategy: PreferredSignInStrategy | undefined,
+ webAuthnSupported: boolean,
+): ReverificationResult {
+ const raw =
+ resource.status === 'needs_second_factor' ? resource.supportedSecondFactors : resource.supportedFirstFactors;
+ const methods = (raw ?? [])
+ .map(factor => toMethod(factor, webAuthnSupported))
+ .filter((method): method is ReverificationMethod => method !== null);
+
+ return {
+ status: resource.status,
+ methods,
+ startingMethod: pickStartingMethod(methods, resource.status, preferredSignInStrategy, webAuthnSupported),
+ };
+}
+
+export function useReverificationModel(props: ReverificationProps): ReverificationModel {
+ const { session } = useSession();
+ const clerk = useClerk();
+ const environment = useMosaicEnvironment();
+ const { isActive, cancel, complete, level } = props;
+
+ if (!session || !environment) {
+ return { status: 'loading', isActive };
+ }
+
+ const webAuthnSupported = isWebAuthnSupported();
+ const preferredSignInStrategy = environment.displayConfig.preferredSignInStrategy;
+
+ const handleResponse = (resource: SessionVerificationResource) =>
+ toResult(resource, preferredSignInStrategy, webAuthnSupported);
+
+ return {
+ status: 'ready',
+ isActive,
+ supportEmail: environment.displayConfig.supportEmail ?? '',
+ start: async () => {
+ try {
+ return handleResponse(await session.startVerification({ level: level ?? 'first_factor' }));
+ } catch (error) {
+ throw toError(error);
+ }
+ },
+ verifyPasskey: async () => {
+ try {
+ return handleResponse(await session.verifyWithPasskey());
+ } catch (error) {
+ throw toError(error);
+ }
+ },
+ cancel: () => {
+ cancel?.();
+ },
+ prepare: async (method, verificationStatus) => {
+ try {
+ if (verificationStatus === 'needs_second_factor') {
+ await session.prepareSecondFactorVerification({
+ strategy: 'phone_code',
+ phoneNumberId: method.phoneNumberId,
+ });
+ return;
+ }
+ if (method.strategy === 'email_code') {
+ await session.prepareFirstFactorVerification({
+ strategy: 'email_code',
+ emailAddressId: method.emailAddressId ?? '',
+ });
+ return;
+ }
+ if (method.strategy === 'phone_code') {
+ await session.prepareFirstFactorVerification({
+ strategy: 'phone_code',
+ phoneNumberId: method.phoneNumberId ?? '',
+ });
+ }
+ } catch (error) {
+ throw toError(error);
+ }
+ },
+ attempt: async (method, value, verificationStatus) => {
+ try {
+ if (verificationStatus === 'needs_second_factor') {
+ const strategy = method.strategy === 'totp' || method.strategy === 'backup_code' ? method.strategy : 'phone_code';
+ return handleResponse(await session.attemptSecondFactorVerification({ strategy, code: value }));
+ }
+ if (method.strategy === 'password') {
+ return handleResponse(await session.attemptFirstFactorVerification({ strategy: 'password', password: value }));
+ }
+ return handleResponse(
+ await session.attemptFirstFactorVerification({
+ strategy: method.strategy === 'phone_code' ? 'phone_code' : 'email_code',
+ code: value,
+ }),
+ );
+ } catch (error) {
+ throw toError(error);
+ }
+ },
+ finish: async () => {
+ try {
+ try {
+ await clerk.setActive({ session: session.id });
+ } finally {
+ complete?.();
+ }
+ } catch (error) {
+ throw toError(error);
+ }
+ },
+ };
+}
diff --git a/packages/ui/src/mosaic/features/reverification/reverification.tsx b/packages/ui/src/mosaic/features/reverification/reverification.tsx
new file mode 100644
index 00000000000..6cdceb9b252
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/reverification.tsx
@@ -0,0 +1,16 @@
+import { useReverificationController } from './reverification.controller';
+import { useReverificationModel } from './reverification.model';
+import type { ReverificationProps } from './reverification.types';
+import { ReverificationView } from './reverification.view';
+
+export function Reverification(props: ReverificationProps) {
+ const model = useReverificationModel(props);
+ const ui = useReverificationController(model);
+
+ if (!props.isActive || ui.status !== 'ready') {
+ return null;
+ }
+
+ const { status: _status, ...viewProps } = ui;
+ return ;
+}
diff --git a/packages/ui/src/mosaic/features/reverification/reverification.types.ts b/packages/ui/src/mosaic/features/reverification/reverification.types.ts
new file mode 100644
index 00000000000..eec60c70649
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/reverification.types.ts
@@ -0,0 +1,48 @@
+import type { SessionVerificationLevel } from '@clerk/shared/types';
+
+import type { FlowDirection } from '../../components/flow';
+
+export type ReverificationStrategy = 'password' | 'passkey' | 'email_code' | 'phone_code' | 'totp' | 'backup_code';
+
+export type ReverificationOtpChannel = 'email' | 'phone' | 'totp';
+
+export type ReverificationStep = 'password' | 'passkey' | 'otp' | 'backup-code' | 'method-picker' | 'help';
+
+export type ReverificationMethod = {
+ id: string;
+ strategy: ReverificationStrategy;
+ identifier?: string;
+ emailAddressId?: string;
+ phoneNumberId?: string;
+};
+
+export type ReverificationViewProps = {
+ step: ReverificationStep;
+ direction?: FlowDirection;
+ value: string;
+ onValueChange: (value: string) => void;
+ errorMessage?: string;
+ isPending: boolean;
+ onSubmit: () => void;
+ onVerifyPasskey: () => void;
+ onShowMethods?: () => void;
+ onShowHelp: () => void;
+ onBack?: () => void;
+ onEmailSupport: () => void;
+ methods: readonly ReverificationMethod[];
+ onSelectMethod: (id: string) => void;
+ otpChannel?: ReverificationOtpChannel;
+ identifier?: string;
+ onResend?: () => void;
+ canResend?: boolean;
+};
+
+export type ReverificationProps =
+ | { isActive: false; complete?: undefined; cancel?: undefined; level?: undefined }
+ | { isActive: true; complete: () => void; cancel: () => void; level: SessionVerificationLevel | undefined };
+
+export type ReverificationResult = {
+ status: 'needs_first_factor' | 'needs_second_factor' | 'complete';
+ methods: readonly ReverificationMethod[];
+ startingMethod: ReverificationMethod | null;
+};
diff --git a/packages/ui/src/mosaic/features/reverification/reverification.utils.ts b/packages/ui/src/mosaic/features/reverification/reverification.utils.ts
new file mode 100644
index 00000000000..0ab365af43b
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/reverification.utils.ts
@@ -0,0 +1,70 @@
+import type { PreferredSignInStrategy } from '@clerk/shared/types';
+
+import type {
+ ReverificationMethod,
+ ReverificationOtpChannel,
+ ReverificationResult,
+ ReverificationStrategy,
+} from './reverification.types';
+
+export function otpChannelFor(strategy: ReverificationStrategy): ReverificationOtpChannel | undefined {
+ if (strategy === 'email_code') {
+ return 'email';
+ }
+ if (strategy === 'phone_code') {
+ return 'phone';
+ }
+ if (strategy === 'totp') {
+ return 'totp';
+ }
+ return undefined;
+}
+
+export function needsPrepare(strategy: ReverificationStrategy): boolean {
+ return strategy === 'email_code' || strategy === 'phone_code';
+}
+
+function pickStartingFirstFactor(
+ methods: readonly ReverificationMethod[],
+ preferredSignInStrategy: PreferredSignInStrategy | undefined,
+ webAuthnSupported: boolean,
+): ReverificationMethod | null {
+ if (methods.length === 0) {
+ return null;
+ }
+
+ if (webAuthnSupported) {
+ const passkey = methods.find(method => method.strategy === 'passkey');
+ if (passkey) {
+ return passkey;
+ }
+ }
+
+ if (preferredSignInStrategy === 'password') {
+ return methods.find(method => method.strategy === 'password') ?? methods[0] ?? null;
+ }
+
+ return (
+ methods.find(method => method.strategy === 'email_code' || method.strategy === 'phone_code') ?? methods[0] ?? null
+ );
+}
+
+function pickStartingSecondFactor(methods: readonly ReverificationMethod[]): ReverificationMethod | null {
+ return (
+ methods.find(method => method.strategy === 'totp') ??
+ methods.find(method => method.strategy === 'phone_code') ??
+ methods[0] ??
+ null
+ );
+}
+
+export function pickStartingMethod(
+ methods: readonly ReverificationMethod[],
+ status: ReverificationResult['status'],
+ preferredSignInStrategy: PreferredSignInStrategy | undefined,
+ webAuthnSupported: boolean,
+): ReverificationMethod | null {
+ return status === 'needs_second_factor'
+ ? pickStartingSecondFactor(methods)
+ : pickStartingFirstFactor(methods, preferredSignInStrategy, webAuthnSupported);
+}
diff --git a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx
new file mode 100644
index 00000000000..e04869c6600
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx
@@ -0,0 +1,204 @@
+import { Card } from '../../components/card';
+import { Flow } from '../../components/flow';
+import type { IconName } from '../../icons/registry';
+import { ReverificationBackupCode } from './panels/reverification-backup-code';
+import { ReverificationHelp } from './panels/reverification-help';
+import { ReverificationMethodPicker } from './panels/reverification-method-picker';
+import { ReverificationOTP } from './panels/reverification-otp';
+import { ReverificationPasskey } from './panels/reverification-passkey';
+import { ReverificationPassword } from './panels/reverification-password';
+import { fill, reverificationBase as m } from './reverification.messages';
+import type { ReverificationMethod, ReverificationOtpChannel, ReverificationViewProps } from './reverification.types';
+
+const actions = {
+ secondaryActionLabel: m.footerActionLink__useAnotherMethod,
+ primaryActionLabel: m.formButtonPrimary,
+ pendingLabel: m.verifying,
+};
+
+const methodIcon = {
+ password: 'security-lock-square',
+ passkey: 'security-passkey',
+ email_code: 'code',
+ phone_code: 'security-phone',
+ totp: 'security-authenticator',
+ backup_code: 'security-phone',
+} as const satisfies Record;
+
+function methodLabel(method: ReverificationMethod): string {
+ const identifier = method.identifier ?? '';
+ switch (method.strategy) {
+ case 'password':
+ return m.alternativeMethods.blockButton__password;
+ case 'passkey':
+ return m.alternativeMethods.blockButton__passkey;
+ case 'email_code':
+ return fill(m.alternativeMethods.blockButton__emailCode, { identifier });
+ case 'phone_code':
+ return fill(m.alternativeMethods.blockButton__phoneCode, { identifier });
+ case 'totp':
+ return m.alternativeMethods.blockButton__totp;
+ case 'backup_code':
+ return m.alternativeMethods.blockButton__backupCode;
+ }
+}
+
+function otpCopy(channel: ReverificationOtpChannel | undefined) {
+ if (channel === 'email') {
+ return m.emailCode;
+ }
+ if (channel === 'phone') {
+ return m.phoneCode;
+ }
+ return m.totpMfa;
+}
+
+export function ReverificationView(props: ReverificationViewProps): JSX.Element {
+ const {
+ step,
+ direction,
+ value,
+ onValueChange,
+ errorMessage,
+ isPending,
+ onSubmit,
+ onVerifyPasskey,
+ onShowMethods,
+ onShowHelp,
+ onBack,
+ onEmailSupport,
+ methods,
+ onSelectMethod,
+ otpChannel,
+ onResend,
+ canResend,
+ } = props;
+
+ const otp = otpCopy(otpChannel);
+
+ return (
+
+
+ {() => (
+ <>
+
+
+
+
+
+
+
+
+
+ {
+ onValueChange(code);
+ onSubmit();
+ }}
+ onSubmit={onSubmit}
+ onCancel={onShowMethods}
+ />
+
+
+
+
+
+
+
+ ({
+ id: method.id,
+ label: methodLabel(method),
+ icon: methodIcon[method.strategy],
+ }))}
+ onSelect={onSelectMethod}
+ onHelp={onShowHelp}
+ onBack={onBack}
+ />
+
+
+
+ {})}
+ />
+
+ >
+ )}
+
+
+ );
+}
diff --git a/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts b/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts
new file mode 100644
index 00000000000..046b01b772b
--- /dev/null
+++ b/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts
@@ -0,0 +1,73 @@
+import { useReverification, useSession } from '@clerk/shared/react';
+import { useEffect, useRef, useState } from 'react';
+
+import type { ReverificationProps } from './reverification.types';
+
+type Fetcher = (...args: any[]) => Promise | undefined;
+
+type UseReverificationOptions = NonNullable[1]>;
+
+export type UseReverificationWithStateOptions = Omit;
+
+export type UseReverificationWithStateResult = readonly [
+ ReturnType>,
+ ReverificationProps,
+];
+
+/**
+ * Same fetcher wrap as `useReverification`, with the need-reverification callback
+ * returned as `ReverificationProps` instead of `onNeedsReverification`.
+ */
+export function useReverificationWithState(
+ fetcher: F,
+ options?: UseReverificationWithStateOptions,
+): UseReverificationWithStateResult {
+ const { session } = useSession();
+ const [props, setProps] = useState({ isActive: false });
+ const openedSessionId = useRef(null);
+
+ const wrapped = useReverification(fetcher, {
+ ...options,
+ onNeedsReverification: ({ complete, cancel, level }) => {
+ openedSessionId.current = session?.id ?? null;
+ setProps({
+ isActive: true,
+ level,
+ complete: () => {
+ setProps({ isActive: false });
+ complete();
+ },
+ cancel: () => {
+ setProps({ isActive: false });
+ cancel();
+ },
+ });
+ },
+ });
+
+ // Cancel if the session changes mid-flight
+ const { isActive, cancel } = props;
+ useEffect(() => {
+ if (!isActive) {
+ openedSessionId.current = null;
+ return;
+ }
+ // Do not reset on the transitive state
+ if (session === undefined) {
+ return;
+ }
+ if (session === null) {
+ cancel?.();
+ return;
+ }
+ if (openedSessionId.current === null) {
+ openedSessionId.current = session.id;
+ return;
+ }
+ if (session.id !== openedSessionId.current) {
+ cancel?.();
+ }
+ }, [isActive, cancel, session]);
+
+ return [wrapped, props];
+}