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 a1f0176fea2..af7611f3a0c 100644 --- a/packages/swingset/src/stories/reverification.mdx +++ b/packages/swingset/src/stories/reverification.mdx @@ -17,12 +17,17 @@ The example pauses briefly in each pending state. Enter `error` in password or b ## Usage -The controller supplies one model containing the active view, transition direction, resolved messages, form state, and event handlers. Spread that model directly into the prop-driven `Reverification` view. +The feature wrapper reads Clerk state and drives `ReverificationView`. Isolated panel stories below are for visual development only. ```tsx -import { Reverification } from '@clerk/ui/mosaic/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 5c24fa85158..d5e42596144 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -1,15 +1,15 @@ -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 { useState } from 'react'; +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 { otpChannelFor } from '@clerk/ui/mosaic/features/reverification/reverification.utils'; +import { ReverificationView } from '@clerk/ui/mosaic/features/reverification/reverification.view'; +import { useRef, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -19,52 +19,11 @@ export const meta: StoryMeta = { group: 'Blocks', status: 'wip', title: 'Reverification', - source: 'packages/ui/src/mosaic/blocks/reverification/reverification.tsx', + source: 'packages/ui/src/mosaic/features/reverification/reverification.view.tsx', }; const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -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.', @@ -81,190 +40,251 @@ 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>; +const allMethods: ReverificationMethod[] = [ + { id: 'password', stage: 'first', strategy: 'password' }, + { id: 'passkey', stage: 'first', strategy: 'passkey' }, + { + id: 'email_code:idn_1', + stage: 'first', + strategy: 'email_code', + emailAddressId: 'idn_1', + identifier: 'a***@ex.com', + }, + { + id: 'phone_code:pn_1', + stage: 'first', + strategy: 'phone_code', + phoneNumberId: 'pn_1', + identifier: '+1••••1', + }, + { id: 'totp', stage: 'second', strategy: 'totp' }, + { id: 'backup_code', stage: 'second', strategy: 'backup_code' }, +]; + +function methodById(id: string): ReverificationMethod | undefined { + return allMethods.find(method => method.id === id); +} -type MethodId = keyof typeof methodPresentation; +function stepFor(id: string): ReverificationStep { + const strategy = methodById(id)?.strategy; + if (strategy === 'passkey') { + return 'passkey'; + } + if (strategy === 'backup_code') { + return 'backup-code'; + } + if (strategy === 'email_code' || strategy === 'phone_code' || strategy === '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 navigate = (nextStatus: ReverificationModel['status'], nextDirection: ReverificationModel['direction']) => { + const valueRef = useRef(value); + const [errorMessage, setErrorMessage] = useState(); + const [isPending, setIsPending] = useState(false); + const [canResend, setCanResend] = useState(true); + const [supportRequested, setSupportRequested] = useState(false); + const activeMethod = methodById(methodId); + + const navigate = (next: ReverificationStep, nextDirection: -1 | 1) => { setDirection(nextDirection); - setStatus(nextStatus); + setStep(next); + valueRef.current = ''; setValue(''); - setFieldError(undefined); + setErrorMessage(undefined); + setCanResend(true); }; const onValueChange = (nextValue: string) => { + valueRef.current = nextValue; 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.'); + const password = valueRef.current; + if (!password.trim()) { + setErrorMessage('This field is required.'); return; } - setPendingMethod('password'); + setIsPending(true); await settleAfter(700); - setPendingMethod(undefined); - if (value.toLowerCase() === 'error') { - setFieldError('That password is incorrect. Try again.'); + setIsPending(false); + if (password.toLowerCase() === 'error') { + 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.'); + const code = valueRef.current; + if (!code.trim()) { + setErrorMessage('This field is required.'); return; } - setPendingMethod('backup-code'); + setIsPending(true); await settleAfter(700); - setPendingMethod(undefined); - if (value.toLowerCase() === 'error') { - setFieldError('That backup code is incorrect. Try again.'); + setIsPending(false); + if (code.toLowerCase() === 'error') { + setErrorMessage('That backup code is incorrect. Try again.'); return; } - complete(); + onComplete(); }; const submitPasskey = async () => { - setPendingMethod('passkey'); + setIsPending(true); await settleAfter(700); - setPendingMethod(undefined); - complete(); + setIsPending(false); + onComplete(); }; const selectMethod = async (id: string) => { - const nextMethodId = id as MethodId; - setPendingMethodId(id); + setIsPending(true); await settleAfter(700); - setPendingMethodId(undefined); - setMethodId(nextMethodId); - navigate(nextMethodId, 1); + setIsPending(false); + setMethodId(id); + navigate(stepFor(id), 1); }; - const resend = async () => { - setIsResending(true); - await settleAfter(700); - setIsResending(false); - onValueChange(''); + const onResend = () => { + if (!canResend || isPending) { + return; + } + void (async () => { + setIsPending(true); + await settleAfter(700); + setIsPending(false); + setCanResend(false); + await settleAfter(3_000); + setCanResend(true); + })(); }; - 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(valueRef.current); + return; + } + if (step === 'passkey') { + void submitPasskey(); + return; + } + void submitPassword(); }; - return model; + return ( + <> + navigate('method-picker', 1)} + onResend={onResend} + canResend={canResend} + onShowHelp={() => navigate('help', 1)} + onBack={() => { + if (step === 'help') { + navigate('method-picker', -1); + return; + } + if (step === 'method-picker') { + navigate(stepFor(methodId), -1); + } + }} + onEmailSupport={() => setSupportRequested(true)} + methods={allMethods.filter(method => method.id !== methodId)} + onSelectMethod={id => void selectMethod(id)} + otpChannel={activeMethod ? otpChannelFor(activeMethod.strategy) : undefined} + /> + {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('')} @@ -273,29 +293,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 ; +} + +function PasskeyPanel({ + isPending = false, + errorMessage, +}: { + isPending?: boolean; + errorMessage?: string; +}): JSX.Element { return ( - undefined} + undefined} onCancel={() => undefined} /> @@ -304,55 +333,55 @@ export function PasswordError(): JSX.Element { } export function Passkey(): JSX.Element { - return ( - - undefined} - onCancel={() => undefined} - /> - - ); + 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('')} @@ -361,88 +390,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('')} @@ -451,34 +461,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..0265eb693c6 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.controller.test.tsx @@ -0,0 +1,715 @@ +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 { + type ReverificationDeps, + reverificationMachine, + useReverificationController, +} from '../reverification.controller'; +import type { ReverificationModel, ReverificationReadyModel } from '../reverification.model'; +import type { ReverificationMethod, ReverificationResult } from '../reverification.types'; + +const password: ReverificationMethod = { id: 'password', stage: 'first', strategy: 'password' }; +const email: ReverificationMethod = { + id: 'email_code:idn_1', + stage: 'first', + strategy: 'email_code', + identifier: 'a***@ex.com', + emailAddressId: 'idn_1', +}; +const totp: ReverificationMethod = { id: 'totp', stage: 'second', 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' })), + 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' })), + 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('methodPickerPreparing'); + await tick(); + expect(prepare).toHaveBeenCalledOnce(); + expect(actor.getSnapshot().value).toBe('verifying'); + expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('email_code'); + expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); + + 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 reset 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: 'RESET' }); + 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('cancels a successful attempt that settled after reset', async () => { + const attempt = deferred(); + const finish = vi.fn(async () => {}); + const cancel = vi.fn(); + const actor = startActor(seatedDeps({ attempt: () => attempt.promise, finish, cancel })); + await tick(); + actor.send({ type: 'TYPE', value: 'secret' }); + actor.send({ type: 'SUBMIT' }); + actor.send({ type: 'RESET' }); + + attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); + await tick(); + expect(actor.getSnapshot().value).toBe('done'); + expect(cancel).toHaveBeenCalledOnce(); + expect(finish).not.toHaveBeenCalled(); + }); + + it('finishes on success without replacing the active method', async () => { + const finish = deferred(); + const actor = startActor( + 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')); + }); + + it('returns to the current method with the error when finish fails', async () => { + const actor = startActor( + seatedDeps({ + attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), + finish: vi.fn(async () => Promise.reject(new Error('Session could not be activated.'))), + }), + ); + await tick(); + actor.send({ type: 'TYPE', value: 'secret' }); + actor.send({ type: 'SUBMIT' }); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifying')); + expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('password'); + expect(actor.getSnapshot().context.errorMessage).toBe('Session could not be activated.'); + expect(actor.getSnapshot().context.deps.cancel).not.toHaveBeenCalled(); + }); + + it('prepares the starting email method without leaving the factor', async () => { + const prepare = deferred(); + const actor = startActor( + seatedDeps({ + start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), + prepare: () => prepare.promise, + }), + ); + await tick(); + expect(actor.getSnapshot().value).toBe('preparing'); + expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); + + actor.send({ type: 'TYPE', value: '123456' }); + expect(actor.getSnapshot().context.inputValue).toBe('123456'); + + prepare.resolve(); + await tick(); + expect(actor.getSnapshot().value).toBe('verifying'); + expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); + }); + + it('queues an attempt submitted while the starting prepare is in flight', async () => { + const prepare = deferred(); + const finish = deferred(); + const attempt = vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); + const actor = startActor( + seatedDeps({ + start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), + prepare: () => prepare.promise, + attempt, + finish: () => finish.promise, + }), + ); + await tick(); + actor.send({ type: 'TYPE', value: '123456' }); + actor.send({ type: 'SUBMIT' }); + expect(actor.getSnapshot().value).toBe('preparing'); + expect(attempt).not.toHaveBeenCalled(); + + prepare.resolve(); + await tick(); + expect(attempt).toHaveBeenCalledOnce(); + expect(actor.getSnapshot().value).toBe('completing'); + }); + + it('keeps the factor interactive and queues submit while resend prepares', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000); + const resend = deferred(); + let prepareCount = 0; + const prepare = vi.fn(() => { + prepareCount += 1; + return prepareCount === 1 ? Promise.resolve() : resend.promise; + }); + const attempt = vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); + + try { + const actor = startActor( + seatedDeps({ + start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), + prepare, + attempt, + }), + ); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifying')); + + actor.send({ type: 'SHOW_HELP' }); + actor.send({ type: 'BACK' }); + now.mockReturnValue(31_000); + actor.send({ type: 'RESEND' }); + + expect(actor.getSnapshot().value).toBe('preparing'); + expect(actor.getSnapshot().context.resendAvailableAt).toBe(61_000); + + actor.send({ type: 'TYPE', value: '123456' }); + actor.send({ type: 'SUBMIT' }); + expect(actor.getSnapshot().context.inputValue).toBe('123456'); + expect(attempt).not.toHaveBeenCalled(); + + resend.resolve(); + await vi.waitFor(() => expect(attempt).toHaveBeenCalledWith(email, '123456')); + } finally { + now.mockRestore(); + } + }); +}); + +describe('useReverificationController', () => { + 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 cancel = vi.fn(); + const { result } = renderHook(() => + useReverificationController( + readyModel({ start: vi.fn(async () => Promise.reject(new Error('no session'))), cancel }), + ), + ); + await waitFor(() => expect(result.current.status).toBe('unavailable')); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('is unavailable when start returns no methods', async () => { + const cancel = vi.fn(); + const { result } = renderHook(() => + useReverificationController( + readyModel({ + start: vi.fn(async () => firstFactorResult({ methods: [], startingMethod: null })), + cancel, + }), + ), + ); + await waitFor(() => expect(result.current.status).toBe('unavailable')); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('still passes onShowMethods when only one method is available', async () => { + 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).toEqual(expect.any(Function)); + expect(result.current.methods).toEqual([]); + expect(result.current.step).toBe('password'); + }); + + it('keeps onResend during the resend cooldown', async () => { + const { result } = renderHook(() => + useReverificationController( + readyModel({ + start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), + }), + ), + ); + + await waitFor(() => expect(result.current.status).toBe('ready')); + if (result.current.status !== 'ready') { + throw new Error('expected ready'); + } + expect(result.current.canResend).toBe(false); + expect(result.current.onResend).toEqual(expect.any(Function)); + expect(result.current.resendRemainingSeconds).toBeGreaterThan(0); + expect(result.current.resendRemainingSeconds).toBeLessThanOrEqual(30); + }); + + it('marks the current step pending while an attempt is in flight', async () => { + 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(() => { + expect(result.current.status).toBe('ready'); + if (result.current.status === 'ready') { + expect(result.current.isPending).toBe(true); + } + }); + + act(() => { + attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); + }); + }); + + it('resets once while an attempt is in flight', async () => { + const attempt = deferred(); + const finish = vi.fn(() => Promise.resolve()); + const cancel = vi.fn(); + const activeModel = readyModel({ attempt: () => attempt.promise, finish, cancel }); + const { result, rerender } = renderHook( + ({ model }: { model: ReverificationModel }) => useReverificationController(model), + { initialProps: { model: activeModel } }, + ); + + await waitFor(() => expect(result.current.status).toBe('ready')); + act(() => { + if (result.current.status === 'ready') { + result.current.onValueChange('secret'); + result.current.onSubmit(); + } + }); + await waitFor(() => { + expect(result.current.status).toBe('ready'); + if (result.current.status === 'ready') { + expect(result.current.isPending).toBe(true); + } + }); + + rerender({ model: { ...activeModel, isActive: false } }); + expect(result.current.status).toBe('idle'); + + act(() => { + attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); + }); + await waitFor(() => expect(cancel).toHaveBeenCalledOnce()); + expect(finish).not.toHaveBeenCalled(); + }); + + it('stays on the current step pending while finish runs', async () => { + const finish = deferred(); + const { result } = renderHook(() => + 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('stays on the current step with the error when finish fails', async () => { + const { result } = renderHook(() => + useReverificationController( + readyModel({ + attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), + finish: vi.fn(async () => Promise.reject(new Error('Session could not be activated.'))), + }), + ), + ); + + await waitFor(() => expect(result.current.status).toBe('ready')); + act(() => { + if (result.current.status === 'ready') { + result.current.onValueChange('secret'); + result.current.onSubmit(); + } + }); + + await waitFor(() => { + expect(result.current.status).toBe('ready'); + if (result.current.status === 'ready') { + expect(result.current.step).toBe('password'); + expect(result.current.isPending).toBe(false); + expect(result.current.errorMessage).toBe('Session could not be activated.'); + } + }); + }); + + it('keeps the current step when the model flickers to loading', async () => { + const start = vi.fn(async () => firstFactorResult()); + const { result, rerender } = renderHook( + ({ 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); + }); + + it('shows the factor idle while the starting prepare is in flight', async () => { + const prepare = deferred(); + const { result } = renderHook(() => + useReverificationController( + readyModel({ + start: vi.fn(async () => firstFactorResult({ methods: [email, password], startingMethod: email })), + prepare: () => prepare.promise, + }), + ), + ); + + await waitFor(() => expect(result.current.status).toBe('ready')); + if (result.current.status !== 'ready') { + throw new Error('expected ready'); + } + expect(result.current.step).toBe('otp'); + expect(result.current.isPending).toBe(false); + expect(result.current.canResend).toBe(false); + expect(result.current.pendingMethodId).toBeUndefined(); + + const onValueChange = result.current.onValueChange; + act(() => { + onValueChange('123456'); + }); + expect(result.current.status).toBe('ready'); + if (result.current.status === 'ready') { + expect(result.current.value).toBe('123456'); + } + + act(() => { + prepare.resolve(); + }); + await waitFor(() => { + expect(result.current.status).toBe('ready'); + if (result.current.status === 'ready') { + expect(result.current.canResend).toBe(false); + expect(result.current.isPending).toBe(false); + } + }); + }); + + it('does not attempt until the starting prepare settles after submit', async () => { + const prepare = deferred(); + const attempt = vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); + const { result } = renderHook(() => + useReverificationController( + readyModel({ + start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), + prepare: () => prepare.promise, + attempt, + }), + ), + ); + + await waitFor(() => expect(result.current.status).toBe('ready')); + act(() => { + if (result.current.status === 'ready') { + result.current.onValueChange('123456'); + result.current.onSubmit(); + } + }); + expect(attempt).not.toHaveBeenCalled(); + if (result.current.status === 'ready') { + expect(result.current.isPending).toBe(false); + } + + act(() => { + prepare.resolve(); + }); + await waitFor(() => expect(attempt).toHaveBeenCalledOnce()); + }); + + it('keeps the picker on the sending row until prepare settles', async () => { + const prepare = deferred(); + const { result } = renderHook(() => + useReverificationController( + readyModel({ + prepare: () => prepare.promise, + }), + ), + ); + + await waitFor(() => expect(result.current.status).toBe('ready')); + act(() => { + if (result.current.status === 'ready') { + result.current.onShowMethods(); + } + }); + if (result.current.status !== 'ready') { + throw new Error('expected ready'); + } + expect(result.current.step).toBe('method-picker'); + + const onSelectMethod = result.current.onSelectMethod; + act(() => { + onSelectMethod(email.id); + }); + if (result.current.status !== 'ready') { + throw new Error('expected ready'); + } + expect(result.current.step).toBe('method-picker'); + expect(result.current.pendingMethodId).toBe(email.id); + expect(result.current.isPending).toBe(false); + expect(result.current.methods.some(method => method.id === email.id)).toBe(true); + expect(result.current.onBack).toEqual(expect.any(Function)); + + act(() => { + prepare.resolve(); + }); + await waitFor(() => { + expect(result.current.status).toBe('ready'); + if (result.current.status === 'ready') { + expect(result.current.step).toBe('otp'); + expect(result.current.pendingMethodId).toBeUndefined(); + expect(result.current.canResend).toBe(false); + } + }); + }); + + it('shows the factor with the error when a picker prepare fails', async () => { + const prepare = deferred(); + const { result } = renderHook(() => + useReverificationController( + readyModel({ + prepare: () => prepare.promise, + }), + ), + ); + + await waitFor(() => expect(result.current.status).toBe('ready')); + act(() => { + if (result.current.status === 'ready') { + result.current.onShowMethods(); + result.current.onSelectMethod(email.id); + } + }); + + act(() => { + prepare.reject(new Error('Could not send the code.')); + }); + await waitFor(() => { + expect(result.current.status).toBe('ready'); + if (result.current.status === 'ready') { + expect(result.current.step).toBe('otp'); + expect(result.current.errorMessage).toBe('Could not send the code.'); + expect(result.current.pendingMethodId).toBeUndefined(); + expect(result.current.canResend).toBe(true); + } + }); + }); +}); 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..7403bc26b16 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.model.test.tsx @@ -0,0 +1,322 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type * as SharedReact from '@clerk/shared/react'; +import type { PreferredSignInStrategy, SessionVerificationResource } from '@clerk/shared/types'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + type ReverificationModel, + type ReverificationReadyModel, + useReverificationModel, +} 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 | undefined; +let webAuthnSupported: boolean; +let setActive: ReturnType; + +function environment() { + return environmentHydrated ? { displayConfig: { preferredSignInStrategy } } : 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('../../../hooks/useMosaicSupportEmail', () => ({ + useMosaicSupportEmail: () => supportEmail, +})); + +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 loading until supportEmail is resolved', () => { + supportEmail = undefined; + const { result } = renderHook(() => useReverificationModel(activeProps())); + expect(result.current.status).toBe('loading'); + expect(result.current.isActive).toBe(true); + }); + + it('is active when props are active', () => { + const { result } = renderHook(() => useReverificationModel(activeProps())); + expect(result.current.isActive).toBe(true); + expect(ready(result.current).supportEmail).toBe('support@example.com'); + }); + + it('is inactive when props are idle', () => { + const { result } = renderHook(() => useReverificationModel({ isActive: false })); + expect(result.current.isActive).toBe(false); + }); + + it('defaults to second-factor verification when no level is provided', async () => { + session?.startVerification.mockResolvedValue(resource()); + const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: undefined })); + + await ready(result.current).start(); + + expect(session?.startVerification).toHaveBeenCalledWith({ level: 'second_factor' }); + }); + + it('maps first-factor strategies and drops enterprise_sso and passkey without WebAuthn', async () => { + webAuthnSupported = false; + session?.startVerification.mockResolvedValue( + 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', + stage: 'first', + 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).toEqual({ id: 'totp', stage: 'second', strategy: 'totp' }); + expect(started.methods.find(method => method.strategy === 'phone_code')).toEqual({ + id: 'phone_code:pn_1', + stage: 'second', + strategy: 'phone_code', + identifier: '+1••••1', + phoneNumberId: 'pn_1', + }); + }); + + it('prepares and attempts with the Clerk param shape for the active method', async () => { + 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', + stage: 'first', + strategy: 'email_code', + identifier: 'a***@ex.com', + emailAddressId: 'idn_1', + }); + expect(session?.prepareFirstFactorVerification).toHaveBeenCalledWith({ + strategy: 'email_code', + emailAddressId: 'idn_1', + }); + + await ready(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'secret'); + expect(session?.attemptFirstFactorVerification).toHaveBeenCalledWith({ + strategy: 'password', + password: 'secret', + }); + + await ready(result.current).prepare({ + id: 'phone_code:pn_1', + stage: 'first', + strategy: 'phone_code', + phoneNumberId: 'pn_1', + identifier: '+1••••1', + }); + expect(session?.prepareFirstFactorVerification).toHaveBeenCalledWith({ + strategy: 'phone_code', + phoneNumberId: 'pn_1', + }); + }); + + it('prepares second-factor phone codes and attempts totp against the second-factor API', async () => { + 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', + stage: 'second', + strategy: 'phone_code', + phoneNumberId: 'pn_1', + identifier: '+1••••1', + }); + expect(session?.prepareSecondFactorVerification).toHaveBeenCalledWith({ + strategy: 'phone_code', + phoneNumberId: 'pn_1', + }); + + await ready(result.current).attempt({ id: 'totp', stage: 'second', strategy: 'totp' }, '123456'); + expect(session?.attemptSecondFactorVerification).toHaveBeenCalledWith({ strategy: 'totp', code: '123456' }); + }); + + it('verifies a passkey', async () => { + session?.verifyWithPasskey.mockResolvedValue(resource({ status: 'complete' })); + const { result } = renderHook(() => useReverificationModel(activeProps())); + + await ready(result.current).attempt({ id: 'passkey', stage: 'first', strategy: 'passkey' }, ''); + expect(session?.verifyWithPasskey).toHaveBeenCalledOnce(); + }); + + it('rewrites Clerk API errors to plain Error messages', async () => { + session?.attemptFirstFactorVerification.mockRejectedValue( + new ClerkAPIResponseError('nope', { + data: [ + { + code: 'form_password_incorrect', + message: 'Incorrect password', + long_message: 'That password is incorrect.', + }, + ], + status: 422, + }), + ); + + const { result } = renderHook(() => useReverificationModel(activeProps())); + await expect( + ready(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'bad'), + ).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)); + const started = await ready(result.current).start(); + expect(started).toEqual({ status: 'complete', methods: [], startingMethod: null }); + await ready(result.current).finish(); + expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' }); + expect(props.complete).toHaveBeenCalledOnce(); + }); + + it('does not call complete when setActive fails', async () => { + setActive.mockRejectedValue(new Error('Session could not be activated.')); + const props = activeProps(); + const { result } = renderHook(() => useReverificationModel(props)); + + await expect(ready(result.current).finish()).rejects.toThrow('Session could not be activated.'); + expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' }); + expect(props.complete).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx new file mode 100644 index 00000000000..5611f71f638 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.test.tsx @@ -0,0 +1,83 @@ +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(), + 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(), + onShowMethods: vi.fn(), + onShowHelp: vi.fn(), + onBack: vi.fn(), + onEmailSupport: vi.fn(), + onResend: vi.fn(), + canResend: true, + 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..17f1b01bf8a --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx @@ -0,0 +1,99 @@ +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(), + onShowMethods: vi.fn(), + onShowHelp: vi.fn(), + onBack: vi.fn(), + onEmailSupport: vi.fn(), + onResend: vi.fn(), + canResend: true, + 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('keeps a disabled resend control during the cooldown', () => { + renderView({ + step: 'otp', + otpChannel: 'email', + canResend: false, + resendRemainingSeconds: 23, + }); + + expect(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (23)' })).toBeDisabled(); + }); + + 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..162709d54a7 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -0,0 +1,223 @@ +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 an overlapping challenge and preserves the active challenge', () => { + const { result } = renderHook(() => useReverificationWithState(fetcher)); + const firstComplete = vi.fn(); + const firstCancel = vi.fn(); + const secondComplete = vi.fn(); + const secondCancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete: firstComplete, cancel: firstCancel, level: 'first_factor' }); + capturedOnNeeds?.({ complete: secondComplete, cancel: secondCancel, level: 'multi_factor' }); + }); + + expect(secondCancel).toHaveBeenCalledOnce(); + expect(result.current[1]).toMatchObject({ isActive: true, level: 'first_factor' }); + + act(() => { + const [, state] = result.current; + if (state.isActive) { + state.complete(); + } + }); + + expect(firstComplete).toHaveBeenCalledOnce(); + expect(firstCancel).not.toHaveBeenCalled(); + expect(secondComplete).not.toHaveBeenCalled(); + }); + + it('allows another challenge after the active challenge settles', () => { + const { result } = renderHook(() => useReverificationWithState(fetcher)); + const firstComplete = vi.fn(); + const secondCancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete: firstComplete, cancel: vi.fn(), level: 'first_factor' }); + }); + act(() => { + const [, state] = result.current; + if (state.isActive) { + state.complete(); + } + }); + act(() => { + capturedOnNeeds?.({ complete: vi.fn(), cancel: secondCancel, level: 'multi_factor' }); + }); + + expect(firstComplete).toHaveBeenCalledOnce(); + expect(secondCancel).not.toHaveBeenCalled(); + expect(result.current[1]).toMatchObject({ isActive: true, level: 'multi_factor' }); + }); + + it('cancels when the session changes after reverification opens', async () => { + const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); + const cancel = vi.fn(); + + 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); + }); + + it('cancels when the owner unmounts mid-flow', () => { + const { unmount } = renderHook(() => useReverificationWithState(fetcher)); + const cancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + }); + + unmount(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('does not cancel again on unmount after the flow already settled', () => { + const { result, unmount } = renderHook(() => useReverificationWithState(fetcher)); + const cancel = vi.fn(); + + act(() => { + capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + }); + act(() => { + const [, state] = result.current; + if (state.isActive) { + state.cancel(); + } + }); + expect(cancel).toHaveBeenCalledOnce(); + + unmount(); + expect(cancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/mosaic/features/reverification/index.ts b/packages/ui/src/mosaic/features/reverification/index.ts new file mode 100644 index 00000000000..4ac85e89457 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/index.ts @@ -0,0 +1,15 @@ +export { Reverification } from './reverification'; +export type { + ReverificationMethod, + ReverificationOtpChannel, + ReverificationProps, + ReverificationStage, + 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 81% 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 da1138bcd59..150b0c14739 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,10 +1,10 @@ -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 { 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'; export interface ReverificationMethod { id: string; @@ -79,6 +79,7 @@ export function ReverificationMethodPicker({ type='button' variant='ghost' fullWidth + disabled={Boolean(pendingMethodId)} onClick={onBack} > {messages.backButton} @@ -96,7 +97,12 @@ export function ReverificationMethodPicker({ type='button' size='sm' variant='link' - onClick={onHelp} + disabled={Boolean(pendingMethodId)} + onClick={() => { + if (!pendingMethodId) { + onHelp(); + } + }} > {messages.helpButton} diff --git a/packages/ui/src/mosaic/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 86% 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..261cfe794d1 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; @@ -14,7 +14,7 @@ export interface ReverificationPasskeyProps { messages: ReverificationPasskeyMessages; errorMessage?: string; isPending?: boolean; - onVerify: () => void; + onSubmit: () => void; onCancel?: () => void; } @@ -22,7 +22,7 @@ export function ReverificationPasskey({ messages, errorMessage, isPending = false, - onVerify, + onSubmit, onCancel, }: ReverificationPasskeyProps) { return ( @@ -59,7 +59,7 @@ export function ReverificationPasskey({ fullWidth isPending={isPending} pendingLabel={messages.pendingLabel} - onClick={onVerify} + onClick={onSubmit} > {messages.primaryActionLabel} diff --git a/packages/ui/src/mosaic/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..98beda9cf65 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/reverification.controller.ts @@ -0,0 +1,488 @@ +import { useEffect, useState } from 'react'; + +import type { FlowDirection } from '../../components/flow'; +import { setup } from '../../machine/setup'; +import type { DoneInvokeEvent, StateConfig } from '../../machine/types'; +import { useMachine } from '../../machine/useMachine'; +import type { ReverificationModel, ReverificationReadyModel } from './reverification.model'; +import type { ReverificationMethod, ReverificationResult, ReverificationViewProps } from './reverification.types'; +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 = Pick; + +interface ReverificationContext { + inputValue: string; + errorMessage: string | undefined; + direction: FlowDirection; + activeMethod: ReverificationMethod | null; + methods: readonly ReverificationMethod[]; + resendAvailableAt: number | undefined; + abortRequested: boolean; + submitRequested: boolean; + frozenActiveMethodId: string | undefined; + overlayFrom: OverlayFrom; + supportEmail: string; + 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' }; + +const { createMachine, assign, fromPromise } = setup(); + +function notSeated(): Promise { + return Promise.reject(new Error('reverification deps are not seated')); +} + +const unseatedDeps: ReverificationDeps = { + start: notSeated, + prepare: notSeated, + attempt: notSeated, + finish: notSeated, + cancel: () => {}, +}; + +export const RESEND_COOLDOWN_MS = 30_000; + +function lockResend(): Pick { + return { resendAvailableAt: Date.now() + RESEND_COOLDOWN_MS }; +} + +function unlockResend(): Pick { + return { resendAvailableAt: undefined }; +} + +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, + submitRequested: false, + }; +} + +function prepareActive(ctx: ReverificationContext) { + const method = ctx.activeMethod; + return method && needsPrepare(method) ? ctx.deps.prepare(method) : Promise.resolve(); +} + +function submit(ctx: ReverificationContext): Promise { + const method = ctx.activeMethod; + if (!method) { + return Promise.reject(new Error('No active method')); + } + return ctx.deps.attempt(method, ctx.inputValue); +} + +const applyResult = assign>((_, event) => ({ + methods: event.output.methods, + activeMethod: event.output.startingMethod, + inputValue: '', + errorMessage: undefined, + ...unlockResend(), + direction: 1, +})); +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 method = event.output.startingMethod; + return Boolean(method && needsPrepare(method)); + }, + target: 'preparing' as const, + actions: [applyResult, assign(() => lockResend())], + }, + { target: 'verifying' as const, actions: applyResult }, +]; + +const abortAfterInvoke = { + target: 'done' as const, + guard: (ctx: ReverificationContext) => ctx.abortRequested, + actions: [ + (ctx: ReverificationContext) => { + ctx.deps.cancel(); + }, + assign(() => ({ abortRequested: false })), + ], +}; + +const factorEvents = { + TYPE: { + actions: assign((_, event) => ({ inputValue: event.value, errorMessage: undefined })), + }, + SHOW_METHODS: { + target: 'methodPicker', + guard: ctx => ctx.methods.filter(method => method.id !== ctx.activeMethod?.id).length > 0, + actions: assign(ctx => ({ + direction: 1 as const, + overlayFrom: 'factor' as const, + submitRequested: false, + frozenActiveMethodId: ctx.activeMethod?.id, + })), + }, + SHOW_HELP: { + target: 'help', + actions: assign(() => ({ + direction: 1 as const, + overlayFrom: 'factor' as const, + submitRequested: false, + })), + }, + RESET: 'inactive', +} satisfies NonNullable['on']>; + +export const reverificationMachine = createMachine({ + id: 'reverification', + initial: 'inactive', + context: { + inputValue: '', + errorMessage: undefined, + direction: 1, + activeMethod: null, + methods: [], + resendAvailableAt: undefined, + abortRequested: false, + submitRequested: false, + frozenActiveMethodId: undefined, + overlayFrom: 'factor', + supportEmail: '', + deps: unseatedDeps, + }, + states: { + inactive: { + entry: assign(() => ({ + inputValue: '', + errorMessage: undefined, + abortRequested: false, + submitRequested: false, + ...unlockResend(), + })), + on: { START: 'starting' }, + }, + + starting: { + on: { RESET: 'inactive' }, + invoke: fromPromise(ctx => ctx.deps.start(), { + onDone: afterResult, + onError: 'unavailable', + }), + }, + + /* + Preparing a factor behind the scenes (e.g. sending an OTP) + + This shows the factor card with optimistic UI, so it needs to handle the same actions + as verifying, but if the user submits, we queue that up until after prepare resolves + */ + preparing: { + on: { + ...factorEvents, + SUBMIT: { + actions: assign(() => ({ submitRequested: true })), + }, + }, + invoke: fromPromise(prepareActive, { + onDone: [ + { + guard: (ctx: ReverificationContext) => ctx.submitRequested, + target: 'submitting' as const, + actions: assign(() => ({ submitRequested: false })), + }, + { target: 'verifying' as const }, + ], + onError: { + target: 'verifying', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + submitRequested: false, + ...unlockResend(), + })), + }, + }), + }, + + /* + When selecting a method from the picker, we prepare before going to the factor card + + We don't use optimistic UI here simply because we're in a better position to show + some feedback that does not feel janky. The view handles disabling inputs while preparing. + */ + methodPickerPreparing: { + entry: assign(() => lockResend()), + on: { RESET: 'inactive' }, + invoke: fromPromise(prepareActive, { + onDone: 'verifying', + onError: { + target: 'verifying', + actions: assign((_, event) => ({ errorMessage: errorMessage(event.error), ...unlockResend() })), + }, + }), + }, + + verifying: { + always: [{ guard: ctx => ctx.activeMethod === null, target: 'unavailable' }], + on: { + ...factorEvents, + SUBMIT: 'submitting', + RESEND: { + target: 'preparing', + guard: ctx => + Boolean(ctx.activeMethod && needsPrepare(ctx.activeMethod)) && + (ctx.resendAvailableAt === undefined || Date.now() >= ctx.resendAvailableAt), + actions: assign(() => ({ + ...lockResend(), + inputValue: '', + errorMessage: undefined, + submitRequested: false, + })), + }, + }, + }, + + submitting: { + on: { + RESET: { + guard: ctx => !ctx.abortRequested, + actions: assign(() => ({ abortRequested: true })), + }, + }, + invoke: fromPromise(submit, { + onDone: [abortAfterInvoke, ...afterResult], + onError: [ + abortAfterInvoke, + { + target: 'verifying', + actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), + }, + ], + }), + }, + + methodPicker: { + on: { + SELECT_METHOD: [ + { + target: 'methodPickerPreparing', + guard: (ctx, event) => { + const method = ctx.methods.find(candidate => candidate.id === event.id); + return Boolean(method && needsPrepare(method)); + }, + 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 })) }, + 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 })) }, + ], + RESET: 'inactive', + }, + }, + + unavailable: { + entry: (ctx: ReverificationContext) => { + ctx.deps.cancel(); + }, + on: { RESET: 'inactive' }, + }, + + completing: { + on: { RESET: 'inactive' }, + invoke: fromPromise(ctx => ctx.deps.finish(), { + onDone: 'done', + onError: { + target: 'verifying', + actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), + }, + }), + }, + + done: { + on: { RESET: 'inactive' }, + }, + }, +}); + +const pendingStates = new Set(['submitting', 'completing']); + +function viewStep(value: string, method: ReverificationMethod | null): ReverificationViewProps['step'] | undefined { + if (value === 'methodPicker' || value === 'methodPickerPreparing') { + return 'method-picker'; + } + if (value === 'help') { + return 'help'; + } + if (value === 'verifying' || value === 'submitting' || value === 'preparing' || 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: ready, + }, + } + : undefined, + ); + + const [now, setNow] = useState(() => Date.now()); + const resendAvailableAt = snapshot.context.resendAvailableAt; + const canResend = resendAvailableAt === undefined || now >= resendAvailableAt; + const countingDown = !canResend; + + useEffect(() => { + if (!countingDown) { + return; + } + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [countingDown, resendAvailableAt]); + + const needsStart = model.isActive && Boolean(ready) && snapshot.value === 'inactive'; + const needsReset = !model.isActive && snapshot.value !== 'inactive'; + useEffect(() => { + if (needsStart) { + send({ type: 'START' }); + } else if (needsReset) { + send({ type: 'RESET' }); + } + }, [needsStart, needsReset, send]); + + 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; + // If we are currently on the alternative methods screen and preparing a factor, activeMethod will + // have transitioned to the factor we are now preparing, so the one we want to hide is the old one + const excludeId = snapshot.value === 'methodPickerPreparing' ? context.frozenActiveMethodId : activeMethod?.id; + const methods = context.methods.filter(method => method.id !== excludeId); + const pendingMethodId = snapshot.value === 'methodPickerPreparing' ? activeMethod?.id : undefined; + + return { + status: 'ready', + 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' }), + onShowMethods: () => send({ type: 'SHOW_METHODS' }), + onShowHelp: () => send({ type: 'SHOW_HELP' }), + onBack: () => send({ type: 'BACK' }), + onEmailSupport: () => { + if (context.supportEmail) { + window.location.assign(`mailto:${context.supportEmail}`); + } + }, + methods, + pendingMethodId, + onSelectMethod: id => send({ type: 'SELECT_METHOD', id }), + otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, + onResend: () => send({ type: 'RESEND' }), + canResend, + // We clamp this to 30s because the first render after locking resend + // will have the old `now` state set, which would result in a value above + // 30s. There are other solutions like reading Date.now() in render and + // letting the interval only retrigger render, but that makes the render + // impure which is something the React compiler would warn about. + // useSyncExternalStore feels unnecessarily complex here + resendRemainingSeconds: + countingDown && resendAvailableAt + ? Math.min(RESEND_COOLDOWN_MS / 1000, Math.max(0, Math.ceil((resendAvailableAt - now) / 1000))) + : undefined, + }; +} 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..76b76a73bd0 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/reverification.model.tsx @@ -0,0 +1,216 @@ +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 { useMosaicSupportEmail } from '../../hooks/useMosaicSupportEmail'; +import type { + ReverificationMethod, + ReverificationPreparableMethod, + ReverificationProps, + ReverificationResult, + ReverificationStage, +} from './reverification.types'; +import { pickStartingMethod } from './reverification.utils'; + +export type ReverificationReadyModel = { + status: 'ready'; + isActive: boolean; + supportEmail: string; + start: () => Promise; + prepare: (method: ReverificationPreparableMethod) => Promise; + attempt: (method: ReverificationMethod, value: string) => 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, + stage: ReverificationStage, + webAuthnSupported: boolean, +): ReverificationMethod | null { + if (factor.strategy === 'passkey') { + if (stage !== 'first' || !webAuthnSupported) { + return null; + } + return { id: 'passkey', stage: 'first', strategy: 'passkey' }; + } + + if (factor.strategy === 'password') { + if (stage !== 'first') { + return null; + } + return { id: 'password', stage: 'first', strategy: 'password' }; + } + + if (factor.strategy === 'email_code') { + if (stage !== 'first') { + return null; + } + return { + id: `email_code:${factor.emailAddressId}`, + stage: 'first', + strategy: 'email_code', + identifier: factor.safeIdentifier, + emailAddressId: factor.emailAddressId, + }; + } + + if (factor.strategy === 'phone_code') { + return { + id: `phone_code:${factor.phoneNumberId}`, + stage, + strategy: 'phone_code', + identifier: factor.safeIdentifier, + phoneNumberId: factor.phoneNumberId, + }; + } + + if (factor.strategy === 'totp' || factor.strategy === 'backup_code') { + if (stage !== 'second') { + return null; + } + return { id: factor.strategy, stage: 'second', strategy: factor.strategy }; + } + + return null; +} + +function toResult( + resource: SessionVerificationResource, + preferredSignInStrategy: PreferredSignInStrategy | undefined, + webAuthnSupported: boolean, +): ReverificationResult { + if (resource.status === 'complete') { + return { status: 'complete', methods: [], startingMethod: null }; + } + + const stage: ReverificationStage = resource.status === 'needs_second_factor' ? 'second' : 'first'; + const raw = stage === 'second' ? resource.supportedSecondFactors : resource.supportedFirstFactors; + const methods = (raw ?? []) + .map(factor => toMethod(factor, stage, webAuthnSupported)) + .filter((method): method is ReverificationMethod => method !== null); + + return { + status: resource.status, + methods, + startingMethod: pickStartingMethod(methods, preferredSignInStrategy, webAuthnSupported), + }; +} + +export function useReverificationModel(props: ReverificationProps): ReverificationModel { + const { session } = useSession(); + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + const supportEmail = useMosaicSupportEmail(); + const { isActive, cancel, complete, level } = props; + + if (!session || !environment || supportEmail === undefined) { + 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, + start: async () => { + try { + return handleResponse(await session.startVerification({ level: level ?? 'second_factor' })); + } catch (error) { + throw toError(error); + } + }, + cancel: () => { + cancel?.(); + }, + prepare: async method => { + try { + switch (method.strategy) { + case 'email_code': + await session.prepareFirstFactorVerification({ + strategy: 'email_code', + emailAddressId: method.emailAddressId, + }); + return; + case 'phone_code': + if (method.stage === 'second') { + await session.prepareSecondFactorVerification({ + strategy: 'phone_code', + phoneNumberId: method.phoneNumberId, + }); + return; + } + await session.prepareFirstFactorVerification({ + strategy: 'phone_code', + phoneNumberId: method.phoneNumberId, + }); + return; + } + } catch (error) { + throw toError(error); + } + }, + attempt: async (method, value) => { + try { + switch (method.strategy) { + case 'password': + return handleResponse( + await session.attemptFirstFactorVerification({ strategy: 'password', password: value }), + ); + case 'email_code': + return handleResponse( + await session.attemptFirstFactorVerification({ strategy: 'email_code', code: value }), + ); + case 'phone_code': + if (method.stage === 'second') { + return handleResponse( + await session.attemptSecondFactorVerification({ strategy: 'phone_code', code: value }), + ); + } + return handleResponse( + await session.attemptFirstFactorVerification({ strategy: 'phone_code', code: value }), + ); + case 'totp': + case 'backup_code': + return handleResponse( + await session.attemptSecondFactorVerification({ strategy: method.strategy, code: value }), + ); + case 'passkey': + return handleResponse(await session.verifyWithPasskey()); + } + } catch (error) { + throw toError(error); + } + }, + finish: async () => { + try { + await clerk.setActive({ session: session.id }); + 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..5b277c9df62 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/reverification.tsx @@ -0,0 +1,21 @@ +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 controller = useReverificationController(model); + + if (!props.isActive) { + return null; + } + + if (controller.status !== 'ready') { + // TODO: Implement unavailable and loading states, could also live in the .view. + return null; + } + + const { status: _status, ...viewProps } = controller; + 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..c31890a4fbe --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/reverification.types.ts @@ -0,0 +1,50 @@ +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 ReverificationStage = 'first' | 'second'; + +export type ReverificationOtpChannel = 'email' | 'phone' | 'totp'; + +export type ReverificationStep = 'password' | 'passkey' | 'otp' | 'backup-code' | 'method-picker' | 'help'; + +export type ReverificationMethod = + | { id: string; stage: 'first'; strategy: 'password' | 'passkey' } + | { id: string; stage: 'first'; strategy: 'email_code'; emailAddressId: string; identifier: string } + | { id: string; stage: 'first' | 'second'; strategy: 'phone_code'; phoneNumberId: string; identifier: string } + | { id: string; stage: 'second'; strategy: 'totp' | 'backup_code' }; + +export type ReverificationPreparableMethod = Extract; + +export type ReverificationViewProps = { + step: ReverificationStep; + direction?: FlowDirection; + value: string; + onValueChange: (value: string) => void; + errorMessage?: string; + isPending: boolean; + onSubmit: () => void; + onShowMethods: () => void; + onShowHelp: () => void; + onBack: () => void; + onEmailSupport: () => void; + methods: readonly ReverificationMethod[]; + pendingMethodId?: string; + onSelectMethod: (id: string) => void; + otpChannel?: ReverificationOtpChannel; + onResend: () => void; + canResend: boolean; + resendRemainingSeconds?: number; +}; + +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..42f07b27c3e --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/reverification.utils.ts @@ -0,0 +1,69 @@ +import type { PreferredSignInStrategy } from '@clerk/shared/types'; + +import type { + ReverificationMethod, + ReverificationOtpChannel, + ReverificationPreparableMethod, + 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(method: ReverificationMethod): method is ReverificationPreparableMethod { + return method.strategy === 'email_code' || method.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[], + preferredSignInStrategy: PreferredSignInStrategy | undefined, + webAuthnSupported: boolean, +): ReverificationMethod | null { + return methods[0]?.stage === 'second' + ? pickStartingSecondFactor(methods) + : pickStartingFirstFactor(methods, preferredSignInStrategy, webAuthnSupported); +} diff --git a/packages/ui/src/mosaic/features/reverification/reverification.view.tsx b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx new file mode 100644 index 00000000000..36b18cb3eae --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/reverification.view.tsx @@ -0,0 +1,208 @@ +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 = 'identifier' in method ? 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, + onShowMethods, + onShowHelp, + onBack, + onEmailSupport, + methods, + pendingMethodId, + onSelectMethod, + otpChannel, + onResend, + canResend, + resendRemainingSeconds, + } = props; + + const otp = otpCopy(otpChannel); + const hasAlternatives = methods.length > 0; + const resendLabel = otpChannel === 'phone' ? m.phoneCode.resendButton : m.emailCode.resendButton; + const resend = + otpChannel === 'email' || otpChannel === 'phone' + ? { + label: resendRemainingSeconds ? `${resendLabel} (${resendRemainingSeconds})` : resendLabel, + disabled: !canResend || isPending, + onClick: onResend, + } + : undefined; + + return ( + + + {() => ( + <> + + + + + + + + + + { + onValueChange(code); + onSubmit(); + }} + onSubmit={onSubmit} + onCancel={hasAlternatives ? onShowMethods : undefined} + /> + + + + + + + + ({ + id: method.id, + label: methodLabel(method), + icon: methodIcon[method.strategy], + }))} + pendingMethodId={pendingMethodId} + 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..927cd262e46 --- /dev/null +++ b/packages/ui/src/mosaic/features/reverification/use-reverification-with-state.ts @@ -0,0 +1,95 @@ +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 [reverificationState, setReverificationState] = useState({ isActive: false }); + const openedSessionId = useRef(null); + const activeCancelRef = useRef<(() => void) | undefined>(undefined); + + const wrapped = useReverification(fetcher, { + ...options, + onNeedsReverification: ({ complete, cancel, level }) => { + // If another Reverification is already in progress for this specific + // wrapped action, cancel this new one immediately and keep the old one. + // This is an extra safeguard for something that likely never happens. + if (activeCancelRef.current) { + cancel(); + return; + } + + openedSessionId.current = session?.id ?? null; + activeCancelRef.current = cancel; + + const settle = (callback: () => void) => { + if (activeCancelRef.current !== cancel) { + return; + } + activeCancelRef.current = undefined; + setReverificationState({ isActive: false }); + callback(); + }; + + setReverificationState({ + isActive: true, + level, + complete: () => settle(complete), + cancel: () => settle(cancel), + }); + }, + }); + + // Cancel if the session changes mid-flight + const { isActive, cancel } = reverificationState; + 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]); + + useEffect(() => { + return () => { + const cancel = activeCancelRef.current; + activeCancelRef.current = undefined; + cancel?.(); + }; + }, []); + + return [wrapped, reverificationState]; +} diff --git a/packages/ui/src/mosaic/hooks/__tests__/useMosaicSupportEmail.test.ts b/packages/ui/src/mosaic/hooks/__tests__/useMosaicSupportEmail.test.ts new file mode 100644 index 00000000000..af9054bc6d7 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/__tests__/useMosaicSupportEmail.test.ts @@ -0,0 +1,86 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useMosaicSupportEmail } from '../useMosaicSupportEmail'; + +let supportEmailFromOptions: string | undefined; +let frontendApi: string; +let environmentHydrated: boolean; +let supportEmailFromEnvironment: string; + +function environment() { + return environmentHydrated ? { displayConfig: { supportEmail: supportEmailFromEnvironment } } : undefined; +} + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useClerk: () => ({ + frontendApi, + __internal_getOption: (key: string) => (key === 'supportEmail' ? supportEmailFromOptions : undefined), + }), + }; +}); + +vi.mock('../useMosaicEnvironment', () => ({ + useMosaicEnvironment: () => environment(), +})); + +describe('useMosaicSupportEmail', () => { + beforeEach(() => { + supportEmailFromOptions = undefined; + frontendApi = ''; + environmentHydrated = true; + supportEmailFromEnvironment = ''; + }); + + it('uses the ClerkProvider option over the environment', () => { + supportEmailFromOptions = 'option@email.com'; + supportEmailFromEnvironment = 'env@email.com'; + + const { result } = renderHook(() => useMosaicSupportEmail()); + + expect(result.current).toBe('option@email.com'); + }); + + it('returns the option before the environment hydrates', () => { + supportEmailFromOptions = 'option@email.com'; + environmentHydrated = false; + + const { result } = renderHook(() => useMosaicSupportEmail()); + + expect(result.current).toBe('option@email.com'); + }); + + it('uses the environment when no option is set', () => { + supportEmailFromEnvironment = 'env@email.com'; + + const { result } = renderHook(() => useMosaicSupportEmail()); + + expect(result.current).toBe('env@email.com'); + }); + + it('falls back to the Frontend API host when neither source is set', () => { + const { result } = renderHook(() => useMosaicSupportEmail()); + + expect(result.current).toBe('support@clerk.com'); + }); + + it('strips the clerk. prefix from the Frontend API host', () => { + frontendApi = 'clerk.example.com'; + + const { result } = renderHook(() => useMosaicSupportEmail()); + + expect(result.current).toBe('support@example.com'); + }); + + it('is undefined until the environment hydrates when no option is set', () => { + environmentHydrated = false; + + const { result } = renderHook(() => useMosaicSupportEmail()); + + expect(result.current).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/mosaic/hooks/useMosaicSupportEmail.ts b/packages/ui/src/mosaic/hooks/useMosaicSupportEmail.ts new file mode 100644 index 00000000000..7c1b32567f0 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useMosaicSupportEmail.ts @@ -0,0 +1,39 @@ +import { buildEmailAddress } from '@clerk/shared/internal/clerk-js/email'; +import { useClerk } from '@clerk/shared/react'; + +import { useMosaicEnvironment } from './useMosaicEnvironment'; + +/** + * Resolves the support email Mosaic flows show in "contact us" copy. + * + * Priority matches clerk-js's `useSupportEmail`: the ClerkProvider `supportEmail` + * option, then `displayConfig.supportEmail`, then `support@` + the Frontend API + * host. Mosaic has no `OptionsContext`, so the option is read through + * `clerk.__internal_getOption` — quarantined here the same way + * `__internal_environment` lives only in `useMosaicEnvironment`. + * + * This is a one-shot read off the Clerk singleton, NOT a reactive subscription: + * `useClerk()` does not re-render when options or the environment mutate. Only + * use this for the hydration-time address; a later option/env change will not + * update the value. + * + * Returns `undefined` until the value is stable. The option can resolve before + * the environment hydrates; otherwise callers must wait so the Frontend API + * fallback is not locked in ahead of a dashboard-configured address. + */ +export function useMosaicSupportEmail(): string | undefined { + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + const emailFromOptions = clerk.__internal_getOption('supportEmail'); + + if (emailFromOptions) { + return emailFromOptions; + } + if (!environment) { + return undefined; + } + return ( + environment.displayConfig.supportEmail || + buildEmailAddress({ localPart: 'support', frontendApi: clerk.frontendApi }) + ); +}