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 }) => {step}, +})); + +const active = { + isActive: true as const, + complete: vi.fn(), + cancel: vi.fn(), + level: 'first_factor' as const, +}; + +function ready(overrides: Partial = {}): ReverificationController { + return { + status: 'ready', + step: 'password', + value: '', + onValueChange: vi.fn(), + isPending: false, + onSubmit: vi.fn(), + onVerifyPasskey: vi.fn(), + onShowHelp: vi.fn(), + onEmailSupport: vi.fn(), + methods: [], + onSelectMethod: vi.fn(), + ...overrides, + }; +} + +describe('Reverification', () => { + it('renders nothing when reverification is not active', () => { + 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]; +}