From cc1a4a83599614e53fed0cab29441406a51f6669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Mon, 21 Sep 2026 13:29:37 +0200 Subject: [PATCH 01/40] Create UserProfileDeleteSection wrapper --- .claude/skills/mosaic/references/views.md | 4 +- .../user-profile-delete-section.view.test.tsx | 67 +++++++++++-------- .../user-profile-delete-section.controller.ts | 2 +- .../user-profile-delete-section.tsx | 11 +++ .../user-profile-delete-section.view.tsx | 22 +++--- .../user-profile-profile-panel.view.tsx | 5 +- .../user-profile-security-panel.view.tsx | 15 +++-- .../user-profile-delete-section.stories.tsx | 12 +++- references/mosaic-architecture.md | 6 +- 9 files changed, 88 insertions(+), 56 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx diff --git a/.claude/skills/mosaic/references/views.md b/.claude/skills/mosaic/references/views.md index aedf67aef06..84253fd8355 100644 --- a/.claude/skills/mosaic/references/views.md +++ b/.claude/skills/mosaic/references/views.md @@ -39,8 +39,8 @@ Two shapes, chosen by whether the slice fetches its own data: - A **wrapper composes** model + controller + view, and the view is a pure function of props (`user-button.tsx`). - A **leaf view owns its controller** and takes the effect as a prop - (`UserProfileDeleteSectionView` calls `useUserProfileDeleteSectionController` - with its `onDelete`). Still no Clerk — the effect arrives from above. + (`UserProfileUsernameRowView` calls `useUserProfileEditUsernameController` + with its `onSubmit`). Still no Clerk — the effect arrives from above. ## Where the strings live diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx index 9f70ce5dea6..f8e7727686d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx @@ -3,38 +3,54 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; +import type { UserProfileDeleteSectionViewProps } from '../user-profile-delete-section/user-profile-delete-section.view'; import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view'; -function renderView(onDelete: () => Promise = vi.fn(() => Promise.resolve())) { - return render( +function viewProps(overrides: Partial = {}): UserProfileDeleteSectionViewProps { + return { + isOpen: false, + onOpenChange: vi.fn(), + onConfirm: vi.fn(), + isDeleting: false, + errorMessage: undefined, + ...overrides, + }; +} + +function view(props: UserProfileDeleteSectionViewProps) { + return ( - - , + + ); } -const openDialog = async (user: ReturnType) => { - await user.click(screen.getByRole('button', { name: 'Delete account' })); - return screen.getByRole('dialog'); -}; +function renderView(overrides: Partial = {}) { + const props = viewProps(overrides); + return { ...render(view(props)), props }; +} describe('UserProfileDeleteSectionView', () => { - it('renders the danger zone with the dialog closed', () => { - renderView(); + it('renders the danger zone and asks to open the dialog', async () => { + const user = userEvent.setup(); + const { props } = renderView(); expect(screen.getByRole('heading', { name: 'Danger zone' })).toBeInTheDocument(); expect( screen.getByText('Permanently delete this account and all its data. This cannot be undone.'), ).toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Delete account' })); + expect(props.onOpenChange).toHaveBeenCalledWith(true, expect.anything()); }); - it('deletes only after the phrase is typed back, then closes', async () => { - const onDelete = vi.fn(() => Promise.resolve()); + it('confirms only after the phrase is typed back', async () => { + const onConfirm = vi.fn(); const user = userEvent.setup(); - renderView(onDelete); + renderView({ isOpen: true, onConfirm }); - const dialog = await openDialog(user); + const dialog = screen.getByRole('dialog'); const confirm = within(dialog).getByRole('button', { name: 'Delete account' }); expect(within(dialog).getByText('Type “Delete account” below to continue')).toBeInTheDocument(); @@ -43,32 +59,29 @@ describe('UserProfileDeleteSectionView', () => { await user.type(within(dialog).getByRole('textbox'), 'Delete account'); await user.click(confirm); - expect(onDelete).toHaveBeenCalledOnce(); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(onConfirm).toHaveBeenCalledOnce(); }); - it('keeps the dialog up and explains a failed delete', async () => { - const user = userEvent.setup(); - renderView(() => Promise.reject(new Error('Your subscription is still active.'))); - - const dialog = await openDialog(user); - await user.type(within(dialog).getByRole('textbox'), 'Delete account'); - await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); + it('renders a delete failure while keeping the dialog open', () => { + renderView({ isOpen: true, errorMessage: 'Your subscription is still active.' }); - expect(await screen.findByText('Your subscription is still active.')).toBeInTheDocument(); + expect(screen.getByText('Your subscription is still active.')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument(); }); it('clears the phrase when the dialog is cancelled', async () => { const user = userEvent.setup(); - renderView(); + const props = viewProps({ isOpen: true }); + const { rerender } = render(view(props)); - const dialog = await openDialog(user); + const dialog = screen.getByRole('dialog'); await user.type(within(dialog).getByRole('textbox'), 'Delete account'); await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + rerender(view({ ...props, isOpen: false })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - await openDialog(user); + rerender(view({ ...props, isOpen: true })); expect(screen.getByRole('textbox')).toHaveValue(''); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts index 1b96da8ca90..0b9bcd3d0ad 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts @@ -2,7 +2,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; export interface UserProfileDeleteSectionContext { - /** Deletes the account. Injected by the view from its `onDelete` prop. */ + /** Deletes the account. Injected by the wrapper from its `onDelete` prop. */ deleteAccount: () => Promise; /** Why the last attempt failed. */ errorMessage: string | undefined; diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx new file mode 100644 index 00000000000..befe806aad3 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx @@ -0,0 +1,11 @@ +import type { UserProfileDeleteSectionControllerOptions } from './user-profile-delete-section.controller'; +import { useUserProfileDeleteSectionController } from './user-profile-delete-section.controller'; +import { UserProfileDeleteSectionView } from './user-profile-delete-section.view'; + +export type UserProfileDeleteSectionProps = UserProfileDeleteSectionControllerOptions; + +export function UserProfileDeleteSection(props: UserProfileDeleteSectionProps) { + const controller = useUserProfileDeleteSectionController(props); + + return ; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx index 0922220d9a8..65dbb2c3b04 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx @@ -2,21 +2,23 @@ import { Destructive } from '../../../blocks/destructive'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { fill, useMessages } from '../../../localization'; -import { useUserProfileDeleteSectionController } from './user-profile-delete-section.controller'; export interface UserProfileDeleteSectionViewProps { - /** - * Deletes the account. Resolve and the confirmation dialog closes; reject with an `Error` - * and it stays open with that message under the confirmation field. - */ - onDelete: () => Promise; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isDeleting: boolean; + errorMessage: string | undefined; } -export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) { +export function UserProfileDeleteSectionView({ + isOpen, + onOpenChange, + onConfirm, + isDeleting, + errorMessage, +}: UserProfileDeleteSectionViewProps) { const m = useMessages('userProfileDeleteSection'); - const { isOpen, onOpenChange, onConfirm, isDeleting, errorMessage } = useUserProfileDeleteSectionController({ - onDelete, - }); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index 05f8ccede3a..3b8219f84af 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -14,7 +14,7 @@ import type { UserProfileConnectionProvider, } from './user-profile-connected-accounts-section.view'; import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view'; -import { UserProfileDeleteSectionView } from './user-profile-delete-section/user-profile-delete-section.view'; +import { UserProfileDeleteSection } from './user-profile-delete-section/user-profile-delete-section'; import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view'; @@ -139,7 +139,8 @@ export function UserProfileProfilePanelView({ onRemove={onRemoveWeb3Wallet} onSetPrimary={onSetPrimaryWeb3Wallet} /> - {onDeleteAccount ? : null} + {/* TODO: This is temporary, a view should not render a connected component */} + {onDeleteAccount ? : null} ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 95dafc3cdfa..b53fbce6a6b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -8,7 +8,7 @@ import type { UserProfileDevice, } from './user-profile-active-devices-section.view'; import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view'; -import { UserProfileDeleteSectionView } from './user-profile-delete-section/user-profile-delete-section.view'; +import { UserProfileDeleteSection } from './user-profile-delete-section/user-profile-delete-section'; import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view'; import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; @@ -31,11 +31,11 @@ export type { export interface UserProfileSecurityPanelViewProps extends - Omit, - Pick< - UserProfilePasswordSectionViewProps, - 'hasPassword' | 'requiresCurrentPassword' | 'managedBy' | 'onSubmitPassword' - > { + Omit, + Pick< + UserProfilePasswordSectionViewProps, + 'hasPassword' | 'requiresCurrentPassword' | 'managedBy' | 'onSubmitPassword' + > { passkeys?: UserProfilePasskey[]; passkeysVisible?: boolean; mfaMethods?: UserProfileMfaMethod[]; @@ -124,7 +124,8 @@ export function UserProfileSecurityPanelView({ onSignOutDevice={onSignOutDevice} /> ) : null} - {onDeleteAccount ? : null} + {/* TODO: This is temporary, a view should not render a connected component */} + {onDeleteAccount ? : null} ); diff --git a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx index 9be6612ca04..9c9209e5996 100644 --- a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx @@ -1,3 +1,4 @@ +import { useUserProfileDeleteSectionController } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller'; import { UserProfileDeleteSectionView } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.view'; import { useState } from 'react'; @@ -11,13 +12,18 @@ export const meta: StoryMeta = { title: 'UserProfileDeleteSection', label: 'Danger zone', navigation: { category: 'Sections' }, - source: 'packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx', + source: 'packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx', }; // A real delete is a network round trip. Without one the button never renders its pending // state, so both stories wait before they settle. const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +function DeleteSectionHarness({ onDelete }: { onDelete: () => Promise }) { + const controller = useUserProfileDeleteSectionController({ onDelete }); + return ; +} + export function Default() { const [runId, setRunId] = useState(0); @@ -29,7 +35,7 @@ export function Default() { }; return ( - @@ -38,7 +44,7 @@ export function Default() { export function WithError() { return ( - { await settleAfter(2000); throw new Error('Your subscription is still active. Cancel it before you delete your account.'); diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index 8e9f82f6742..a422f078f5d 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -269,10 +269,8 @@ export function UserButton(props: UserButtonProps = {}) { prop. There is no model: the Clerk call arrives from whoever renders it. ```tsx -export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) { - const { isOpen, onOpenChange, onConfirm, isDeleting, errorMessage } = useUserProfileDeleteSectionController({ - onDelete, - }); +export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { + const controller = useUserProfileEditUsernameController({ username, onSubmit }); // …render… } ``` From dd04458ab74418a8652e9e5ab09e83c7460fc05b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 22 Sep 2026 14:36:22 +0200 Subject: [PATCH 02/40] Refactor isActive -> phase in useReverificationWithState and introduce "retrying" The retrying state will give consumers a way to decide whether to let Reverification stay pending while the action retries after verification is done, or whether to take over that pending state themselves. --- .../use-reverification-with-state.test.tsx | 414 ++++++++++++++---- .../use-reverification-with-state.ts | 173 ++++++-- 2 files changed, 451 insertions(+), 136 deletions(-) diff --git a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx index 162709d54a7..641381fdfad 100644 --- a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -1,8 +1,11 @@ +import { ClerkRuntimeError, isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; 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 { deferred, tick } from '../../../machines/__tests__/test-utils'; +import type { ReverificationProps } from '../reverification.types'; import { useReverificationWithState } from '../use-reverification-with-state'; type NeedsReverificationParameters = { @@ -11,9 +14,16 @@ type NeedsReverificationParameters = { level: SessionVerificationLevel | undefined; }; -let capturedOnNeeds: ((params: NeedsReverificationParameters) => void) | undefined; +type Hint = { reverificationLevel: SessionVerificationLevel | undefined }; + +const IN_PROGRESS = 'request_already_in_progress'; + let session: { id: string } | null | undefined = { id: 'sess_1' }; -const wrapped = vi.fn(); +let challengeCancel: ReturnType; + +function isHint(value: unknown): value is Hint { + return Boolean(value && typeof value === 'object' && 'reverificationLevel' in value); +} vi.mock('@clerk/shared/react', async importOriginal => { const actual = await importOriginal(); @@ -21,203 +31,413 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useSession: () => ({ session }), useReverification: ( - _fetcher: unknown, + fetcher: (...args: unknown[]) => Promise | undefined, options?: { onNeedsReverification?: (params: NeedsReverificationParameters) => void }, ) => { - capturedOnNeeds = options?.onNeedsReverification; - return wrapped; + return async (...args: unknown[]) => { + const result = await fetcher(...args); + if (!isHint(result)) { + return result; + } + await new Promise((resolve, reject) => { + options?.onNeedsReverification?.({ + level: result.reverificationLevel, + complete: () => resolve(), + cancel: () => { + challengeCancel(); + reject( + new ClerkRuntimeError('User cancelled attempted verification', { + code: 'reverification_cancelled', + }), + ); + }, + }); + }); + return fetcher(...args); + }; }, }; }); -const fetcher = async (id: string) => id; +function assertActive(state: ReverificationProps): asserts state is Extract { + expect(state.phase).toBe('active'); + if (state.phase !== 'active') { + throw new Error('expected active reverification'); + } +} describe('useReverificationWithState', () => { beforeEach(() => { - capturedOnNeeds = undefined; session = { id: 'sess_1' }; - wrapped.mockReset(); + challengeCancel = vi.fn(); }); - it('returns the enhanced fetcher and is idle until reverification is needed', () => { + it('returns the enhanced fetcher and stays inactive until reverification is needed', async () => { + const fetcher = vi.fn((id: string) => Promise.resolve(id)); 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(); + expect(typeof result.current[0]).toBe('function'); + expect(result.current[1]).toEqual({ phase: 'inactive' }); + + let value: string | undefined; + await act(async () => { + value = await result.current[0]('user'); + }); + + expect(value).toBe('user'); + expect(fetcher).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledWith('user'); + expect(result.current[1]).toEqual({ phase: 'inactive' }); }); - it('surfaces complete, cancel, and level when useReverification needs reverification', () => { + it('moves inactive → active → retrying → inactive when the retry succeeds', async () => { + const retry = deferred<{ ok: true }>(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockImplementationOnce(() => retry.promise); const { result } = renderHook(() => useReverificationWithState(fetcher)); - const complete = vi.fn(); - const cancel = vi.fn(); + let pending!: Promise; act(() => { - capturedOnNeeds?.({ complete, cancel, level: 'first_factor' }); + pending = result.current[0](); }); - - 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'); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + assertActive(result.current[1]); + expect(result.current[1].level).toBe('first_factor'); + expect(fetcher).toHaveBeenCalledOnce(); act(() => { - state.complete(); + assertActive(result.current[1]); + result.current[1].complete(); }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'retrying' })); + expect(fetcher).toHaveBeenCalledTimes(2); - expect(complete).toHaveBeenCalledOnce(); - expect(result.current[1].isActive).toBe(false); + retry.resolve({ ok: true }); + await act(async () => { + await expect(pending).resolves.toEqual({ ok: true }); + }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'inactive' })); + expect(challengeCancel).not.toHaveBeenCalled(); }); - it('returns to idle after cancel and calls the state cancel', () => { + it('returns to inactive when the delayed retry fails', async () => { + const retry = deferred(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'multi_factor' } satisfies Hint) + .mockImplementationOnce(() => retry.promise); const { result } = renderHook(() => useReverificationWithState(fetcher)); - const complete = vi.fn(); - const cancel = vi.fn(); + let pending!: Promise; act(() => { - capturedOnNeeds?.({ complete, cancel, level: undefined }); + pending = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); act(() => { - const [, state] = result.current; - if (state.isActive) { - state.cancel(); - } + assertActive(result.current[1]); + result.current[1].complete(); }); + await waitFor(() => expect(result.current[1].phase).toBe('retrying')); - expect(cancel).toHaveBeenCalledOnce(); - expect(complete).not.toHaveBeenCalled(); - expect(result.current[1].isActive).toBe(false); + retry.reject(new Error('Mock delete failed.')); + await act(async () => { + await expect(pending).rejects.toThrow('Mock delete failed.'); + }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'inactive' })); + expect(challengeCancel).not.toHaveBeenCalled(); }); - it('cancels an overlapping challenge and preserves the active challenge', () => { + it('returns directly to inactive when the active challenge is cancelled', async () => { + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: undefined } satisfies Hint); const { result } = renderHook(() => useReverificationWithState(fetcher)); - const firstComplete = vi.fn(); - const firstCancel = vi.fn(); - const secondComplete = vi.fn(); - const secondCancel = vi.fn(); + let pending!: Promise; act(() => { - capturedOnNeeds?.({ complete: firstComplete, cancel: firstCancel, level: 'first_factor' }); - capturedOnNeeds?.({ complete: secondComplete, cancel: secondCancel, level: 'multi_factor' }); + pending = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); - expect(secondCancel).toHaveBeenCalledOnce(); - expect(result.current[1]).toMatchObject({ isActive: true, level: 'first_factor' }); + act(() => { + assertActive(result.current[1]); + result.current[1].cancel(); + }); + + await act(async () => { + await expect(pending).rejects.toMatchObject({ code: 'reverification_cancelled' }); + }); + expect(isReverificationCancelledError(await pending.catch(error => error))).toBe(true); + expect(result.current[1]).toEqual({ phase: 'inactive' }); + expect(fetcher).toHaveBeenCalledOnce(); + expect(challengeCancel).toHaveBeenCalledOnce(); + }); + it('rejects a second call during the initial request without calling the fetcher again', async () => { + const gate = deferred(); + const fetcher = vi.fn(() => gate.promise); + const { result } = renderHook(() => useReverificationWithState(fetcher)); + + let first!: Promise; act(() => { - const [, state] = result.current; - if (state.isActive) { - state.complete(); - } + first = result.current[0]('first'); }); + await waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); - expect(firstComplete).toHaveBeenCalledOnce(); - expect(firstCancel).not.toHaveBeenCalled(); - expect(secondComplete).not.toHaveBeenCalled(); + await expect(result.current[0]('second')).rejects.toMatchObject({ code: IN_PROGRESS }); + expect(fetcher).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledWith('first'); + + gate.resolve('done'); + await act(async () => { + await expect(first).resolves.toBe('done'); + }); }); - it('allows another challenge after the active challenge settles', () => { + it('rejects a second call during verification and retry without calling the fetcher', async () => { + const retry = deferred(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockImplementationOnce(() => retry.promise); const { result } = renderHook(() => useReverificationWithState(fetcher)); - const firstComplete = vi.fn(); - const secondCancel = vi.fn(); + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: firstComplete, cancel: vi.fn(), level: 'first_factor' }); + first = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + expect(fetcher).toHaveBeenCalledOnce(); + + const duringVerification = await result.current[0]().then( + () => { + throw new Error('expected rejection'); + }, + error => error, + ); + expect(isReverificationCancelledError(duringVerification)).toBe(false); + expect(isClerkRuntimeError(duringVerification) && duringVerification.code).toBe(IN_PROGRESS); + expect(fetcher).toHaveBeenCalledOnce(); + act(() => { - const [, state] = result.current; - if (state.isActive) { - state.complete(); - } + assertActive(result.current[1]); + result.current[1].complete(); }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'retrying' })); + expect(fetcher).toHaveBeenCalledTimes(2); + + await expect(result.current[0]()).rejects.toMatchObject({ code: IN_PROGRESS }); + expect(fetcher).toHaveBeenCalledTimes(2); + + retry.resolve('ok'); + await act(async () => { + await expect(first).resolves.toBe('ok'); + }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'inactive' })); + }); + + it('starts another challenge after the first invocation settles', async () => { + const retry = deferred(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockImplementationOnce(() => retry.promise) + .mockResolvedValueOnce({ reverificationLevel: 'multi_factor' } satisfies Hint); + const { result } = renderHook(() => useReverificationWithState(fetcher)); + + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: vi.fn(), cancel: secondCancel, level: 'multi_factor' }); + first = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].complete(); + }); + await waitFor(() => expect(result.current[1].phase).toBe('retrying')); + + retry.resolve('ok'); + await act(async () => { + await expect(first).resolves.toBe('ok'); + }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'inactive' })); - expect(firstComplete).toHaveBeenCalledOnce(); - expect(secondCancel).not.toHaveBeenCalled(); - expect(result.current[1]).toMatchObject({ isActive: true, level: 'multi_factor' }); + act(() => { + first = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + expect(result.current[1]).toMatchObject({ phase: 'active', level: 'multi_factor' }); + act(() => { + assertActive(result.current[1]); + result.current[1].cancel(); + }); + await act(async () => { + await first.catch(() => undefined); + }); }); - it('cancels when the session changes after reverification opens', async () => { + it('cancels when the session changes while the challenge is active', async () => { + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); - const cancel = vi.fn(); + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + first = result.current[0](); }); - expect(result.current[1].isActive).toBe(true); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + const settled = expect(first).rejects.toMatchObject({ code: 'reverification_cancelled' }); session = { id: 'sess_2' }; rerender(); - await waitFor(() => expect(cancel).toHaveBeenCalledOnce()); - expect(result.current[1].isActive).toBe(false); + await settled; + expect(result.current[1]).toEqual({ phase: 'inactive' }); + expect(challengeCancel).toHaveBeenCalledOnce(); }); it('does not cancel when the session is briefly unloaded', async () => { - const { rerender } = renderHook(() => useReverificationWithState(fetcher)); - const cancel = vi.fn(); + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); + const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + first = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); const previous = session; session = undefined; rerender(); session = previous; rerender(); - expect(cancel).not.toHaveBeenCalled(); + expect(challengeCancel).not.toHaveBeenCalled(); + expect(result.current[1].phase).toBe('active'); + + act(() => { + assertActive(result.current[1]); + result.current[1].cancel(); + }); + await act(async () => { + await first.catch(() => undefined); + }); }); - it('cancels when the session is signed out', async () => { + it('cancels when the session is signed out during the challenge', async () => { + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); - const cancel = vi.fn(); + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + first = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + + const settled = expect(first).rejects.toMatchObject({ code: 'reverification_cancelled' }); + session = null; + rerender(); + await settled; + expect(result.current[1]).toEqual({ phase: 'inactive' }); + }); + + it('does not cancel a retry when the session changes', async () => { + const retry = deferred(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockImplementationOnce(() => retry.promise); + const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); + + let first!: Promise; + act(() => { + first = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].complete(); + }); + await waitFor(() => expect(result.current[1].phase).toBe('retrying')); + + session = { id: 'sess_2' }; + rerender(); + await tick(); + expect(challengeCancel).not.toHaveBeenCalled(); + expect(result.current[1]).toEqual({ phase: 'retrying' }); session = null; rerender(); - await waitFor(() => expect(cancel).toHaveBeenCalledOnce()); - expect(result.current[1].isActive).toBe(false); + await tick(); + expect(challengeCancel).not.toHaveBeenCalled(); + expect(result.current[1]).toEqual({ phase: 'retrying' }); + + retry.resolve('ok'); + await act(async () => { + await expect(first).resolves.toBe('ok'); + }); }); - it('cancels when the owner unmounts mid-flow', () => { - const { unmount } = renderHook(() => useReverificationWithState(fetcher)); - const cancel = vi.fn(); + it('cancels when the owner unmounts during the challenge', async () => { + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); + const { result, unmount } = renderHook(() => useReverificationWithState(fetcher)); + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + first = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + const settled = expect(first).rejects.toMatchObject({ code: 'reverification_cancelled' }); unmount(); - expect(cancel).toHaveBeenCalledOnce(); + await settled; + expect(challengeCancel).toHaveBeenCalledOnce(); }); - it('does not cancel again on unmount after the flow already settled', () => { + it('does not cancel when the owner unmounts during retry', async () => { + const retry = deferred(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockImplementationOnce(() => retry.promise); const { result, unmount } = renderHook(() => useReverificationWithState(fetcher)); - const cancel = vi.fn(); + let first!: Promise; act(() => { - capturedOnNeeds?.({ complete: vi.fn(), cancel, level: 'first_factor' }); + first = result.current[0](); }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); act(() => { - const [, state] = result.current; - if (state.isActive) { - state.cancel(); - } + assertActive(result.current[1]); + result.current[1].complete(); + }); + await waitFor(() => expect(result.current[1].phase).toBe('retrying')); + + unmount(); + await tick(); + expect(challengeCancel).not.toHaveBeenCalled(); + + retry.resolve('ok'); + await expect(first).resolves.toBe('ok'); + }); + + it('does not cancel again on unmount after the flow already settled', async () => { + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); + const { result, unmount } = renderHook(() => useReverificationWithState(fetcher)); + + let first!: Promise; + act(() => { + first = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].cancel(); + }); + await act(async () => { + await first.catch(() => undefined); }); - expect(cancel).toHaveBeenCalledOnce(); + expect(challengeCancel).toHaveBeenCalledOnce(); unmount(); - expect(cancel).toHaveBeenCalledOnce(); + expect(challengeCancel).toHaveBeenCalledOnce(); }); }); diff --git a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts index 927cd262e46..aef1356d39c 100644 --- a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts +++ b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts @@ -1,5 +1,6 @@ -import { useReverification, useSession } from '@clerk/shared/react'; -import { useEffect, useRef, useState } from 'react'; +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { useReverification, useSafeLayoutEffect, useSession } from '@clerk/shared/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { ReverificationProps } from './reverification.types'; @@ -14,56 +15,145 @@ export type UseReverificationWithStateResult = read ReverificationProps, ]; +type RuntimeOperation = + | { status: 'idle' } + | { status: 'requesting'; promise: Promise } + | { + status: 'active'; + promise: Promise; + sessionId: string | null; + complete: () => void; + cancel: () => void; + } + | { status: 'retrying'; promise: Promise } + | { status: 'cancelling'; promise: Promise }; + +type Runtime = { + operation: RuntimeOperation; + sessionId: string | null; +}; + +const REQUEST_ALREADY_IN_PROGRESS_CODE = 'request_already_in_progress'; + +function requestAlreadyInProgressError(): ClerkRuntimeError { + return new ClerkRuntimeError('A request is already in progress.', { + code: REQUEST_ALREADY_IN_PROGRESS_CODE, + }); +} + /** - * Same fetcher wrap as `useReverification`, with the need-reverification callback - * returned as `ReverificationProps` instead of `onNeedsReverification`. + * This wraps useReverification, but does not pop the default UI and instead manages + * the lifecycle, use the returned `phase` and callbacks to build your custom UI. + * + * This is meant to be paired with the Mosaic component. + * + * In contrast to useReverification, the returned handler is only allowed to run + * in serial. Any new calls that happen while it's still pending will throw + * request_already_in_progress. If you always guard against double-invocation, + * you wont see this error. */ +// The reason we need to enforce single-flight is that we need a direct link between +// a single invocation of the handler and a specific reverification. useReverification +// does not have well-defined behavior for concurrent calls, so we add the single-flight +// constraint for extra safeguards here. +// If we ever want to make this hook public API, we might want to reconsider the single-flight +// behavior by first fixing the useReverification hook. 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); + // The return is observable and needs to be driven by React state + const [reverificationState, setReverificationState] = useState({ phase: 'inactive' }); + // State updates are not immediate and since parallel requests can resolve before observing + // those state changes, the internal state is driven by a ref + const runtimeRef = useRef({ + operation: { status: 'idle' }, + sessionId: session?.id ?? null, + }); + useSafeLayoutEffect(() => { + runtimeRef.current.sessionId = session?.id ?? null; + }); + + const completeChallenge = useCallback(() => { + const operation = runtimeRef.current.operation; + if (operation.status !== 'active') { + return; + } + runtimeRef.current.operation = { status: 'retrying', promise: operation.promise }; + setReverificationState({ phase: 'retrying' }); + operation.complete(); + }, []); + + const cancelChallenge = useCallback(() => { + const operation = runtimeRef.current.operation; + if (operation.status !== 'active') { + return; + } + runtimeRef.current.operation = { status: 'cancelling', promise: operation.promise }; + setReverificationState({ phase: 'inactive' }); + operation.cancel(); + }, []); 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(); + const operation = runtimeRef.current.operation; + if (operation.status !== 'requesting') { 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(); + runtimeRef.current.operation = { + status: 'active', + promise: operation.promise, + sessionId: runtimeRef.current.sessionId, + complete, + cancel, }; setReverificationState({ - isActive: true, + phase: 'active', level, - complete: () => settle(complete), - cancel: () => settle(cancel), + complete: completeChallenge, + cancel: cancelChallenge, }); }, }); - // Cancel if the session changes mid-flight - const { isActive, cancel } = reverificationState; + const singleFlight = useCallback( + (...args: Parameters) => { + // Only a single handler call is allowed to be in progress at the same time + if (runtimeRef.current.operation.status !== 'idle') { + return Promise.reject(requestAlreadyInProgressError()); + } + + const invocation = Promise.resolve().then(() => wrapped(...args)); + runtimeRef.current.operation = { status: 'requesting', promise: invocation }; + void invocation + .finally(() => { + const operation = runtimeRef.current.operation; + if (operation.status === 'idle' || operation.promise !== invocation) { + return; + } + runtimeRef.current.operation = { status: 'idle' }; + setReverificationState({ phase: 'inactive' }); + }) + // The original error is meant to be handled outside, but .finally() creates + // a new promise that errors the same way, so we swallow that duplicate error silently + .catch(() => undefined); + + return invocation; + }, + [wrapped], + ) as ReturnType>; + + const phase = reverificationState.phase; useEffect(() => { - if (!isActive) { - openedSessionId.current = null; + if (phase !== 'active') { + return; + } + const operation = runtimeRef.current.operation; + if (operation.status !== 'active') { return; } // Do not reset on the transitive state @@ -71,25 +161,30 @@ export function useReverificationWithState( return; } if (session === null) { - cancel?.(); + cancelChallenge(); return; } - if (openedSessionId.current === null) { - openedSessionId.current = session.id; + // If operation started before sessionId was known, we record it here + if (operation.sessionId === null) { + runtimeRef.current.operation = { ...operation, sessionId: session.id }; return; } - if (session.id !== openedSessionId.current) { - cancel?.(); + if (session.id !== operation.sessionId) { + cancelChallenge(); } - }, [isActive, cancel, session]); + }, [phase, session, cancelChallenge]); + // Cancel on unmount - Does not cancel ongoing retry after reverification has finished useEffect(() => { + const runtime = runtimeRef.current; return () => { - const cancel = activeCancelRef.current; - activeCancelRef.current = undefined; - cancel?.(); + const operation = runtime.operation; + runtime.operation = { status: 'idle' }; + if (operation.status === 'active') { + operation.cancel(); + } }; }, []); - return [wrapped, reverificationState]; + return [singleFlight, reverificationState]; } From 84fc5d5b6fcfd4aa2d429b0959b438d7fd921a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 22 Sep 2026 17:41:42 +0200 Subject: [PATCH 03/40] Add retrying state to useReverificationWithState This let's consumers decide whether they want to keep the reverification pending state up while the action is being retried, or render their own pending state. This also makes useReverificationWithState single-flight, repeat calls to the handler will throw. --- .../reverification.composition.test.tsx | 259 ++++++++++++++ .../reverification.controller.test.tsx | 76 +++- .../__tests__/reverification.model.test.tsx | 23 +- .../__tests__/reverification.test.tsx | 55 ++- .../__tests__/reverification.view.test.tsx | 8 + .../reverification.controller.ts | 78 ++-- .../reverification/reverification.messages.ts | 7 + .../reverification/reverification.model.tsx | 13 +- .../reverification/reverification.styles.ts | 23 ++ .../reverification/reverification.tsx | 33 +- .../reverification/reverification.types.ts | 13 +- .../reverification/reverification.view.tsx | 333 ++++++++++-------- .../swingset/src/stories/reverification.mdx | 4 +- 13 files changed, 703 insertions(+), 222 deletions(-) create mode 100644 packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx create mode 100644 packages/mosaic/src/features/reverification/reverification.styles.ts diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx new file mode 100644 index 00000000000..f2c14a9c485 --- /dev/null +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx @@ -0,0 +1,259 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Card } from '../../../components/card'; +import { Dialog } from '../../../components/dialog'; +import { Flow } from '../../../components/flow'; +import { MosaicProvider } from '../../../MosaicProvider'; +import { Reverification } from '../reverification'; +import type { ReverificationController } from '../reverification.controller'; +import type { ReverificationViewProps } from '../reverification.types'; + +let controller: ReverificationController = { status: 'idle' }; + +vi.mock('../reverification.model', () => ({ + useReverificationModel: () => ({ + status: 'ready', + phase: 'active', + supportEmail: 'support@example.com', + start: vi.fn(), + prepare: vi.fn(), + attempt: vi.fn(), + finish: vi.fn(), + cancel: vi.fn(), + }), +})); + +vi.mock('../reverification.controller', () => ({ + useReverificationController: () => controller, +})); + +function surface(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, + }; +} + +function Nested({ step }: { step: 'confirm' | 'verify' | 'finalizing' }) { + return ( + + + + + + {() => ( + <> + + + Delete account? + Confirm the mock delete. + + + + + + + + Finalizing + Completing the mock delete. + + + + )} + + + + + + ); +} + +describe('embedded reverification inside an outer flow', () => { + it('keeps one dialog and one card, and nests a flow only while verifying', () => { + controller = { status: 'idle' }; + const { rerender } = render(); + + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); + expect(document.querySelectorAll('.cl-flow-root')).toHaveLength(1); + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); + expect(screen.getByText('Confirm the mock delete.')).toBeInTheDocument(); + + controller = { status: 'loading' }; + rerender(); + + const flows = document.querySelectorAll('.cl-flow-root'); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); + expect(flows).toHaveLength(1); + expect(flows[0]).toHaveAttribute('data-value', 'verify'); + expect(screen.queryByText('Confirm the mock delete.')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + expect(document.querySelector('.cl-spinner')).not.toBeNull(); + + const outerCard = document.querySelector('.cl-card-root'); + const outerFlow = flows[0]; + controller = surface(); + rerender(); + + const nextFlows = document.querySelectorAll('.cl-flow-root'); + expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); + expect(document.querySelector('.cl-card-root')).toBe(outerCard); + expect(nextFlows).toHaveLength(2); + expect(nextFlows[0]).toBe(outerFlow); + expect(nextFlows[1]).toHaveAttribute('data-value', 'password'); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.queryByText('Confirm the mock delete.')).not.toBeInTheDocument(); + + controller = { status: 'idle' }; + rerender(); + + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'finalizing'); + expect(screen.getByRole('heading', { name: 'Finalizing' })).toBeInTheDocument(); + expect(screen.queryByText('Confirm the mock delete.')).not.toBeInTheDocument(); + }); + + it('keeps the current step when the challenge goes inactive', () => { + controller = surface(); + const { rerender } = render(); + + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + + controller = { status: 'idle' }; + rerender(); + + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); + expect(screen.queryByText('Confirm the mock delete.')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + }); + + it('does not mount an inner flow before verification starts', () => { + controller = surface(); + const { rerender } = render(); + + expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); + expect(document.querySelectorAll('.cl-flow-root')).toHaveLength(1); + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + + rerender(); + expect(document.querySelectorAll('.cl-flow-root')).toHaveLength(1); + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); + }); +}); + +const active = { + phase: 'active' as const, + complete: vi.fn(), + cancel: vi.fn(), + level: 'first_factor' as const, +}; + +describe('reverification card states', () => { + it('keeps one card from the pending state through a factor', () => { + controller = { status: 'loading' }; + const { container, rerender } = render( + + + , + ); + const card = container.querySelector('.cl-card-root'); + const spinner = container.querySelector('.cl-spinner'); + + expect(card).not.toBeNull(); + expect(container.querySelector('.cl-flow-root')).toBeNull(); + expect(screen.getByRole('heading', { name: 'Verification required' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + expect(container.querySelector('[aria-busy="true"]')).toContainElement(spinner); + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + + controller = surface(); + rerender( + + + , + ); + + expect(container.querySelector('.cl-card-root')).toBe(card); + expect(container.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'password'); + expect(container.querySelector('.cl-spinner')).toBeNull(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + }); + + it('shows a dismiss button on the pending card inside a dialog', () => { + controller = { status: 'loading' }; + render( + + + + + + + , + ); + + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toHaveAccessibleName('Verification required'); + expect(document.querySelector('.cl-card-root')).toBeNull(); + }); + + it('renders unavailable outside the factor flow, then mounts that flow in the same card', () => { + controller = { status: 'unavailable' }; + const { container, rerender } = render( + + + , + ); + const card = container.querySelector('.cl-card-root'); + + expect(card).not.toBeNull(); + expect(container.querySelector('.cl-flow-root')).toBeNull(); + expect(screen.getByRole('heading', { name: 'Cannot verify your account' })).toBeInTheDocument(); + expect( + screen.getByText('Cannot proceed with verification. No suitable authentication factor is configured.'), + ).toBeInTheDocument(); + + controller = surface(); + rerender( + + + , + ); + + expect(container.querySelector('.cl-card-root')).toBe(card); + expect(container.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'password'); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + }); +}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx index 0265eb693c6..8593e43f790 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx @@ -50,7 +50,7 @@ function startActor(deps: ReverificationDeps = seatedDeps()) { function readyModel(overrides: Partial = {}): ReverificationReadyModel { return { status: 'ready', - isActive: true, + phase: 'active', supportEmail: 'support@example.com', start: vi.fn(async () => firstFactorResult()), prepare: vi.fn(async () => {}), @@ -290,14 +290,14 @@ describe('reverificationMachine', () => { describe('useReverificationController', () => { it('is idle when reverification is not active', () => { - const { result } = renderHook(() => useReverificationController(readyModel({ isActive: false }))); - expect(result.current.status).toBe('idle'); + const { result } = renderHook(() => useReverificationController(readyModel({ phase: 'inactive' }))); + expect(result.current).toEqual({ status: 'idle' }); }); it('is loading while the model is still waiting on Clerk', () => { const loading: ReverificationModel = { status: 'loading', - isActive: true, + phase: 'active', }; const { result } = renderHook(() => useReverificationController(loading)); expect(result.current.status).toBe('loading'); @@ -413,7 +413,7 @@ describe('useReverificationController', () => { } }); - rerender({ model: { ...activeModel, isActive: false } }); + rerender({ model: { ...activeModel, phase: 'inactive' } }); expect(result.current.status).toBe('idle'); act(() => { @@ -494,7 +494,7 @@ describe('useReverificationController', () => { await waitFor(() => expect(result.current.status).toBe('ready')); expect(start).toHaveBeenCalledOnce(); - rerender({ model: { status: 'loading', isActive: true } }); + rerender({ model: { status: 'loading', phase: 'active' } }); expect(result.current.status).toBe('ready'); if (result.current.status === 'ready') { expect(result.current.step).toBe('password'); @@ -532,7 +532,7 @@ describe('useReverificationController', () => { } }); - rerender({ model: { status: 'loading', isActive: true } }); + rerender({ model: { status: 'loading', phase: 'active' } }); expect(result.current.status).toBe('ready'); if (result.current.status === 'ready') { expect(result.current.step).toBe('password'); @@ -554,10 +554,10 @@ describe('useReverificationController', () => { ); await waitFor(() => expect(result.current.status).toBe('ready')); - rerender({ model: readyModel({ start, isActive: false }) }); - expect(result.current.status).toBe('idle'); + rerender({ model: readyModel({ start, phase: 'inactive' }) }); + expect(result.current).toEqual({ status: 'idle' }); - rerender({ model: readyModel({ start, isActive: true }) }); + rerender({ model: readyModel({ start, phase: 'active' }) }); await waitFor(() => expect(result.current.status).toBe('ready')); expect(start).toHaveBeenCalledTimes(2); }); @@ -712,4 +712,60 @@ describe('useReverificationController', () => { } }); }); + + it('keeps the last factor pending during retrying and resets only when inactive', async () => { + const finish = deferred(); + const start = vi.fn(() => Promise.resolve(firstFactorResult())); + const cancel = vi.fn(); + const active = readyModel({ + start, + cancel, + attempt: vi.fn(() => + Promise.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), + ), + finish: () => finish.promise, + }); + const { result, rerender } = renderHook( + ({ model }: { model: ReverificationModel }) => useReverificationController(model), + { initialProps: { model: active } }, + ); + + 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); + } + }); + + rerender({ model: { ...active, phase: 'retrying' } }); + expect(result.current.status).toBe('ready'); + if (result.current.status === 'ready') { + expect(result.current.step).toBe('password'); + expect(result.current.isPending).toBe(true); + } + expect(start).toHaveBeenCalledOnce(); + + act(() => { + finish.resolve(); + }); + 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); + } + }); + expect(cancel).not.toHaveBeenCalled(); + + rerender({ model: { ...active, phase: 'inactive' } }); + expect(result.current).toEqual({ status: 'idle' }); + }); }); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx index 7403bc26b16..ec7bdab059f 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx @@ -74,7 +74,7 @@ function resource(overrides: Partial = {}): Session function activeProps() { return { - isActive: true as const, + phase: 'active' as const, complete: vi.fn(), cancel: vi.fn(), level: 'first_factor' as const, @@ -103,25 +103,25 @@ describe('useReverificationModel', () => { session = null; const { result } = renderHook(() => useReverificationModel(activeProps())); expect(result.current.status).toBe('loading'); - expect(result.current.isActive).toBe(true); + expect(result.current.phase).toBe('active'); }); 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); + expect(result.current.phase).toBe('active'); }); it('is active when props are active', () => { const { result } = renderHook(() => useReverificationModel(activeProps())); - expect(result.current.isActive).toBe(true); + expect(result.current.phase).toBe('active'); 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); + const { result } = renderHook(() => useReverificationModel({ phase: 'inactive' })); + expect(result.current.phase).toBe('inactive'); }); it('defaults to second-factor verification when no level is provided', async () => { @@ -299,15 +299,22 @@ describe('useReverificationModel', () => { ).rejects.toThrow('That password is incorrect.'); }); - it('activates the verified session and calls complete', async () => { + it('activates the verified session before complete', async () => { session?.startVerification.mockResolvedValue(resource({ status: 'complete' })); + const order: string[] = []; + setActive.mockImplementation(() => { + order.push('setActive'); + }); const props = activeProps(); + props.complete.mockImplementation(() => { + order.push('complete'); + }); 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(); + expect(order).toEqual(['setActive', 'complete']); }); it('does not call complete when setActive fails', async () => { diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx index 5611f71f638..2d5e2b876bb 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx @@ -8,7 +8,7 @@ import type { ReverificationViewProps } from '../reverification.types'; const model: ReverificationModel = { status: 'ready', - isActive: true, + phase: 'active', supportEmail: '', start: vi.fn(), prepare: vi.fn(), @@ -28,11 +28,20 @@ vi.mock('../reverification.controller', () => ({ })); vi.mock('../reverification.view', () => ({ - ReverificationView: ({ step }: { step: string }) => {step}, + ReverificationPending: () => , + ReverificationUnavailable: () => , + ReverificationView: ({ step, embedded }: { step: string; embedded?: boolean }) => ( + + {step} + + ), })); const active = { - isActive: true as const, + phase: 'active' as const, complete: vi.fn(), cancel: vi.fn(), level: 'first_factor' as const, @@ -59,25 +68,45 @@ function ready(overrides: Partial = {}): Reverification } describe('Reverification', () => { - it('renders nothing when reverification is not active', () => { + it('renders nothing while reverification is inactive', () => { controller = { status: 'idle' }; - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); - it('renders nothing while the controller is loading or unavailable', () => { + it('renders pending, unavailable, or the factor view', () => { controller = { status: 'loading' }; - const loading = render(); - expect(loading.container).toBeEmptyDOMElement(); + const { rerender } = render(); + expect(screen.getByTestId('pending')).toBeInTheDocument(); + expect(screen.queryByTestId('view')).not.toBeInTheDocument(); + expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); controller = { status: 'unavailable' }; - const unavailable = render(); - expect(unavailable.container).toBeEmptyDOMElement(); - }); + rerender(); + expect(screen.getByTestId('unavailable')).toBeInTheDocument(); + expect(screen.queryByTestId('view')).not.toBeInTheDocument(); + expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); - it('renders the view once the controller is ready', () => { controller = ready({ step: 'otp', otpChannel: 'email' }); - render(); + rerender(); expect(screen.getByTestId('view')).toHaveTextContent('otp'); + expect(screen.getByTestId('view')).toHaveAttribute('data-embedded', ''); + expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); + }); + + it('keeps the card flow mounted through retrying and can omit the card', () => { + controller = ready({ isPending: true }); + const { rerender } = render( + , + ); + expect(screen.getByTestId('view')).toHaveTextContent('password'); + expect(screen.getByTestId('view')).toHaveAttribute('data-embedded', ''); + + controller = { status: 'idle' }; + rerender(); + expect(screen.queryByTestId('view')).not.toBeInTheDocument(); }); }); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx index d9439dedfc7..39190d96c71 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx @@ -109,6 +109,14 @@ describe('ReverificationView', () => { expect(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (23)' })).toBeDisabled(); }); + it('omits the card root when embedded', () => { + const { container } = renderView({ embedded: true }); + + expect(container.querySelector('.cl-card-root')).toBeNull(); + expect(container.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'password'); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + }); + it('does not render Card branding', () => { renderView(); diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index 98beda9cf65..2d3bf25f1cd 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -5,7 +5,12 @@ 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 type { + ReverificationMethod, + ReverificationResult, + ReverificationStep, + ReverificationViewProps, +} from './reverification.types'; import { needsPrepare, otpChannelFor } from './reverification.utils'; export type ReverificationController = @@ -352,7 +357,20 @@ export const reverificationMachine = createMachine({ const pendingStates = new Set(['submitting', 'completing']); -function viewStep(value: string, method: ReverificationMethod | null): ReverificationViewProps['step'] | undefined { +function factorStep(method: ReverificationMethod): ReverificationStep { + if (method.strategy === 'password') { + return 'password'; + } + if (method.strategy === 'passkey') { + return 'passkey'; + } + if (method.strategy === 'backup_code') { + return 'backup-code'; + } + return 'otp'; +} + +function viewStep(value: string, method: ReverificationMethod | null): ReverificationStep | undefined { if (value === 'methodPicker' || value === 'methodPickerPreparing') { return 'method-picker'; } @@ -363,16 +381,7 @@ function viewStep(value: string, method: ReverificationMethod | null): Reverific 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 factorStep(method); } return undefined; } @@ -380,13 +389,15 @@ function viewStep(value: string, method: ReverificationMethod | null): Reverific /** * 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 + * - status: 'loading' | 'unavailable' carry no factor props + * - status: 'ready' carries the factor view, including step * - * Note that there are two loading states. - * - status: 'loading' - Full card spinner - * - status: 'ready' && isPending: true - Current action is pending, inline loading state + * There are two pending presentations. + * - status: 'loading' is the pending card rendered in place, before a factor exists + * - status: 'ready' && isPending is the inline pending state of the current factor + * + * Root reverification `phase` is an input, not part of this return. The factor machine runs + * while `active` and resets only when `phase` returns to `inactive`. `retrying` keeps the last factor pending. */ export function useReverificationController(model: ReverificationModel): ReverificationController { const ready = model.status === 'ready' ? model : null; @@ -416,8 +427,9 @@ export function useReverificationController(model: ReverificationModel): Reverif return () => window.clearInterval(id); }, [countingDown, resendAvailableAt]); - const needsStart = model.isActive && Boolean(ready) && snapshot.value === 'inactive'; - const needsReset = !model.isActive && snapshot.value !== 'inactive'; + const reverificationPhase = model.phase; + const needsStart = reverificationPhase === 'active' && Boolean(ready) && snapshot.value === 'inactive'; + const needsReset = reverificationPhase === 'inactive' && snapshot.value !== 'inactive'; useEffect(() => { if (needsStart) { send({ type: 'START' }); @@ -426,25 +438,31 @@ export function useReverificationController(model: ReverificationModel): Reverif } }, [needsStart, needsReset, send]); - if (!model.isActive) { + if (reverificationPhase === 'inactive') { return { status: 'idle' }; } - if (snapshot.value === 'inactive' || snapshot.value === 'starting' || snapshot.value === 'done') { - return { status: 'loading' }; - } + const { context } = snapshot; + const activeMethod = context.activeMethod; if (snapshot.value === 'unavailable') { return { status: 'unavailable' }; } - const { context } = snapshot; - const step = viewStep(snapshot.value, context.activeMethod); + // If the action is retrying after success and the component is rendered, we stay + // on the last factor that was visible in a pending state until the retry is done + const step = + reverificationPhase === 'retrying' && snapshot.value === 'done' && activeMethod + ? factorStep(activeMethod) + : viewStep(snapshot.value, activeMethod); + if (!step) { + if (snapshot.value === 'inactive' || snapshot.value === 'starting' || snapshot.value === 'done') { + return { status: 'loading' }; + } 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; @@ -456,9 +474,9 @@ export function useReverificationController(model: ReverificationModel): Reverif step, direction: context.direction, value: context.inputValue, - onValueChange: value => send({ type: 'TYPE', value }), + onValueChange: (value: string) => send({ type: 'TYPE', value }), errorMessage: context.errorMessage, - isPending: pendingStates.has(snapshot.value), + isPending: reverificationPhase === 'retrying' || pendingStates.has(snapshot.value), onSubmit: () => send({ type: 'SUBMIT' }), onShowMethods: () => send({ type: 'SHOW_METHODS' }), onShowHelp: () => send({ type: 'SHOW_HELP' }), @@ -470,7 +488,7 @@ export function useReverificationController(model: ReverificationModel): Reverif }, methods, pendingMethodId, - onSelectMethod: id => send({ type: 'SELECT_METHOD', id }), + onSelectMethod: (id: string) => send({ type: 'SELECT_METHOD', id }), otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, onResend: () => send({ type: 'RESEND' }), canResend, diff --git a/packages/mosaic/src/features/reverification/reverification.messages.ts b/packages/mosaic/src/features/reverification/reverification.messages.ts index 2b26f27a698..bb6a0c63397 100644 --- a/packages/mosaic/src/features/reverification/reverification.messages.ts +++ b/packages/mosaic/src/features/reverification/reverification.messages.ts @@ -21,6 +21,9 @@ export const reverificationMessages = { description: 'Enter the backup code you received when setting up two-step authentication', title: 'Enter a backup code', }, + loading: { + title: 'Verification required', + }, emailCode: { description: 'Enter the code sent to your email to continue', formTitle: 'Verification code', @@ -50,6 +53,10 @@ export const reverificationMessages = { resendButton: 'Didn’t receive a code? Resend', title: 'Verification required', }, + unavailable: { + description: 'Cannot proceed with verification. No suitable authentication factor is configured.', + title: 'Cannot verify your account', + }, totpMfa: { description: 'Enter the code generated by your authenticator app to continue', formTitle: 'Verification code', diff --git a/packages/mosaic/src/features/reverification/reverification.model.tsx b/packages/mosaic/src/features/reverification/reverification.model.tsx index 76b76a73bd0..9b14c62fabe 100644 --- a/packages/mosaic/src/features/reverification/reverification.model.tsx +++ b/packages/mosaic/src/features/reverification/reverification.model.tsx @@ -21,7 +21,7 @@ import { pickStartingMethod } from './reverification.utils'; export type ReverificationReadyModel = { status: 'ready'; - isActive: boolean; + phase: ReverificationProps['phase']; supportEmail: string; start: () => Promise; prepare: (method: ReverificationPreparableMethod) => Promise; @@ -30,7 +30,7 @@ export type ReverificationReadyModel = { cancel: () => void; }; -export type ReverificationModel = { status: 'loading'; isActive: boolean } | ReverificationReadyModel; +export type ReverificationModel = { status: 'loading'; phase: ReverificationProps['phase'] } | ReverificationReadyModel; function toError(error: unknown): Error { if (isClerkAPIResponseError(error)) { @@ -119,10 +119,13 @@ export function useReverificationModel(props: ReverificationProps): Reverificati const clerk = useClerk(); const environment = useMosaicEnvironment(); const supportEmail = useMosaicSupportEmail(); - const { isActive, cancel, complete, level } = props; + const phase = props.phase; + const level = props.phase === 'active' ? props.level : undefined; + const cancel = props.phase === 'active' ? props.cancel : undefined; + const complete = props.phase === 'active' ? props.complete : undefined; if (!session || !environment || supportEmail === undefined) { - return { status: 'loading', isActive }; + return { status: 'loading', phase }; } const webAuthnSupported = isWebAuthnSupported(); @@ -133,7 +136,7 @@ export function useReverificationModel(props: ReverificationProps): Reverificati return { status: 'ready', - isActive, + phase, supportEmail, start: async () => { try { diff --git a/packages/mosaic/src/features/reverification/reverification.styles.ts b/packages/mosaic/src/features/reverification/reverification.styles.ts new file mode 100644 index 00000000000..506678bc8bb --- /dev/null +++ b/packages/mosaic/src/features/reverification/reverification.styles.ts @@ -0,0 +1,23 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../../tokens.stylex'; + +const stackedFooter = '@container card (max-width: 20rem)' as const; +const half = `calc(50% - ${space['1']})`; + +export const styles = stylex.create({ + pending: { + paddingBlock: space['12'], + alignItems: 'center', + display: 'flex', + justifyContent: 'center', + width: '100%', + }, + pendingAction: { + flexBasis: { default: half, [stackedFooter]: 'auto' }, + flexGrow: { default: 0, [stackedFooter]: 1 }, + flexShrink: 0, + marginInlineStart: { default: 'auto', [stackedFooter]: 0 }, + width: { default: half, [stackedFooter]: '100%' }, + }, +}); diff --git a/packages/mosaic/src/features/reverification/reverification.tsx b/packages/mosaic/src/features/reverification/reverification.tsx index 5b277c9df62..a41f0170e2f 100644 --- a/packages/mosaic/src/features/reverification/reverification.tsx +++ b/packages/mosaic/src/features/reverification/reverification.tsx @@ -1,21 +1,38 @@ +import { Card } from '../../components/card'; import { useReverificationController } from './reverification.controller'; import { useReverificationModel } from './reverification.model'; import type { ReverificationProps } from './reverification.types'; -import { ReverificationView } from './reverification.view'; +import { ReverificationPending, ReverificationUnavailable, ReverificationView } from './reverification.view'; -export function Reverification(props: ReverificationProps) { +export function Reverification({ embedded = false, ...props }: ReverificationProps & { embedded?: boolean }) { const model = useReverificationModel(props); const controller = useReverificationController(model); - if (!props.isActive) { + if (controller.status === 'idle') { return null; } - if (controller.status !== 'ready') { - // TODO: Implement unavailable and loading states, could also live in the .view. - return null; + let content: JSX.Element; + if (controller.status === 'loading') { + content = ; + } else if (controller.status === 'unavailable') { + content = ; + } else { + const { status: _status, ...viewProps } = controller; + content = ( + and the view is always acts as embedded + embedded + /> + ); + } + + if (embedded) { + return content; } - const { status: _status, ...viewProps } = controller; - return ; + return {content}; } diff --git a/packages/mosaic/src/features/reverification/reverification.types.ts b/packages/mosaic/src/features/reverification/reverification.types.ts index c31890a4fbe..fb9a2eb3bf7 100644 --- a/packages/mosaic/src/features/reverification/reverification.types.ts +++ b/packages/mosaic/src/features/reverification/reverification.types.ts @@ -18,8 +18,8 @@ export type ReverificationMethod = export type ReverificationPreparableMethod = Extract; -export type ReverificationViewProps = { - step: ReverificationStep; +type ReverificationViewFields = { + embedded?: boolean; direction?: FlowDirection; value: string; onValueChange: (value: string) => void; @@ -39,9 +39,14 @@ export type ReverificationViewProps = { resendRemainingSeconds?: number; }; +export type ReverificationViewProps = ReverificationViewFields & { + step: ReverificationStep; +}; + export type ReverificationProps = - | { isActive: false; complete?: undefined; cancel?: undefined; level?: undefined } - | { isActive: true; complete: () => void; cancel: () => void; level: SessionVerificationLevel | undefined }; + | { phase: 'inactive' } + | { phase: 'active'; complete: () => void; cancel: () => void; level: SessionVerificationLevel | undefined } + | { phase: 'retrying' }; export type ReverificationResult = { status: 'needs_first_factor' | 'needs_second_factor' | 'complete'; diff --git a/packages/mosaic/src/features/reverification/reverification.view.tsx b/packages/mosaic/src/features/reverification/reverification.view.tsx index f160482f1c2..3e4711a8cbc 100644 --- a/packages/mosaic/src/features/reverification/reverification.view.tsx +++ b/packages/mosaic/src/features/reverification/reverification.view.tsx @@ -1,8 +1,11 @@ +import { Button } from '../../components/button'; import { Card } from '../../components/card'; import { Flow } from '../../components/flow'; +import { Spinner } from '../../components/spinner'; import type { IconName } from '../../icons/registry'; import type { MosaicMessages } from '../../localization'; import { fill, useMessages } from '../../localization'; +import { styles } from './reverification.styles'; import type { ReverificationMethod, ReverificationOtpChannel, ReverificationViewProps } from './reverification.types'; import { ReverificationBackupCode } from './steps/reverification-backup-code'; import { ReverificationHelp } from './steps/reverification-help'; @@ -40,6 +43,46 @@ function methodLabel(method: ReverificationMethod, m: Messages): string { } } +// Lives here but is rendered by the wrapper parent +export function ReverificationUnavailable() { + const m = useMessages('reverification'); + + return ( + + {m.unavailable.title} + {m.unavailable.description} + + ); +} + +// Lives here but is rendered by the wrapper parent +export function ReverificationPending() { + const m = useMessages('reverification'); + + return ( + <> + + {m.loading.title} + + + + + + + + + ); +} + function otpCopy(channel: ReverificationOtpChannel | undefined, m: Messages) { if (channel === 'email') { return m.emailCode; @@ -50,28 +93,28 @@ function otpCopy(channel: ReverificationOtpChannel | undefined, m: Messages) { return m.totpMfa; } -export function ReverificationView(props: ReverificationViewProps): JSX.Element { +export function ReverificationView({ + step, + direction, + value, + onValueChange, + errorMessage, + isPending, + onSubmit, + onShowMethods, + onShowHelp, + onBack, + onEmailSupport, + methods, + pendingMethodId, + onSelectMethod, + otpChannel, + onResend, + canResend, + resendRemainingSeconds, + embedded = false, +}: ReverificationViewProps): JSX.Element { const m = useMessages('reverification'); - const { - step, - direction, - value, - onValueChange, - errorMessage, - isPending, - onSubmit, - onShowMethods, - onShowHelp, - onBack, - onEmailSupport, - methods, - pendingMethodId, - onSelectMethod, - otpChannel, - onResend, - canResend, - resendRemainingSeconds, - } = props; const otp = otpCopy(otpChannel, m); const actions = { @@ -84,128 +127,134 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element const resend = otpChannel === 'email' || otpChannel === 'phone' ? { - label: resendRemainingSeconds ? `${resendLabel} (${resendRemainingSeconds})` : resendLabel, - disabled: !canResend || isPending, - onClick: onResend, - } + 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, m), - icon: methodIcon[method.strategy], - }))} - pendingMethodId={pendingMethodId} - onSelect={onSelectMethod} - onHelp={onShowHelp} - onBack={onBack} - /> - - - - - - - )} - - + const flow = ( + + {() => ( + <> + + + + + + + + + + { + onValueChange(code); + onSubmit(); + }} + onSubmit={onSubmit} + onCancel={hasAlternatives ? onShowMethods : undefined} + /> + + + + + + + + ({ + id: method.id, + label: methodLabel(method, m), + icon: methodIcon[method.strategy], + }))} + pendingMethodId={pendingMethodId} + onSelect={onSelectMethod} + onHelp={onShowHelp} + onBack={onBack} + /> + + + + + + + )} + ); + + if (embedded) { + return flow; + } + + // The wrapper always renders the view as embedded, so this case should only + // happen when rendering the view standalone (e.g. for swingset) + return {flow}; } diff --git a/packages/swingset/src/stories/reverification.mdx b/packages/swingset/src/stories/reverification.mdx index 0fa8f8eec3c..d1a8a1f2877 100644 --- a/packages/swingset/src/stories/reverification.mdx +++ b/packages/swingset/src/stories/reverification.mdx @@ -17,13 +17,13 @@ The example pauses briefly in each pending state. Enter `error` in password or b ## Usage -The feature wrapper reads Clerk state and drives `ReverificationView`. Isolated panel stories below are for visual development only. +The feature wrapper reads Clerk state and drives the reverification flow. It renders nothing while `phase` is `inactive`. While a challenge is still preparing, the card stays in place with a verification header, a centered spinner, and a disabled continue button, then shows the factor without a step transition. A card appears for a ready factor, a pending retry, or the unavailable state. Pass `embedded` to render that flow without the card. Isolated panel stories below are for visual development only. ```tsx import { Reverification } from '@clerk/mosaic/features/reverification'; {}} cancel={() => {}} level='first_factor' From 105109e8410cd53d07f2a65c077e70600086ae24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 22 Sep 2026 17:42:36 +0200 Subject: [PATCH 04/40] Add more cases to Reverification story in swingset live --- .../reverification.composition.test.tsx | 28 +- .../__tests__/reverification.test.tsx | 23 +- .../__tests__/reverification.view.test.tsx | 22 +- .../reverification/reverification.tsx | 26 +- .../reverification/reverification.types.ts | 1 - .../reverification/reverification.view.tsx | 19 +- .../swingset/src/app/(clerk)/live-sidebar.tsx | 6 +- .../app/(clerk)/live/reverification/page.tsx | 294 +++++++++++++++--- .../src/app/api/live/mock-delete/route.ts | 2 +- .../swingset/src/stories/reverification.mdx | 17 +- .../src/stories/reverification.stories.tsx | 54 ++-- 11 files changed, 333 insertions(+), 159 deletions(-) diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx index f2c14a9c485..79858223aee 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx @@ -72,7 +72,6 @@ function Nested({ step }: { step: 'confirm' | 'verify' | 'finalizing' }) { { +describe('reverification inside an outer flow', () => { it('keeps one dialog and one card, and nests a flow only while verifying', () => { controller = { status: 'idle' }; const { rerender } = render(); @@ -183,7 +182,9 @@ describe('reverification card states', () => { controller = { status: 'loading' }; const { container, rerender } = render( - + + + , ); const card = container.querySelector('.cl-card-root'); @@ -199,7 +200,9 @@ describe('reverification card states', () => { controller = surface(); rerender( - + + + , ); @@ -215,10 +218,9 @@ describe('reverification card states', () => { - + + + , @@ -226,14 +228,16 @@ describe('reverification card states', () => { expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); expect(screen.getByRole('dialog')).toHaveAccessibleName('Verification required'); - expect(document.querySelector('.cl-card-root')).toBeNull(); + expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); }); it('renders unavailable outside the factor flow, then mounts that flow in the same card', () => { controller = { status: 'unavailable' }; const { container, rerender } = render( - + + + , ); const card = container.querySelector('.cl-card-root'); @@ -248,7 +252,9 @@ describe('reverification card states', () => { controller = surface(); rerender( - + + + , ); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx index 2d5e2b876bb..f8f6946ccae 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx @@ -30,14 +30,7 @@ vi.mock('../reverification.controller', () => ({ vi.mock('../reverification.view', () => ({ ReverificationPending: () => , ReverificationUnavailable: () => , - ReverificationView: ({ step, embedded }: { step: string; embedded?: boolean }) => ( - - {step} - - ), + ReverificationView: ({ step }: { step: string }) => {step}, })); const active = { @@ -79,31 +72,21 @@ describe('Reverification', () => { const { rerender } = render(); expect(screen.getByTestId('pending')).toBeInTheDocument(); expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); controller = { status: 'unavailable' }; rerender(); expect(screen.getByTestId('unavailable')).toBeInTheDocument(); expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); controller = ready({ step: 'otp', otpChannel: 'email' }); rerender(); expect(screen.getByTestId('view')).toHaveTextContent('otp'); - expect(screen.getByTestId('view')).toHaveAttribute('data-embedded', ''); - expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); }); - it('keeps the card flow mounted through retrying and can omit the card', () => { + it('keeps the factor flow mounted through retrying', () => { controller = ready({ isPending: true }); - const { rerender } = render( - , - ); + const { rerender } = render(); expect(screen.getByTestId('view')).toHaveTextContent('password'); - expect(screen.getByTestId('view')).toHaveAttribute('data-embedded', ''); controller = { status: 'idle' }; rerender(); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx index 39190d96c71..cf185f5c28c 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx @@ -33,15 +33,13 @@ function renderView(overrides: Partial = {}) { } describe('ReverificationView', () => { - it('keeps one Card and Flow surface while view props and panels change', () => { + it('keeps one 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(container.querySelector('.cl-card-root')).toBeNull(); expect(flow).toHaveAttribute('data-value', 'password'); rerender( @@ -50,7 +48,6 @@ describe('ReverificationView', () => { , ); - 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); @@ -60,7 +57,6 @@ describe('ReverificationView', () => { , ); - 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'); @@ -109,20 +105,6 @@ describe('ReverificationView', () => { expect(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (23)' })).toBeDisabled(); }); - it('omits the card root when embedded', () => { - const { container } = renderView({ embedded: true }); - - expect(container.querySelector('.cl-card-root')).toBeNull(); - expect(container.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'password'); - expect(screen.getByLabelText('Password')).toBeInTheDocument(); - }); - - 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', diff --git a/packages/mosaic/src/features/reverification/reverification.tsx b/packages/mosaic/src/features/reverification/reverification.tsx index a41f0170e2f..05f1958f0f2 100644 --- a/packages/mosaic/src/features/reverification/reverification.tsx +++ b/packages/mosaic/src/features/reverification/reverification.tsx @@ -1,10 +1,9 @@ -import { Card } from '../../components/card'; import { useReverificationController } from './reverification.controller'; import { useReverificationModel } from './reverification.model'; import type { ReverificationProps } from './reverification.types'; import { ReverificationPending, ReverificationUnavailable, ReverificationView } from './reverification.view'; -export function Reverification({ embedded = false, ...props }: ReverificationProps & { embedded?: boolean }) { +export function Reverification(props: ReverificationProps) { const model = useReverificationModel(props); const controller = useReverificationController(model); @@ -12,27 +11,12 @@ export function Reverification({ embedded = false, ...props }: ReverificationPro return null; } - let content: JSX.Element; if (controller.status === 'loading') { - content = ; + return ; } else if (controller.status === 'unavailable') { - content = ; - } else { - const { status: _status, ...viewProps } = controller; - content = ( - and the view is always acts as embedded - embedded - /> - ); + return ; } - if (embedded) { - return content; - } - - return {content}; + const { status: _status, ...viewProps } = controller; + return ; } diff --git a/packages/mosaic/src/features/reverification/reverification.types.ts b/packages/mosaic/src/features/reverification/reverification.types.ts index fb9a2eb3bf7..94d690320f3 100644 --- a/packages/mosaic/src/features/reverification/reverification.types.ts +++ b/packages/mosaic/src/features/reverification/reverification.types.ts @@ -19,7 +19,6 @@ export type ReverificationMethod = export type ReverificationPreparableMethod = Extract; type ReverificationViewFields = { - embedded?: boolean; direction?: FlowDirection; value: string; onValueChange: (value: string) => void; diff --git a/packages/mosaic/src/features/reverification/reverification.view.tsx b/packages/mosaic/src/features/reverification/reverification.view.tsx index 3e4711a8cbc..1e13684542a 100644 --- a/packages/mosaic/src/features/reverification/reverification.view.tsx +++ b/packages/mosaic/src/features/reverification/reverification.view.tsx @@ -112,7 +112,6 @@ export function ReverificationView({ onResend, canResend, resendRemainingSeconds, - embedded = false, }: ReverificationViewProps): JSX.Element { const m = useMessages('reverification'); @@ -127,13 +126,13 @@ export function ReverificationView({ const resend = otpChannel === 'email' || otpChannel === 'phone' ? { - label: resendRemainingSeconds ? `${resendLabel} (${resendRemainingSeconds})` : resendLabel, - disabled: !canResend || isPending, - onClick: onResend, - } + label: resendRemainingSeconds ? `${resendLabel} (${resendRemainingSeconds})` : resendLabel, + disabled: !canResend || isPending, + onClick: onResend, + } : undefined; - const flow = ( + return ( ); - - if (embedded) { - return flow; - } - - // The wrapper always renders the view as embedded, so this case should only - // happen when rendering the view standalone (e.g. for swingset) - return {flow}; } diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index 2e206b399de..92d6c86be04 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -20,6 +20,10 @@ import { const flows = [{ title: 'Reverification', href: '/live/reverification' }]; +function flowIsActive(pathname: string, href: string) { + return pathname === href || pathname.startsWith(`${href}/`); +} + export function LiveSidebar(props: ComponentProps) { const pathname = usePathname(); @@ -64,7 +68,7 @@ export function LiveSidebar(props: ComponentProps) { } > {flow.title} diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx index 47f4be03068..9fcf416c523 100644 --- a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -1,14 +1,19 @@ 'use client'; import { Button } from '@clerk/mosaic/components/button'; +import { Card } from '@clerk/mosaic/components/card'; +import { Dialog } from '@clerk/mosaic/components/dialog'; +import { Flow, type FlowDirection } from '@clerk/mosaic/components/flow'; import { Reverification, useReverificationWithState } from '@clerk/mosaic/features/reverification'; import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; -import { SignOutButton, useUser } from '@clerk/nextjs'; -import { isReverificationCancelledError } from '@clerk/shared/error'; +import { useUser } from '@clerk/nextjs'; +import { isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; import Link from 'next/link'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; -async function mockDelete() { +const SUCCESS_DELAY_MS = 3000; + +async function mockDelete(delaySuccess: boolean) { const response = await fetch('/api/live/mock-delete', { method: 'POST' }); const body = await response.json().catch(() => ({})); @@ -18,6 +23,11 @@ async function mockDelete() { if (!response.ok) { throw new Error(typeof body.error === 'string' ? body.error : `Mock delete failed (${response.status})`); } + if (delaySuccess) { + await new Promise(resolve => { + setTimeout(resolve, SUCCESS_DELAY_MS); + }); + } return body; } @@ -25,31 +35,36 @@ async function resetMockDelete() { await fetch('/api/live/mock-delete', { method: 'DELETE' }); } -function DeleteAccountHarness() { - const { user } = useUser(); +function CardHarness() { const [status, setStatus] = useState<'idle' | 'success' | 'cancelled' | 'error'>('idle'); const [message, setMessage] = useState(null); - const [deleteAccount, reverification] = useReverificationWithState(mockDelete); - - if (!user) { - return null; - } + const [requestPending, setRequestPending] = useState(false); + const [deleteAccount, reverification] = useReverificationWithState(() => mockDelete(false)); + const busy = requestPending || reverification.phase !== 'inactive'; return (
-

Signed in as {user.primaryEmailAddress?.emailAddress ?? user.id}

- - -
- + + + {message ?

{message}

: null}
); } +type Presentation = 'retain' | 'replace'; +type OuterStep = 'confirm' | 'verify' | 'finalizing'; + +function DialogHarness() { + const [deleteAccount, reverification] = useReverificationWithState(() => mockDelete(true)); + const [open, setOpen] = useState(false); + const [presentation, setPresentation] = useState('retain'); + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [requestPending, setRequestPending] = useState(false); + const [direction, setDirection] = useState(1); + const [step, setStep] = useState('confirm'); + const runRef = useRef(false); + + const nextStep: OuterStep | null = + reverification.phase === 'retrying' && presentation === 'replace' + ? 'finalizing' + : reverification.phase === 'active' || reverification.phase === 'retrying' + ? 'verify' + : null; + + if (nextStep && nextStep !== step) { + setStep(nextStep); + setDirection(1); + } + + const continueDelete = () => { + if (runRef.current || reverification.phase !== 'inactive') { + return; + } + runRef.current = true; + setRequestPending(true); + setErrorMessage(null); + setSuccessMessage(null); + void (async () => { + try { + await resetMockDelete(); + await deleteAccount(); + setSuccessMessage('Mock delete completed. The account was not deleted.'); + setOpen(false); + } catch (error) { + if (isClerkRuntimeError(error) && error.code === 'request_already_in_progress') { + return; + } + await resetMockDelete(); + if (isReverificationCancelledError(error)) { + setErrorMessage(null); + setOpen(false); + return; + } + setErrorMessage(error instanceof Error ? error.message : 'Mock delete failed.'); + setStep('confirm'); + setDirection(-1); + setOpen(true); + } finally { + runRef.current = false; + setRequestPending(false); + } + })(); + }; + + return ( +
+
+ +
+ {successMessage ?

{successMessage}

: null} + { + if (next) { + return; + } + if (requestPending && reverification.phase === 'inactive') { + return; + } + if (reverification.phase === 'active') { + reverification.cancel(); + } + setOpen(false); + if (reverification.phase !== 'retrying') { + setErrorMessage(null); + void resetMockDelete(); + } + }} + > + + + + {() => ( + <> + + + Delete account? + + This mock asks for verification, then waits before a fake success. The account is not deleted. + + + +
+ While the mock delete finishes + + +
+ {errorMessage ?

{errorMessage}

: null} +
+ + + +
+ + + + + + Finalizing + Completing the mock delete. The account is not deleted. + + + + )} +
+
+
+
+
+ ); +} + export default function ReverificationLivePage() { const { isLoaded, isSignedIn } = useUser(); + const [demoKey, setDemoKey] = useState(0); return ( -
-
+
+

Reverification

-

- Delete account hits a mock route that returns a reverification hint, then a fake success. The account is not - deleted. -

-
- {!isLoaded ?

Loading…

: null} - {isLoaded && !isSignedIn ? ( -

- +

+ Delete account hits a mock route that returns a reverification hint, then a fake success. The account is + not deleted. Reset clears the mock state. +

+ +
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to use the live harness. +

+ ) : null} +
+
+
+

Card

+

+ The default is to render Reverification inside its own card. +

+
+ {isLoaded && isSignedIn ? : null} +
+
+
+

Dialog / Flow

+

+ Reverification renders inside the dialog's card. This dialog showcases two different pending states: + the action retry can either be merged with the reverification pending state, or be its own flow step + after. +

+
+ {isLoaded && isSignedIn ? : null} +
); diff --git a/packages/swingset/src/app/api/live/mock-delete/route.ts b/packages/swingset/src/app/api/live/mock-delete/route.ts index fb0b9d7fd8c..293dbaa5bf2 100644 --- a/packages/swingset/src/app/api/live/mock-delete/route.ts +++ b/packages/swingset/src/app/api/live/mock-delete/route.ts @@ -6,7 +6,7 @@ const COOKIE = 'swingset-mock-delete'; export async function POST() { const store = await cookies(); - // First call: ask for step-up. After the card completes, the same fetcher is retried. + // First call asks for step-up. The same fetcher is retried after verification. if (store.get(COOKIE)?.value !== '1') { store.set(COOKIE, '1', { path: '/', maxAge: 120, sameSite: 'lax' }); return reverificationErrorResponse('strict'); diff --git a/packages/swingset/src/stories/reverification.mdx b/packages/swingset/src/stories/reverification.mdx index d1a8a1f2877..4cf0f1009ab 100644 --- a/packages/swingset/src/stories/reverification.mdx +++ b/packages/swingset/src/stories/reverification.mdx @@ -17,17 +17,20 @@ The example pauses briefly in each pending state. Enter `error` in password or b ## Usage -The feature wrapper reads Clerk state and drives the reverification flow. It renders nothing while `phase` is `inactive`. While a challenge is still preparing, the card stays in place with a verification header, a centered spinner, and a disabled continue button, then shows the factor without a step transition. A card appears for a ready factor, a pending retry, or the unavailable state. Pass `embedded` to render that flow without the card. Isolated panel stories below are for visual development only. +The feature wrapper reads Clerk state and drives the reverification flow. It renders nothing while `phase` is `inactive`. While a challenge is still preparing, the parent card shows a verification header, a centered spinner, and a disabled continue button, then shows the factor without a step transition. Keep the same card around the component for a ready factor, a pending retry, or the unavailable state. Isolated panel stories below are for visual development only. ```tsx +import { Card } from '@clerk/mosaic/components/card'; import { Reverification } from '@clerk/mosaic/features/reverification'; - {}} - cancel={() => {}} - level='first_factor' -/>; + + {}} + cancel={() => {}} + level='first_factor' + /> +; ``` ## Panels diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx index 83a8067f4c6..1b39191d9ca 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -201,32 +201,34 @@ function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element 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} - /> + + 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} ); From b59801f30478442b18750822ac90784bb949c614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 22 Sep 2026 20:42:06 +0200 Subject: [PATCH 05/40] Add Reverification support to destructive block --- .changeset/tall-words-type.md | 2 + .../blocks/destructive/destructive.test.tsx | 71 +++++++++ .../src/blocks/destructive/destructive.tsx | 141 +++++++++++------- .../app/(clerk)/live/reverification/page.tsx | 98 +++++++++++- packages/swingset/src/stories/destructive.mdx | 43 ++++++ 5 files changed, 300 insertions(+), 55 deletions(-) create mode 100644 .changeset/tall-words-type.md diff --git a/.changeset/tall-words-type.md b/.changeset/tall-words-type.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tall-words-type.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/blocks/destructive/destructive.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.test.tsx index 797ca23ed6e..271c172d7c0 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.test.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.test.tsx @@ -9,6 +9,10 @@ import { MosaicProvider } from '../../MosaicProvider'; import type { DestructiveControlledProps, DestructiveHandleProps } from './destructive'; import { Destructive } from './destructive'; +vi.mock('../../features/reverification', () => ({ + Reverification: ({ phase }: { phase: string }) => {phase}, +})); + function renderBlock(overrides: Partial = {}) { return render( @@ -28,6 +32,12 @@ function renderBlock(overrides: Partial = {}) { } const confirmButton = () => screen.getByRole('button', { name: 'Delete account' }); +const activeReverification = { + phase: 'active' as const, + complete: vi.fn(), + cancel: vi.fn(), + level: 'first_factor' as const, +}; describe('Destructive', () => { it('renders nothing until the caller opens it', () => { @@ -143,6 +153,67 @@ describe('Destructive', () => { await user.click(confirmButton()); expect(onDelete).not.toHaveBeenCalled(); }); + + it('shows reverification in the same dialog and card', () => { + renderBlock({ isDeleting: true, reverification: activeReverification }); + + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); + expect(screen.getByTestId('reverification')).toHaveTextContent('active'); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('cancels active reverification when the dialog closes', async () => { + const cancel = vi.fn(); + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderBlock({ + isDeleting: true, + onOpenChange, + reverification: { ...activeReverification, cancel }, + }); + + await user.keyboard('{Escape}'); + + expect(cancel).toHaveBeenCalledOnce(); + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('keeps the retrying factor visible until the hook becomes inactive', () => { + const props = { + open: true, + onOpenChange: vi.fn(), + title: 'Delete account?', + description: 'All of your data will be permanently deleted.', + fieldLabel: 'Type “Delete account” below to continue', + confirmationValue: 'Delete account', + actionLabel: 'Delete account', + onDelete: vi.fn(), + }; + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByText('Delete failed.')).toBeInTheDocument(); + }); }); function renderWithHandle(onDelete: DestructiveHandleProps['onDelete']) { diff --git a/packages/mosaic/src/blocks/destructive/destructive.tsx b/packages/mosaic/src/blocks/destructive/destructive.tsx index 65e7958d1f9..eebeb2a2bd0 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.tsx @@ -3,10 +3,12 @@ import { useEffect, useId, useState } from 'react'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; -import type { DialogFocusTarget, DialogHandle, DialogTriggerProps } from '../../components/dialog'; +import type { DialogFocusTarget, DialogHandle, DialogRootProps, DialogTriggerProps } from '../../components/dialog'; import { Dialog } from '../../components/dialog'; import { Field } from '../../components/field'; +import { Flow } from '../../components/flow'; import { Input } from '../../components/input'; +import { Reverification, type ReverificationProps } from '../../features/reverification'; import { type FromPayload, resolveFromPayload as resolve } from '../../utils/resolve-from-payload'; import { useConfirmationController } from '../confirmation/confirmation.controller'; @@ -15,7 +17,7 @@ export interface DestructiveControlledProps { /** Whether the dialog is open */ open: boolean; /** Callback when open state changes */ - onOpenChange: (open: boolean) => void; + onOpenChange: NonNullable; /** Element that opens the dialog */ trigger?: DialogTriggerProps['render']; /** Dialog heading */ @@ -36,6 +38,7 @@ export interface DestructiveControlledProps { isDeleting?: boolean; /** Error message to display if the delete action fails */ errorMessage?: string; + reverification?: ReverificationProps; } type DestructiveCardProps = Omit; @@ -52,6 +55,7 @@ function DestructiveCard({ onDelete, isDeleting = false, errorMessage, + reverification, }: DestructiveCardProps) { const formId = useId(); const [typedValue, setTypedValue] = useState(''); @@ -65,6 +69,7 @@ function DestructiveCard({ }, [open]); const isConfirmed = typedValue === confirmationValue; + const step = reverification && reverification.phase !== 'inactive' ? 'verify' : 'confirm'; // The action sits in the footer, outside the form, so `form={formId}` associates the two. // That is what makes Enter in the field submit. Both guards are re-checked here because @@ -77,6 +82,76 @@ function DestructiveCard({ } }; + const confirmation = ( + <> + + {title} + {description} + + +
+ + {fieldLabel} + setTypedValue(event.target.value)} + /> + + {errorMessage} + + +
+
+ + + {cancelLabel} + + } + /> + + {actionLabel} + + + + ); + + const content = reverification ? ( + + {() => ( + <> + {confirmation} + {reverification ? : null} + + )} + + ) : ( + confirmation + ); + return ( - - {title} - {description} - - -
- - {fieldLabel} - setTypedValue(event.target.value)} - /> - - {errorMessage} - - -
-
- - - {cancelLabel} - - } - /> - - {actionLabel} - - + {content}
); } -function ControlledDestructive({ open, onOpenChange, trigger, ...props }: DestructiveControlledProps) { +function ControlledDestructive({ open, onOpenChange, trigger, reverification, ...props }: DestructiveControlledProps) { + const handleOpenChange: NonNullable = (...args) => { + const [nextOpen] = args; + if (!nextOpen && reverification?.phase === 'active') { + reverification.cancel(); + } + onOpenChange(...args); + }; + return ( {trigger ? : null} diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx index 9fcf416c523..1ac8eb7bbfb 100644 --- a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { Destructive } from '@clerk/mosaic/blocks/destructive'; import { Button } from '@clerk/mosaic/components/button'; import { Card } from '@clerk/mosaic/components/card'; import { Dialog } from '@clerk/mosaic/components/dialog'; @@ -263,6 +264,89 @@ function DialogHarness() { ); } +function DestructiveHarness() { + const [deleteAccount, reverification] = useReverificationWithState(() => mockDelete(true)); + const [open, setOpen] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [requestPending, setRequestPending] = useState(false); + const runRef = useRef(false); + + const continueDelete = () => { + if (runRef.current || reverification.phase !== 'inactive') { + return; + } + runRef.current = true; + setRequestPending(true); + setErrorMessage(null); + setSuccessMessage(null); + void (async () => { + try { + await resetMockDelete(); + await deleteAccount(); + setSuccessMessage('Mock delete completed. The account was not deleted.'); + setOpen(false); + } catch (error) { + if (isClerkRuntimeError(error) && error.code === 'request_already_in_progress') { + return; + } + await resetMockDelete(); + if (isReverificationCancelledError(error)) { + setErrorMessage(null); + setOpen(false); + return; + } + setErrorMessage(error instanceof Error ? error.message : 'Mock delete failed.'); + setOpen(true); + } finally { + runRef.current = false; + setRequestPending(false); + } + })(); + }; + + return ( +
+
+ +
+ {successMessage ?

{successMessage}

: null} + { + if (!next && requestPending && reverification.phase === 'inactive') { + return; + } + setOpen(next); + if (!next && reverification.phase !== 'retrying') { + setErrorMessage(null); + void resetMockDelete(); + } + }} + title='Delete account?' + description='This mock asks for verification, then waits before a fake success. The account is not deleted.' + fieldLabel='Type “Delete account” below to continue' + confirmationValue='Delete account' + actionLabel='Delete account' + onDelete={continueDelete} + isDeleting={requestPending} + errorMessage={errorMessage ?? undefined} + reverification={reverification} + /> +
+ ); +} + export default function ReverificationLivePage() { const { isLoaded, isSignedIn } = useUser(); const [demoKey, setDemoKey] = useState(0); @@ -313,13 +397,21 @@ export default function ReverificationLivePage() {

Dialog / Flow

- Reverification renders inside the dialog's card. This dialog showcases two different pending states: - the action retry can either be merged with the reverification pending state, or be its own flow step - after. + Reverification renders inside the dialog's card. The action retry can stay on the verification step, + or move to its own finalizing step.

{isLoaded && isSignedIn ? : null} +
+
+

Destructive

+

+ Destructive keeps its dialog and card while Reverification replaces the confirmation step. +

+
+ {isLoaded && isSignedIn ? : null} +
); diff --git a/packages/swingset/src/stories/destructive.mdx b/packages/swingset/src/stories/destructive.mdx index e2590a7464b..95d2b102b1b 100644 --- a/packages/swingset/src/stories/destructive.mdx +++ b/packages/swingset/src/stories/destructive.mdx @@ -58,6 +58,48 @@ A failed attempt leaves the dialog up. Pass the sentence the user should read as storyModule={Stories} /> +## Reverification + +Wrap the action with `useReverificationWithState` and pass the returned state to keep confirmation and verification in the same dialog and card. + +```tsx +import { useReverificationWithState } from '@clerk/mosaic/features/reverification'; +import { isReverificationCancelledError } from '@clerk/shared/error'; +import { useState } from 'react'; + +const [open, setOpen] = useState(false); +const [isDeleting, setIsDeleting] = useState(false); +const [errorMessage, setErrorMessage] = useState(); +const [runDelete, reverification] = useReverificationWithState(deleteAccount); + +const handleDelete = async () => { + setIsDeleting(true); + setErrorMessage(undefined); + try { + await runDelete(); + setOpen(false); + } catch (error) { + if (isReverificationCancelledError(error)) { + setOpen(false); + return; + } + setErrorMessage(error instanceof Error ? error.message : 'Delete failed.'); + } finally { + setIsDeleting(false); + } +}; + + void handleDelete()} + isDeleting={isDeleting} + errorMessage={errorMessage} + reverification={reverification} +/>; +``` + ## One block, many rows A table of projects has a Delete in every row. Create a handle, mount the block once after the table, and open it from any row with the project it is about. The copy props take a function of that payload, and `onDelete` receives it. The block owns `open`, the pending state, and the error: a resolved promise closes it, a rejected one keeps it open showing why. @@ -108,6 +150,7 @@ Controlled: | `onDelete` | `() => void` | — (required) | Asks the caller to run the action, once the typed phrase matches. | | `isDeleting` | `boolean` | `false` | Disables the field, renders the action pending, and ignores further submissions. | | `errorMessage` | `string` | — | Marks the field invalid and renders under it. | +| `reverification` | `ReverificationProps` | — | Renders reverification in the same dialog after the action requests it. | With a handle: From f101432870877ebc6f3dfeac9495e95246c24cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 23 Sep 2026 12:18:37 +0200 Subject: [PATCH 06/40] Hoist reverification model+controller into new useReverificationFlow hook useReverificationWithState also moves into this new hook. Consumers are now expected to call this hook and own the controller state higher up and pass that down into the Reverification component. This makes it easier for reverification state to survive across component unmounts, and makes it easier for consumers to stay on the previous pending step instead of showing a new separate reverification pending step. --- .../blocks/destructive/destructive.test.tsx | 11 ++-- .../src/blocks/destructive/destructive.tsx | 8 +-- .../reverification.composition.test.tsx | 58 +++++-------------- .../reverification.controller.test.tsx | 54 +++++++++++++++-- .../__tests__/reverification.model.test.tsx | 7 ++- .../__tests__/reverification.test.tsx | 50 ++++------------ .../use-reverification-with-state.test.tsx | 4 +- .../src/features/reverification/index.ts | 6 +- .../reverification.controller.ts | 34 +++++++---- .../reverification/reverification.model.tsx | 27 +++++---- .../reverification/reverification.tsx | 29 ++++++++-- .../reverification/reverification.types.ts | 2 +- .../use-reverification-with-state.ts | 14 ++--- .../app/(clerk)/live/reverification/page.tsx | 12 ++-- 14 files changed, 172 insertions(+), 144 deletions(-) diff --git a/packages/mosaic/src/blocks/destructive/destructive.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.test.tsx index 271c172d7c0..0317b137a7a 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.test.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.test.tsx @@ -33,10 +33,9 @@ function renderBlock(overrides: Partial = {}) { const confirmButton = () => screen.getByRole('button', { name: 'Delete account' }); const activeReverification = { + status: 'loading' as const, phase: 'active' as const, - complete: vi.fn(), - cancel: vi.fn(), - level: 'first_factor' as const, + onCancel: vi.fn(), }; describe('Destructive', () => { @@ -171,7 +170,7 @@ describe('Destructive', () => { renderBlock({ isDeleting: true, onOpenChange, - reverification: { ...activeReverification, cancel }, + reverification: { ...activeReverification, onCancel: cancel }, }); await user.keyboard('{Escape}'); @@ -196,7 +195,7 @@ describe('Destructive', () => { , ); @@ -206,7 +205,7 @@ describe('Destructive', () => { , ); diff --git a/packages/mosaic/src/blocks/destructive/destructive.tsx b/packages/mosaic/src/blocks/destructive/destructive.tsx index eebeb2a2bd0..96d9c77e73d 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.tsx @@ -8,7 +8,7 @@ import { Dialog } from '../../components/dialog'; import { Field } from '../../components/field'; import { Flow } from '../../components/flow'; import { Input } from '../../components/input'; -import { Reverification, type ReverificationProps } from '../../features/reverification'; +import { Reverification, type ReverificationController } from '../../features/reverification'; import { type FromPayload, resolveFromPayload as resolve } from '../../utils/resolve-from-payload'; import { useConfirmationController } from '../confirmation/confirmation.controller'; @@ -38,7 +38,7 @@ export interface DestructiveControlledProps { isDeleting?: boolean; /** Error message to display if the delete action fails */ errorMessage?: string; - reverification?: ReverificationProps; + reverification?: ReverificationController; } type DestructiveCardProps = Omit; @@ -170,8 +170,8 @@ function DestructiveCard({ function ControlledDestructive({ open, onOpenChange, trigger, reverification, ...props }: DestructiveControlledProps) { const handleOpenChange: NonNullable = (...args) => { const [nextOpen] = args; - if (!nextOpen && reverification?.phase === 'active') { - reverification.cancel(); + if (!nextOpen) { + reverification?.onCancel?.(); } onOpenChange(...args); }; diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx index 79858223aee..9f83caa897f 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx @@ -9,28 +9,12 @@ import { Reverification } from '../reverification'; import type { ReverificationController } from '../reverification.controller'; import type { ReverificationViewProps } from '../reverification.types'; -let controller: ReverificationController = { status: 'idle' }; - -vi.mock('../reverification.model', () => ({ - useReverificationModel: () => ({ - status: 'ready', - phase: 'active', - supportEmail: 'support@example.com', - start: vi.fn(), - prepare: vi.fn(), - attempt: vi.fn(), - finish: vi.fn(), - cancel: vi.fn(), - }), -})); - -vi.mock('../reverification.controller', () => ({ - useReverificationController: () => controller, -})); +let controller: ReverificationController = { status: 'idle', phase: 'inactive' }; function surface(overrides: Partial = {}): ReverificationController { return { status: 'ready', + phase: 'active', step: 'password', value: '', onValueChange: vi.fn(), @@ -71,12 +55,7 @@ function Nested({ step }: { step: 'confirm' | 'verify' | 'finalizing' }) {
- + @@ -96,7 +75,7 @@ function Nested({ step }: { step: 'confirm' | 'verify' | 'finalizing' }) { describe('reverification inside an outer flow', () => { it('keeps one dialog and one card, and nests a flow only while verifying', () => { - controller = { status: 'idle' }; + controller = { status: 'idle', phase: 'inactive' }; const { rerender } = render(); expect(screen.getAllByRole('dialog')).toHaveLength(1); @@ -105,7 +84,7 @@ describe('reverification inside an outer flow', () => { expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); expect(screen.getByText('Confirm the mock delete.')).toBeInTheDocument(); - controller = { status: 'loading' }; + controller = { status: 'loading', phase: 'active' }; rerender(); const flows = document.querySelectorAll('.cl-flow-root'); @@ -131,7 +110,7 @@ describe('reverification inside an outer flow', () => { expect(screen.getByLabelText('Password')).toBeInTheDocument(); expect(screen.queryByText('Confirm the mock delete.')).not.toBeInTheDocument(); - controller = { status: 'idle' }; + controller = { status: 'idle', phase: 'inactive' }; rerender(); expect(screen.getAllByRole('dialog')).toHaveLength(1); @@ -148,7 +127,7 @@ describe('reverification inside an outer flow', () => { expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); expect(screen.getByLabelText('Password')).toBeInTheDocument(); - controller = { status: 'idle' }; + controller = { status: 'idle', phase: 'inactive' }; rerender(); expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); @@ -170,20 +149,13 @@ describe('reverification inside an outer flow', () => { }); }); -const active = { - phase: 'active' as const, - complete: vi.fn(), - cancel: vi.fn(), - level: 'first_factor' as const, -}; - describe('reverification card states', () => { it('keeps one card from the pending state through a factor', () => { - controller = { status: 'loading' }; + controller = { status: 'loading', phase: 'active' }; const { container, rerender } = render( - + , ); @@ -201,7 +173,7 @@ describe('reverification card states', () => { rerender( - + , ); @@ -213,13 +185,13 @@ describe('reverification card states', () => { }); it('shows a dismiss button on the pending card inside a dialog', () => { - controller = { status: 'loading' }; + controller = { status: 'loading', phase: 'active' }; render( - + @@ -232,11 +204,11 @@ describe('reverification card states', () => { }); it('renders unavailable outside the factor flow, then mounts that flow in the same card', () => { - controller = { status: 'unavailable' }; + controller = { status: 'unavailable', phase: 'active' }; const { container, rerender } = render( - + , ); @@ -253,7 +225,7 @@ describe('reverification card states', () => { rerender( - + , ); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx index 8593e43f790..5f4ef4e3ef9 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx @@ -291,16 +291,23 @@ describe('reverificationMachine', () => { describe('useReverificationController', () => { it('is idle when reverification is not active', () => { const { result } = renderHook(() => useReverificationController(readyModel({ phase: 'inactive' }))); - expect(result.current).toEqual({ status: 'idle' }); + expect(result.current).toEqual({ status: 'idle', phase: 'inactive' }); }); it('is loading while the model is still waiting on Clerk', () => { + const cancel = vi.fn(); const loading: ReverificationModel = { status: 'loading', phase: 'active', + cancel, }; const { result } = renderHook(() => useReverificationController(loading)); expect(result.current.status).toBe('loading'); + if (result.current.status !== 'loading') { + throw new Error('expected loading'); + } + result.current.onCancel?.(); + expect(cancel).toHaveBeenCalledOnce(); }); it('is unavailable when start fails', async () => { @@ -423,6 +430,42 @@ describe('useReverificationController', () => { expect(finish).not.toHaveBeenCalled(); }); + it('cancels an in-flight attempt before it can finish', async () => { + const attempt = deferred(); + const finish = vi.fn(() => Promise.resolve()); + const cancel = vi.fn(); + const { result } = renderHook(() => + useReverificationController(readyModel({ attempt: () => attempt.promise, finish, cancel })), + ); + + 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(() => { + if (result.current.status === 'ready') { + result.current.onCancel?.(); + } + }); + expect(cancel).toHaveBeenCalledOnce(); + + act(() => { + attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); + }); + await waitFor(() => expect(cancel).toHaveBeenCalledTimes(2)); + expect(finish).not.toHaveBeenCalled(); + }); + it('stays on the current step pending while finish runs', async () => { const finish = deferred(); const { result } = renderHook(() => @@ -494,7 +537,7 @@ describe('useReverificationController', () => { await waitFor(() => expect(result.current.status).toBe('ready')); expect(start).toHaveBeenCalledOnce(); - rerender({ model: { status: 'loading', phase: 'active' } }); + rerender({ model: { status: 'loading', phase: 'active', cancel: vi.fn() } }); expect(result.current.status).toBe('ready'); if (result.current.status === 'ready') { expect(result.current.step).toBe('password'); @@ -532,7 +575,7 @@ describe('useReverificationController', () => { } }); - rerender({ model: { status: 'loading', phase: 'active' } }); + rerender({ model: { status: 'loading', phase: 'active', cancel: vi.fn() } }); expect(result.current.status).toBe('ready'); if (result.current.status === 'ready') { expect(result.current.step).toBe('password'); @@ -555,7 +598,7 @@ describe('useReverificationController', () => { await waitFor(() => expect(result.current.status).toBe('ready')); rerender({ model: readyModel({ start, phase: 'inactive' }) }); - expect(result.current).toEqual({ status: 'idle' }); + expect(result.current).toEqual({ status: 'idle', phase: 'inactive' }); rerender({ model: readyModel({ start, phase: 'active' }) }); await waitFor(() => expect(result.current.status).toBe('ready')); @@ -750,6 +793,7 @@ describe('useReverificationController', () => { if (result.current.status === 'ready') { expect(result.current.step).toBe('password'); expect(result.current.isPending).toBe(true); + expect(result.current.onCancel).toBeUndefined(); } expect(start).toHaveBeenCalledOnce(); @@ -766,6 +810,6 @@ describe('useReverificationController', () => { expect(cancel).not.toHaveBeenCalled(); rerender({ model: { ...active, phase: 'inactive' } }); - expect(result.current).toEqual({ status: 'idle' }); + expect(result.current).toEqual({ status: 'idle', phase: 'inactive' }); }); }); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx index ec7bdab059f..06d2110f9cc 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx @@ -101,9 +101,14 @@ describe('useReverificationModel', () => { it('is loading until the session and environment are both present', () => { session = null; - const { result } = renderHook(() => useReverificationModel(activeProps())); + const cancel = vi.fn(); + const { result } = renderHook(() => useReverificationModel({ ...activeProps(), cancel })); expect(result.current.status).toBe('loading'); expect(result.current.phase).toBe('active'); + if (result.current.status === 'loading') { + result.current.cancel(); + } + expect(cancel).toHaveBeenCalledOnce(); }); it('is loading until supportEmail is resolved', () => { diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx index f8f6946ccae..22342fb0356 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx @@ -3,29 +3,9 @@ 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', - phase: 'active', - 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, -})); +let controller: ReverificationController = { status: 'idle', phase: 'inactive' }; vi.mock('../reverification.view', () => ({ ReverificationPending: () => , @@ -33,16 +13,10 @@ vi.mock('../reverification.view', () => ({ ReverificationView: ({ step }: { step: string }) => {step}, })); -const active = { - phase: 'active' as const, - complete: vi.fn(), - cancel: vi.fn(), - level: 'first_factor' as const, -}; - function ready(overrides: Partial = {}): ReverificationController { return { status: 'ready', + phase: 'active', step: 'password', value: '', onValueChange: vi.fn(), @@ -62,34 +36,34 @@ function ready(overrides: Partial = {}): Reverification describe('Reverification', () => { it('renders nothing while reverification is inactive', () => { - controller = { status: 'idle' }; - const { container } = render(); + controller = { status: 'idle', phase: 'inactive' }; + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); it('renders pending, unavailable, or the factor view', () => { - controller = { status: 'loading' }; - const { rerender } = render(); + controller = { status: 'loading', phase: 'active' }; + const { rerender } = render(); expect(screen.getByTestId('pending')).toBeInTheDocument(); expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - controller = { status: 'unavailable' }; - rerender(); + controller = { status: 'unavailable', phase: 'active' }; + rerender(); expect(screen.getByTestId('unavailable')).toBeInTheDocument(); expect(screen.queryByTestId('view')).not.toBeInTheDocument(); controller = ready({ step: 'otp', otpChannel: 'email' }); - rerender(); + rerender(); expect(screen.getByTestId('view')).toHaveTextContent('otp'); }); it('keeps the factor flow mounted through retrying', () => { controller = ready({ isPending: true }); - const { rerender } = render(); + const { rerender } = render(); expect(screen.getByTestId('view')).toHaveTextContent('password'); - controller = { status: 'idle' }; - rerender(); + controller = { status: 'idle', phase: 'inactive' }; + rerender(); expect(screen.queryByTestId('view')).not.toBeInTheDocument(); }); }); diff --git a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx index 641381fdfad..bd72cf8c55f 100644 --- a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -5,7 +5,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { deferred, tick } from '../../../machines/__tests__/test-utils'; -import type { ReverificationProps } from '../reverification.types'; +import type { ReverificationState } from '../reverification.types'; import { useReverificationWithState } from '../use-reverification-with-state'; type NeedsReverificationParameters = { @@ -59,7 +59,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { }; }); -function assertActive(state: ReverificationProps): asserts state is Extract { +function assertActive(state: ReverificationState): asserts state is Extract { expect(state.phase).toBe('active'); if (state.phase !== 'active') { throw new Error('expected active reverification'); diff --git a/packages/mosaic/src/features/reverification/index.ts b/packages/mosaic/src/features/reverification/index.ts index 4ac85e89457..dcc2bf3dd64 100644 --- a/packages/mosaic/src/features/reverification/index.ts +++ b/packages/mosaic/src/features/reverification/index.ts @@ -1,8 +1,10 @@ -export { Reverification } from './reverification'; +export { Reverification, useReverificationFlow } from './reverification'; +export type { ReverificationController } from './reverification.controller'; +export type { UseReverificationFlowResult } from './reverification'; export type { ReverificationMethod, ReverificationOtpChannel, - ReverificationProps, + ReverificationState, ReverificationStage, ReverificationStep, ReverificationStrategy, diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index 2d3bf25f1cd..f3f6e5b10b1 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -8,16 +8,21 @@ import type { ReverificationModel, ReverificationReadyModel } from './reverifica import type { ReverificationMethod, ReverificationResult, + ReverificationState, ReverificationStep, ReverificationViewProps, } from './reverification.types'; import { needsPrepare, otpChannelFor } from './reverification.utils'; export type ReverificationController = - | { status: 'idle' } - | { status: 'loading' } - | { status: 'unavailable' } - | ({ status: 'ready' } & ReverificationViewProps); + | { status: 'idle'; phase: 'inactive' } + | { status: 'loading'; phase: Exclude; onCancel?: () => void } + | { status: 'unavailable'; phase: Exclude; onCancel?: () => void } + | ({ + status: 'ready'; + phase: Exclude; + onCancel?: () => void; + } & ReverificationViewProps); type OverlayFrom = 'factor' | 'method-picker'; @@ -396,8 +401,8 @@ function viewStep(value: string, method: ReverificationMethod | null): Reverific * - status: 'loading' is the pending card rendered in place, before a factor exists * - status: 'ready' && isPending is the inline pending state of the current factor * - * Root reverification `phase` is an input, not part of this return. The factor machine runs - * while `active` and resets only when `phase` returns to `inactive`. `retrying` keeps the last factor pending. + * `phase` is part of this return. The factor machine runs while `active` and resets only when + * `phase` returns to `inactive`. `retrying` keeps the last factor pending. */ export function useReverificationController(model: ReverificationModel): ReverificationController { const ready = model.status === 'ready' ? model : null; @@ -428,6 +433,13 @@ export function useReverificationController(model: ReverificationModel): Reverif }, [countingDown, resendAvailableAt]); const reverificationPhase = model.phase; + const onCancel = + reverificationPhase === 'active' + ? () => { + send({ type: 'RESET' }); + model.cancel(); + } + : undefined; const needsStart = reverificationPhase === 'active' && Boolean(ready) && snapshot.value === 'inactive'; const needsReset = reverificationPhase === 'inactive' && snapshot.value !== 'inactive'; useEffect(() => { @@ -439,14 +451,14 @@ export function useReverificationController(model: ReverificationModel): Reverif }, [needsStart, needsReset, send]); if (reverificationPhase === 'inactive') { - return { status: 'idle' }; + return { status: 'idle', phase: reverificationPhase }; } const { context } = snapshot; const activeMethod = context.activeMethod; if (snapshot.value === 'unavailable') { - return { status: 'unavailable' }; + return { status: 'unavailable', phase: reverificationPhase, onCancel }; } // If the action is retrying after success and the component is rendered, we stay @@ -458,9 +470,9 @@ export function useReverificationController(model: ReverificationModel): Reverif if (!step) { if (snapshot.value === 'inactive' || snapshot.value === 'starting' || snapshot.value === 'done') { - return { status: 'loading' }; + return { status: 'loading', phase: reverificationPhase, onCancel }; } - return { status: 'unavailable' }; + return { status: 'unavailable', phase: reverificationPhase, onCancel }; } // If we are currently on the alternative methods screen and preparing a factor, activeMethod will @@ -471,6 +483,7 @@ export function useReverificationController(model: ReverificationModel): Reverif return { status: 'ready', + phase: reverificationPhase, step, direction: context.direction, value: context.inputValue, @@ -491,6 +504,7 @@ export function useReverificationController(model: ReverificationModel): Reverif onSelectMethod: (id: string) => send({ type: 'SELECT_METHOD', id }), otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, onResend: () => send({ type: 'RESEND' }), + onCancel, 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 diff --git a/packages/mosaic/src/features/reverification/reverification.model.tsx b/packages/mosaic/src/features/reverification/reverification.model.tsx index 9b14c62fabe..b7c87b70745 100644 --- a/packages/mosaic/src/features/reverification/reverification.model.tsx +++ b/packages/mosaic/src/features/reverification/reverification.model.tsx @@ -13,15 +13,15 @@ import { useMosaicSupportEmail } from '../../hooks/useMosaicSupportEmail'; import type { ReverificationMethod, ReverificationPreparableMethod, - ReverificationProps, ReverificationResult, ReverificationStage, + ReverificationState, } from './reverification.types'; import { pickStartingMethod } from './reverification.utils'; export type ReverificationReadyModel = { status: 'ready'; - phase: ReverificationProps['phase']; + phase: ReverificationState['phase']; supportEmail: string; start: () => Promise; prepare: (method: ReverificationPreparableMethod) => Promise; @@ -30,7 +30,9 @@ export type ReverificationReadyModel = { cancel: () => void; }; -export type ReverificationModel = { status: 'loading'; phase: ReverificationProps['phase'] } | ReverificationReadyModel; +export type ReverificationModel = + | { status: 'loading'; phase: ReverificationState['phase']; cancel: () => void } + | ReverificationReadyModel; function toError(error: unknown): Error { if (isClerkAPIResponseError(error)) { @@ -114,18 +116,21 @@ function toResult( }; } -export function useReverificationModel(props: ReverificationProps): ReverificationModel { +export function useReverificationModel(reverificationState: ReverificationState): ReverificationModel { const { session } = useSession(); const clerk = useClerk(); const environment = useMosaicEnvironment(); const supportEmail = useMosaicSupportEmail(); - const phase = props.phase; - const level = props.phase === 'active' ? props.level : undefined; - const cancel = props.phase === 'active' ? props.cancel : undefined; - const complete = props.phase === 'active' ? props.complete : undefined; + const phase = reverificationState.phase; + const level = reverificationState.phase === 'active' ? reverificationState.level : undefined; + const cancel = reverificationState.phase === 'active' ? reverificationState.cancel : undefined; + const complete = reverificationState.phase === 'active' ? reverificationState.complete : undefined; + const cancelVerification = () => { + cancel?.(); + }; if (!session || !environment || supportEmail === undefined) { - return { status: 'loading', phase }; + return { status: 'loading', phase, cancel: cancelVerification }; } const webAuthnSupported = isWebAuthnSupported(); @@ -145,9 +150,7 @@ export function useReverificationModel(props: ReverificationProps): Reverificati throw toError(error); } }, - cancel: () => { - cancel?.(); - }, + cancel: cancelVerification, prepare: async method => { try { switch (method.strategy) { diff --git a/packages/mosaic/src/features/reverification/reverification.tsx b/packages/mosaic/src/features/reverification/reverification.tsx index 05f1958f0f2..04cc862bafc 100644 --- a/packages/mosaic/src/features/reverification/reverification.tsx +++ b/packages/mosaic/src/features/reverification/reverification.tsx @@ -1,12 +1,30 @@ -import { useReverificationController } from './reverification.controller'; +import { type ReverificationController, useReverificationController } from './reverification.controller'; import { useReverificationModel } from './reverification.model'; -import type { ReverificationProps } from './reverification.types'; import { ReverificationPending, ReverificationUnavailable, ReverificationView } from './reverification.view'; +import { + type ReverificationFetcher, + useReverificationWithState, + type UseReverificationWithStateOptions, + type UseReverificationWithStateResult, +} from './use-reverification-with-state'; -export function Reverification(props: ReverificationProps) { - const model = useReverificationModel(props); +export type UseReverificationFlowResult = readonly [ + UseReverificationWithStateResult[0], + ReverificationController, +]; + +export function useReverificationFlow( + fetcher: F, + options?: UseReverificationWithStateOptions, +): UseReverificationFlowResult { + const [wrappedFetcher, reverificationState] = useReverificationWithState(fetcher, options); + const model = useReverificationModel(reverificationState); const controller = useReverificationController(model); + return [wrappedFetcher, controller]; +} + +export function Reverification(controller: ReverificationController) { if (controller.status === 'idle') { return null; } @@ -17,6 +35,5 @@ export function Reverification(props: ReverificationProps) { return ; } - const { status: _status, ...viewProps } = controller; - return ; + return ; } diff --git a/packages/mosaic/src/features/reverification/reverification.types.ts b/packages/mosaic/src/features/reverification/reverification.types.ts index 94d690320f3..3fcaa6b43f2 100644 --- a/packages/mosaic/src/features/reverification/reverification.types.ts +++ b/packages/mosaic/src/features/reverification/reverification.types.ts @@ -42,7 +42,7 @@ export type ReverificationViewProps = ReverificationViewFields & { step: ReverificationStep; }; -export type ReverificationProps = +export type ReverificationState = | { phase: 'inactive' } | { phase: 'active'; complete: () => void; cancel: () => void; level: SessionVerificationLevel | undefined } | { phase: 'retrying' }; diff --git a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts index aef1356d39c..7a7e8d60501 100644 --- a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts +++ b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts @@ -2,17 +2,17 @@ import { ClerkRuntimeError } from '@clerk/shared/error'; import { useReverification, useSafeLayoutEffect, useSession } from '@clerk/shared/react'; import { useCallback, useEffect, useRef, useState } from 'react'; -import type { ReverificationProps } from './reverification.types'; +import type { ReverificationState } from './reverification.types'; -type Fetcher = (...args: any[]) => Promise | undefined; +export type ReverificationFetcher = (...args: any[]) => Promise | undefined; type UseReverificationOptions = NonNullable[1]>; export type UseReverificationWithStateOptions = Omit; -export type UseReverificationWithStateResult = readonly [ +export type UseReverificationWithStateResult = readonly [ ReturnType>, - ReverificationProps, + ReverificationState, ]; type RuntimeOperation = @@ -45,7 +45,7 @@ function requestAlreadyInProgressError(): ClerkRuntimeError { * This wraps useReverification, but does not pop the default UI and instead manages * the lifecycle, use the returned `phase` and callbacks to build your custom UI. * - * This is meant to be paired with the Mosaic component. + * useReverificationFlow pairs this lifecycle with . * * In contrast to useReverification, the returned handler is only allowed to run * in serial. Any new calls that happen while it's still pending will throw @@ -58,13 +58,13 @@ function requestAlreadyInProgressError(): ClerkRuntimeError { // constraint for extra safeguards here. // If we ever want to make this hook public API, we might want to reconsider the single-flight // behavior by first fixing the useReverification hook. -export function useReverificationWithState( +export function useReverificationWithState( fetcher: F, options?: UseReverificationWithStateOptions, ): UseReverificationWithStateResult { const { session } = useSession(); // The return is observable and needs to be driven by React state - const [reverificationState, setReverificationState] = useState({ phase: 'inactive' }); + const [reverificationState, setReverificationState] = useState({ phase: 'inactive' }); // State updates are not immediate and since parallel requests can resolve before observing // those state changes, the internal state is driven by a ref const runtimeRef = useRef({ diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx index 1ac8eb7bbfb..2b4c29eae86 100644 --- a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -5,7 +5,7 @@ import { Button } from '@clerk/mosaic/components/button'; import { Card } from '@clerk/mosaic/components/card'; import { Dialog } from '@clerk/mosaic/components/dialog'; import { Flow, type FlowDirection } from '@clerk/mosaic/components/flow'; -import { Reverification, useReverificationWithState } from '@clerk/mosaic/features/reverification'; +import { Reverification, useReverificationFlow } from '@clerk/mosaic/features/reverification'; import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; import { useUser } from '@clerk/nextjs'; import { isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; @@ -40,7 +40,7 @@ function CardHarness() { const [status, setStatus] = useState<'idle' | 'success' | 'cancelled' | 'error'>('idle'); const [message, setMessage] = useState(null); const [requestPending, setRequestPending] = useState(false); - const [deleteAccount, reverification] = useReverificationWithState(() => mockDelete(false)); + const [deleteAccount, reverification] = useReverificationFlow(() => mockDelete(false)); const busy = requestPending || reverification.phase !== 'inactive'; return ( @@ -95,7 +95,7 @@ type Presentation = 'retain' | 'replace'; type OuterStep = 'confirm' | 'verify' | 'finalizing'; function DialogHarness() { - const [deleteAccount, reverification] = useReverificationWithState(() => mockDelete(true)); + const [deleteAccount, reverification] = useReverificationFlow(() => mockDelete(true)); const [open, setOpen] = useState(false); const [presentation, setPresentation] = useState('retain'); const [errorMessage, setErrorMessage] = useState(null); @@ -179,9 +179,7 @@ function DialogHarness() { if (requestPending && reverification.phase === 'inactive') { return; } - if (reverification.phase === 'active') { - reverification.cancel(); - } + reverification.onCancel?.(); setOpen(false); if (reverification.phase !== 'retrying') { setErrorMessage(null); @@ -265,7 +263,7 @@ function DialogHarness() { } function DestructiveHarness() { - const [deleteAccount, reverification] = useReverificationWithState(() => mockDelete(true)); + const [deleteAccount, reverification] = useReverificationFlow(() => mockDelete(true)); const [open, setOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [successMessage, setSuccessMessage] = useState(null); From 0493692d308830757a3275535f4feedbd16ea01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 23 Sep 2026 12:19:18 +0200 Subject: [PATCH 07/40] Remove separate reverification pending step from destructive --- .../blocks/destructive/destructive.test.tsx | 40 +++++++++++++++++-- .../src/blocks/destructive/destructive.tsx | 7 ++-- .../app/(clerk)/live/reverification/page.tsx | 6 ++- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/mosaic/src/blocks/destructive/destructive.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.test.tsx index 0317b137a7a..4d41d45d2ad 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.test.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../components/button'; import { Menu } from '../../components/menu'; +import type { ReverificationController } from '../../features/reverification'; import { MosaicProvider } from '../../MosaicProvider'; import type { DestructiveControlledProps, DestructiveHandleProps } from './destructive'; import { Destructive } from './destructive'; @@ -32,12 +33,32 @@ function renderBlock(overrides: Partial = {}) { } const confirmButton = () => screen.getByRole('button', { name: 'Delete account' }); -const activeReverification = { +const startingReverification = { status: 'loading' as const, phase: 'active' as const, onCancel: vi.fn(), }; +function readyReverification(phase: 'active' | 'retrying'): ReverificationController { + return { + status: 'ready', + phase, + step: 'password', + value: '', + onValueChange: () => {}, + isPending: phase === 'retrying', + onSubmit: () => {}, + onShowMethods: () => {}, + onShowHelp: () => {}, + onBack: () => {}, + onEmailSupport: () => {}, + onResend: () => {}, + canResend: false, + methods: [], + onSelectMethod: () => {}, + }; +} + describe('Destructive', () => { it('renders nothing until the caller opens it', () => { renderBlock({ open: false }); @@ -153,8 +174,17 @@ describe('Destructive', () => { expect(onDelete).not.toHaveBeenCalled(); }); + it('keeps the delete confirmation pending while verification is starting', () => { + renderBlock({ isDeleting: true, reverification: startingReverification }); + + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); + expect(screen.getByRole('textbox')).toBeDisabled(); + expect(confirmButton()).toHaveAttribute('aria-busy', 'true'); + expect(screen.queryByTestId('reverification')).not.toBeInTheDocument(); + }); + it('shows reverification in the same dialog and card', () => { - renderBlock({ isDeleting: true, reverification: activeReverification }); + renderBlock({ isDeleting: true, reverification: readyReverification('active') }); expect(screen.getAllByRole('dialog')).toHaveLength(1); expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); @@ -170,7 +200,7 @@ describe('Destructive', () => { renderBlock({ isDeleting: true, onOpenChange, - reverification: { ...activeReverification, onCancel: cancel }, + reverification: { ...startingReverification, onCancel: cancel }, }); await user.keyboard('{Escape}'); @@ -195,10 +225,12 @@ describe('Destructive', () => { , ); + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); + expect(screen.getByTestId('reverification')).toHaveTextContent('retrying'); rerender( diff --git a/packages/mosaic/src/blocks/destructive/destructive.tsx b/packages/mosaic/src/blocks/destructive/destructive.tsx index 96d9c77e73d..3ebdb03d5f8 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.tsx @@ -69,7 +69,8 @@ function DestructiveCard({ }, [open]); const isConfirmed = typedValue === confirmationValue; - const step = reverification && reverification.phase !== 'inactive' ? 'verify' : 'confirm'; + const step = + reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm'; // The action sits in the footer, outside the form, so `form={formId}` associates the two. // That is what makes Enter in the field submit. Both guards are re-checked here because @@ -170,8 +171,8 @@ function DestructiveCard({ function ControlledDestructive({ open, onOpenChange, trigger, reverification, ...props }: DestructiveControlledProps) { const handleOpenChange: NonNullable = (...args) => { const [nextOpen] = args; - if (!nextOpen) { - reverification?.onCancel?.(); + if (!nextOpen && reverification && reverification.status !== 'idle') { + reverification.onCancel?.(); } onOpenChange(...args); }; diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx index 2b4c29eae86..4dd6a25508d 100644 --- a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -108,7 +108,7 @@ function DialogHarness() { const nextStep: OuterStep | null = reverification.phase === 'retrying' && presentation === 'replace' ? 'finalizing' - : reverification.phase === 'active' || reverification.phase === 'retrying' + : reverification.status === 'ready' || reverification.status === 'unavailable' ? 'verify' : null; @@ -179,7 +179,9 @@ function DialogHarness() { if (requestPending && reverification.phase === 'inactive') { return; } - reverification.onCancel?.(); + if (reverification.status !== 'idle') { + reverification.onCancel?.(); + } setOpen(false); if (reverification.phase !== 'retrying') { setErrorMessage(null); From c4ec888ef45504ffd0dad8df79dcd65d60e836bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 24 Sep 2026 14:03:04 +0200 Subject: [PATCH 08/40] Add optional destructiveController --- .../destructive/destructive.controller.ts | 81 +++++++++++++++++++ .../src/blocks/destructive/destructive.tsx | 5 +- 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 packages/mosaic/src/blocks/destructive/destructive.controller.ts diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.ts new file mode 100644 index 00000000000..aac8d353e86 --- /dev/null +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.ts @@ -0,0 +1,81 @@ +import { isReverificationCancelledError } from '@clerk/shared/error'; +import { useState } from 'react'; + +import type { ReverificationController } from '../../features/reverification'; +import type { DestructiveProps } from './destructive'; + +export type DestructiveController = Pick< + DestructiveProps, + 'open' | 'isDeleting' | 'errorMessage' | 'onOpenChange' | 'reverification' +> & { + onDelete: () => Promise; + openDestructiveDialog: () => void; +}; + +type DestructiveState = + | { status: 'closed' } + | { status: 'open-needs-confirmation' } + | { status: 'open-pending' } + | { status: 'open-error'; errorMessage: string }; + +/** Optional controller for use with Destructive block */ +export function useDestructiveController({ + onDelete, + reverification, +}: { + onDelete: () => Promise; + reverification?: ReverificationController; +}): DestructiveController { + const [destructiveState, setDestructiveState] = useState({ status: 'closed' }); + const { status } = destructiveState; + + const openDestructiveDialog = () => { + if (status === 'closed') { + setDestructiveState({ status: 'open-needs-confirmation' }); + } + }; + + const isDeleting = status === 'open-pending'; + + return { + open: status !== 'closed', + isDeleting, + errorMessage: status === 'open-error' ? destructiveState.errorMessage : undefined, + reverification, + openDestructiveDialog, + onDelete: async () => { + if (status === 'open-needs-confirmation' || status === 'open-error') { + setDestructiveState({ status: 'open-pending' }); + try { + await onDelete(); + // TODO: It's possible this might give a flash of the confirm page after + // reverification. + // While we do stay on the current page throughout the retry, the + // useReverificationWithState `.finally` runs first and sets phase + // to inactive. That resets the reverification state and this + // dialog could flash to the first screen. Unconfirmed. + setDestructiveState({ status: 'closed' }); + } catch (error: unknown) { + if (isReverificationCancelledError(error)) { + setDestructiveState({ status: 'closed' }); + return; + } + // TODO: Better error handling, localization + setDestructiveState({ status: 'open-error', errorMessage: 'Something went wrong' }); + } + } + }, + onOpenChange: nextIsOpen => { + const pendingForReverification = reverification?.phase === 'active'; + if (!nextIsOpen && isDeleting && !pendingForReverification) { + return; + } + + if (nextIsOpen) { + setDestructiveState({ status: 'open-needs-confirmation' }); + } else { + setDestructiveState({ status: 'closed' }); + } + }, + }; +} diff --git a/packages/mosaic/src/blocks/destructive/destructive.tsx b/packages/mosaic/src/blocks/destructive/destructive.tsx index 3ebdb03d5f8..40a4867e676 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.tsx @@ -33,7 +33,7 @@ export interface DestructiveControlledProps { /** Text of the cancel button (default: "Cancel") */ cancelLabel?: string; /** Callback when delete is confirmed, by button or by Enter */ - onDelete: () => void; + onDelete: () => Promise | void; /** Whether the delete action is in progress */ isDeleting?: boolean; /** Error message to display if the delete action fails */ @@ -79,7 +79,8 @@ function DestructiveCard({ const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (isConfirmed && !isDeleting) { - onDelete(); + // Errors are meant to be handled on the outside, so we swallow them here + onDelete()?.catch(() => { }); } }; From e4ffe6df03d634930e7efb0a13062e7c160a84ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 24 Sep 2026 14:07:57 +0200 Subject: [PATCH 09/40] Wire up delete account --- .../destructive/destructive.controller.ts | 4 +- .../user-profile-delete-section.controller.ts | 92 ------------------- .../user-profile-delete-section.tsx | 54 ++++++++++- .../user-profile-delete-section.view.tsx | 23 +---- .../user-profile-profile-panel.view.tsx | 2 +- 5 files changed, 55 insertions(+), 120 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.ts index aac8d353e86..f6db12e9eab 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.ts @@ -2,10 +2,10 @@ import { isReverificationCancelledError } from '@clerk/shared/error'; import { useState } from 'react'; import type { ReverificationController } from '../../features/reverification'; -import type { DestructiveProps } from './destructive'; +import type { DestructiveControlledProps } from './destructive'; export type DestructiveController = Pick< - DestructiveProps, + DestructiveControlledProps, 'open' | 'isDeleting' | 'errorMessage' | 'onOpenChange' | 'reverification' > & { onDelete: () => Promise; diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts deleted file mode 100644 index 0b9bcd3d0ad..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { setup } from '../../../machine/setup'; -import { useMachine } from '../../../machine/useMachine'; - -export interface UserProfileDeleteSectionContext { - /** Deletes the account. Injected by the wrapper from its `onDelete` prop. */ - deleteAccount: () => Promise; - /** Why the last attempt failed. */ - errorMessage: string | undefined; -} - -export type UserProfileDeleteSectionEvent = { type: 'OPEN' } | { type: 'CONFIRM' } | { type: 'CANCEL' }; - -const { createMachine, assign, fromPromise } = setup(); - -/** - * The delete-account flow. `deleting` is the state that decides the dialog's fate: the - * account is gone on success, so the machine finishes in `deleted` and never reopens, - * while a failure drops back to `confirming` with the reason to render. - * - * The typed confirmation phrase is not here. It is a half-formed string that only the - * `Destructive` block can use, so the block keeps it. - */ -export const userProfileDeleteSectionMachine = createMachine({ - id: 'deleteAccount', - initial: 'idle', - context: { - deleteAccount: async () => {}, - errorMessage: undefined, - }, - states: { - idle: { on: { OPEN: 'confirming' } }, - confirming: { - on: { - CONFIRM: 'deleting', - CANCEL: { target: 'idle', actions: assign(() => ({ errorMessage: undefined })) }, - }, - }, - deleting: { - invoke: fromPromise(context => context.deleteAccount(), { - onDone: 'deleted', - onError: { - target: 'confirming', - actions: assign((_, event) => ({ - errorMessage: - event.error instanceof Error ? event.error.message : 'Something went wrong. Please try again.', - })), - }, - }), - }, - deleted: { type: 'final' }, - }, -}); - -export interface UserProfileDeleteSectionControllerOptions { - /** - * Deletes the account. Resolve and the flow finishes; reject with an `Error` and it returns to - * the confirmation step with that message. - */ - onDelete: () => Promise; -} - -export interface UserProfileDeleteSectionController { - /** Whether the confirmation dialog is open */ - isOpen: boolean; - /** Opens or closes the confirmation dialog */ - onOpenChange: (open: boolean) => void; - /** Starts the delete */ - onConfirm: () => void; - /** Whether the delete is in progress */ - isDeleting: boolean; - /** Why the last attempt failed */ - errorMessage: string | undefined; -} - -/** - * Drives the delete-account flow and hands the view plain props. A machine backs it because the - * flow has an async step, an error path back to a previous step, and a terminal state that must - * never reopen. A simpler section is free to hold its state in `useState`; the view cannot tell. - */ -export function useUserProfileDeleteSectionController({ - onDelete, -}: UserProfileDeleteSectionControllerOptions): UserProfileDeleteSectionController { - const [snapshot, send] = useMachine(userProfileDeleteSectionMachine, { context: { deleteAccount: onDelete } }); - - return { - isOpen: snapshot.value === 'confirming' || snapshot.value === 'deleting', - onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), - onConfirm: () => send({ type: 'CONFIRM' }), - isDeleting: snapshot.value === 'deleting', - errorMessage: snapshot.context.errorMessage, - }; -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx index befe806aad3..d481dc74f2e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx @@ -1,11 +1,55 @@ -import type { UserProfileDeleteSectionControllerOptions } from './user-profile-delete-section.controller'; -import { useUserProfileDeleteSectionController } from './user-profile-delete-section.controller'; +import { useClerk, useUser } from '@clerk/shared/react'; + +import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; +import { useReverificationFlow } from '../../reverification'; import { UserProfileDeleteSectionView } from './user-profile-delete-section.view'; -export type UserProfileDeleteSectionProps = UserProfileDeleteSectionControllerOptions; +export type UserProfileDeleteSectionProps = { + fallback?: React.ReactNode; +} export function UserProfileDeleteSection(props: UserProfileDeleteSectionProps) { - const controller = useUserProfileDeleteSectionController(props); + // -- Model -- + const clerk = useClerk(); + const { setActive, client } = clerk; + const { isLoaded, user } = useUser(); + + // Because this is wrapped in reverification, it's important that it doesn't catch + // reverification hints or cancellation errors + const deleteAccount = async () => { + // Should never happen, just an extra guard + if (!user?.delete || !user?.deleteSelfEnabled) { + return undefined; + } + + await user.delete(); + + const hasOtherSessions = client.signedInSessions.filter(s => s.user?.id !== user?.id).length > 0; + const redirectUrl = hasOtherSessions ? + clerk.buildAfterMultiSessionSingleSignOutUrl() : + clerk.buildAfterSignOutUrl(); + + return await setActive({ + session: null, + redirectUrl, + }); + } + + // -- Controllers -- + const [deleteAccountWithReverification, reverificationState] = useReverificationFlow(deleteAccount); + const destructiveController = useDestructiveController({ + onDelete: deleteAccountWithReverification, + reverification: reverificationState, + }) + + // -- View -- + if (!isLoaded) { + return props.fallback ?? null; + } + + if (!user?.deleteSelfEnabled) { + return null; + } - return ; + return ; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx index 65dbb2c3b04..d5ecee39f4a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx @@ -1,23 +1,10 @@ import { Destructive } from '../../../blocks/destructive'; +import type { DestructiveController } from '../../../blocks/destructive/destructive.controller'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { fill, useMessages } from '../../../localization'; -export interface UserProfileDeleteSectionViewProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; - isDeleting: boolean; - errorMessage: string | undefined; -} - -export function UserProfileDeleteSectionView({ - isOpen, - onOpenChange, - onConfirm, - isDeleting, - errorMessage, -}: UserProfileDeleteSectionViewProps) { +export function UserProfileDeleteSectionView(destructiveProps: DestructiveController) { const m = useMessages('userProfileDeleteSection'); return ( @@ -35,8 +22,7 @@ export function UserProfileDeleteSectionView({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index 3b8219f84af..1449f145a47 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -140,7 +140,7 @@ export function UserProfileProfilePanelView({ onSetPrimary={onSetPrimaryWeb3Wallet} /> {/* TODO: This is temporary, a view should not render a connected component */} - {onDeleteAccount ? : null} + {onDeleteAccount ? : null} ); From 5af5abb020b8b72f2773d58e5862b74777e8b2a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 24 Sep 2026 14:09:21 +0200 Subject: [PATCH 10/40] Add delete account live swingset story --- .../swingset/src/app/(clerk)/live-sidebar.tsx | 5 ++- .../app/(clerk)/live/delete-account/page.tsx | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 packages/swingset/src/app/(clerk)/live/delete-account/page.tsx diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index 92d6c86be04..1ce15a7dd11 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -18,7 +18,10 @@ import { SidebarRail, } from '@/components/ui/sidebar'; -const flows = [{ title: 'Reverification', href: '/live/reverification' }]; +const flows = [ + { title: 'Delete account', href: '/live/delete-account' }, + { title: 'Reverification', href: '/live/reverification' }, +]; function flowIsActive(pathname: string, href: string) { return pathname === href || pathname.startsWith(`${href}/`); diff --git a/packages/swingset/src/app/(clerk)/live/delete-account/page.tsx b/packages/swingset/src/app/(clerk)/live/delete-account/page.tsx new file mode 100644 index 00000000000..11fb20a9017 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/delete-account/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { UserProfileDeleteSection } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function DeleteAccountLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Delete account

+

+ Deletes the signed-in account. The section stays hidden when the instance does not allow it. +

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to delete this account. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} From b7fe8a45a5dbcaa4ea31c96ecbd0cd89e1dc8bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 24 Sep 2026 14:11:00 +0200 Subject: [PATCH 11/40] Make destructive swingset story use new controller --- .../src/stories/destructive.stories.tsx | 106 ++++++------------ .../user-profile-delete-section.stories.tsx | 4 +- 2 files changed, 37 insertions(+), 73 deletions(-) diff --git a/packages/swingset/src/stories/destructive.stories.tsx b/packages/swingset/src/stories/destructive.stories.tsx index 168a8221f82..df33aecc1ae 100644 --- a/packages/swingset/src/stories/destructive.stories.tsx +++ b/packages/swingset/src/stories/destructive.stories.tsx @@ -1,4 +1,5 @@ import { Destructive } from '@clerk/mosaic/blocks/destructive'; +import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import { Button } from '@clerk/mosaic/components/button'; import { Menu } from '@clerk/mosaic/components/menu'; import React from 'react'; @@ -20,86 +21,49 @@ export const meta: StoryMeta = { // state, so both stories wait before they settle. const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -const trigger = ( - -); - -/** - * The block holds the typed phrase and compares it to `confirmationValue`. Everything that - * decides what the dialog does next stays with the caller: `open` closes it, `isDeleting` - * marks it busy, `errorMessage` explains a failure. - */ -export function Default() { - const [open, setOpen] = React.useState(false); - const [isDeleting, setIsDeleting] = React.useState(false); - - const handleDelete = async () => { - setIsDeleting(true); - await settleAfter(2000); - setIsDeleting(false); - setOpen(false); - }; +const copy = { + trigger: ( + + ), + title: 'Delete account?', + description: 'Are you sure you want to delete your account? All of your data will be permanently deleted.', + fieldLabel: 'Type “Delete account” below to continue', + confirmationValue: 'Delete account', + actionLabel: 'Delete account', +}; +function Example({ onDelete }: { onDelete: () => Promise }) { + const controller = useDestructiveController({ onDelete }); return ( void handleDelete()} - isDeleting={isDeleting} + {...copy} + {...controller} /> ); } -/** - * A failed attempt leaves the dialog up. Pass the sentence the user should read as - * `errorMessage`, and clear it when the next attempt starts. - */ -export function WithError() { - const [open, setOpen] = React.useState(false); - const [isDeleting, setIsDeleting] = React.useState(false); - const [errorMessage, setErrorMessage] = React.useState(undefined); - - const handleDelete = async () => { - setErrorMessage(undefined); - setIsDeleting(true); - await settleAfter(2000); - setIsDeleting(false); - setErrorMessage('Your subscription is still active. Cancel it before you delete your account.'); - }; - - // The error belongs to the caller, so the caller drops it. Without this a reopened dialog - // still shows why the last attempt failed. - const handleOpenChange = (next: boolean) => { - setOpen(next); - if (!next) { - setErrorMessage(undefined); - } - }; +export function Default() { + return ( + { + await settleAfter(2000); + }} + /> + ); +} +export function WithError() { return ( - void handleDelete()} - isDeleting={isDeleting} - errorMessage={errorMessage} + { + await settleAfter(2000); + throw new Error('Your subscription is still active. Cancel it before you delete your account.'); + }} /> ); } diff --git a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx index 9c9209e5996..c1ea6ffeb43 100644 --- a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx @@ -1,4 +1,4 @@ -import { useUserProfileDeleteSectionController } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.controller'; +import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import { UserProfileDeleteSectionView } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.view'; import { useState } from 'react'; @@ -20,7 +20,7 @@ export const meta: StoryMeta = { const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); function DeleteSectionHarness({ onDelete }: { onDelete: () => Promise }) { - const controller = useUserProfileDeleteSectionController({ onDelete }); + const controller = useDestructiveController({ onDelete }); return ; } From f26189529bd2771c74988874897257ff000882a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 24 Sep 2026 14:25:14 +0200 Subject: [PATCH 12/40] Update destructive swingset story --- packages/swingset/src/stories/destructive.mdx | 151 ++++++++---------- 1 file changed, 71 insertions(+), 80 deletions(-) diff --git a/packages/swingset/src/stories/destructive.mdx b/packages/swingset/src/stories/destructive.mdx index 95d2b102b1b..809af4724b3 100644 --- a/packages/swingset/src/stories/destructive.mdx +++ b/packages/swingset/src/stories/destructive.mdx @@ -19,39 +19,29 @@ import * as Stories from './destructive.stories'; A confirmation for a destructive action that warrants making the user type. Deleting an account, deleting a project, revoking an API key. For actions that do not warrant typing, use [Confirmation](/blocks/confirmation). -The block holds the typed phrase and compares it to `confirmationValue`. In the controlled form, everything that decides what the dialog does next belongs to the caller. `open` closes it, `isDeleting` marks it busy, `errorMessage` explains a failure. +The block holds the typed phrase and compares it to `confirmationValue`. `useDestructiveController` owns the dialog for a single action. Resolve `onDelete` and the dialog closes. Reject it and the dialog stays open. Pass the same props yourself to drive it from `useState` or a machine, or mount one block with a handle when many rows share it. ```tsx import { Destructive } from '@clerk/mosaic/blocks/destructive'; +import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import { Button } from '@clerk/mosaic/components/button'; -const [open, setOpen] = useState(false); -const [isDeleting, setIsDeleting] = useState(false); - -const handleDelete = async () => { - setIsDeleting(true); - await deleteAccount(); - setIsDeleting(false); - setOpen(false); -}; +const destructive = useDestructiveController({ onDelete: deleteAccount }); Delete account} title='Delete account?' description='Are you sure you want to delete your account? All of your data will be permanently deleted.' fieldLabel='Type “Delete account” below to continue' confirmationValue='Delete account' actionLabel='Delete account' - onDelete={() => void handleDelete()} - isDeleting={isDeleting} />; ``` ## Failure -A failed attempt leaves the dialog up. Pass the sentence the user should read as `errorMessage`, and clear it when the next attempt starts. The field is marked invalid for as long as a message is set. +A rejection leaves the dialog up. The field is marked invalid for as long as the message is set. (); -const [runDelete, reverification] = useReverificationWithState(deleteAccount); +import { useReverificationFlow } from '@clerk/mosaic/features/reverification'; -const handleDelete = async () => { - setIsDeleting(true); - setErrorMessage(undefined); - try { - await runDelete(); - setOpen(false); - } catch (error) { - if (isReverificationCancelledError(error)) { - setOpen(false); - return; - } - setErrorMessage(error instanceof Error ? error.message : 'Delete failed.'); - } finally { - setIsDeleting(false); - } -}; +const [runDelete, reverification] = useReverificationFlow(deleteAccount); +const destructive = useDestructiveController({ onDelete: runDelete, reverification }); - void handleDelete()} - isDeleting={isDeleting} - errorMessage={errorMessage} - reverification={reverification} -/>; +; ``` ## One block, many rows @@ -131,26 +92,71 @@ Opened from a menu item, focus returns to that menu's trigger when the dialog cl storyModule={Stories} /> +## Controlled + +The same props can be passed directly, from `useState` or from a machine. `open` closes the dialog, `isDeleting` marks it busy, `errorMessage` explains a failure. + +```tsx +import { Destructive } from '@clerk/mosaic/blocks/destructive'; +import { Button } from '@clerk/mosaic/components/button'; + +const [open, setOpen] = useState(false); +const [isDeleting, setIsDeleting] = useState(false); + +const handleDelete = async () => { + setIsDeleting(true); + await deleteAccount(); + setIsDeleting(false); + setOpen(false); +}; + +Delete account} + title='Delete account?' + description='Are you sure you want to delete your account? All of your data will be permanently deleted.' + fieldLabel='Type “Delete account” below to continue' + confirmationValue='Delete account' + actionLabel='Delete account' + onDelete={() => void handleDelete()} + isDeleting={isDeleting} +/>; +``` + +A machine maps onto those same props: + +```tsx + send({ type: open ? 'OPEN' : 'CANCEL' })} + onDelete={() => send({ type: 'CONFIRM' })} + isDeleting={snapshot.value === 'deleting'} + errorMessage={snapshot.context.errorMessage} + {...copy} +/> +``` + ## Props Controlled: -| Prop | Type | Default | Description | -| ------------------- | ------------------------- | ------------ | -------------------------------------------------------------------------------- | -| `open` | `boolean` | — (required) | Whether the confirmation is showing. Controlled, the way any dialog is. | -| `onOpenChange` | `(open: boolean) => void` | — (required) | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | -| `trigger` | `ReactNode` | — | The button that asks to open the dialog. | -| `title` | `string` | — (required) | Names what is about to happen. | -| `description` | `ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | -| `fieldLabel` | `string` | — (required) | Labels the confirmation field. | -| `confirmationValue` | `string` | — (required) | The phrase the user has to type back. Also the field's placeholder. | -| `actionLabel` | `string` | — (required) | The confirming button's label. | -| `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | -| `finalFocus` | `DialogFocusTarget` | the trigger | Where focus returns on close, for a removal that unmounts the trigger. | -| `onDelete` | `() => void` | — (required) | Asks the caller to run the action, once the typed phrase matches. | -| `isDeleting` | `boolean` | `false` | Disables the field, renders the action pending, and ignores further submissions. | -| `errorMessage` | `string` | — | Marks the field invalid and renders under it. | -| `reverification` | `ReverificationProps` | — | Renders reverification in the same dialog after the action requests it. | +| Prop | Type | Default | Description | +| ------------------- | --------------------------- | ------------ | -------------------------------------------------------------------------------- | +| `open` | `boolean` | — (required) | Whether the confirmation is showing. Controlled, the way any dialog is. | +| `onOpenChange` | `(open: boolean) => void` | — (required) | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | +| `trigger` | `ReactNode` | — | The button that asks to open the dialog. | +| `title` | `string` | — (required) | Names what is about to happen. | +| `description` | `ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | +| `fieldLabel` | `string` | — (required) | Labels the confirmation field. | +| `confirmationValue` | `string` | — (required) | The phrase the user has to type back. Also the field's placeholder. | +| `actionLabel` | `string` | — (required) | The confirming button's label. | +| `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | +| `finalFocus` | `DialogFocusTarget` | the trigger | Where focus returns on close, for a removal that unmounts the trigger. | +| `onDelete` | `() => void` | — (required) | Asks the caller to run the action, once the typed phrase matches. | +| `isDeleting` | `boolean` | `false` | Disables the field, renders the action pending, and ignores further submissions. | +| `errorMessage` | `string` | — | Marks the field invalid and renders under it. | +| `reverification` | `ReverificationController` | — | Renders reverification in the same dialog after the action requests it. | With a handle: @@ -165,18 +171,3 @@ With a handle: | `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | | `finalFocus` | `DialogFocusTarget` | the trigger | Where focus returns on close, for a removal that unmounts the trigger. | | `onDelete` | `(payload: Payload) => Promise \| void` | — (required) | Runs the action. Resolve to close; reject with an `Error` to keep it open showing why. | - -## Driving it from a machine - -A section that wires the block to a state machine maps the machine's state onto the same props: - -```tsx - send({ type: open ? 'OPEN' : 'CANCEL' })} - onDelete={() => send({ type: 'CONFIRM' })} - isDeleting={snapshot.value === 'deleting'} - errorMessage={snapshot.context.errorMessage} - {...copy} -/> -``` From 82db26d83dc58f15cd0be1a9eaa7b97abc0ac9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 24 Sep 2026 14:32:18 +0200 Subject: [PATCH 13/40] Update tests etc --- .../destructive.controller.test.ts | 142 ++++++++++++++++++ ...-profile-delete-section.controller.test.ts | 80 ---------- .../user-profile-delete-section.test.tsx | 95 ++++++++++++ .../user-profile-delete-section.view.test.tsx | 34 +++-- .../user-profile-profile-panel.view.test.tsx | 36 +++-- .../user-profile-security-panel.view.test.tsx | 31 ++-- .../user-profile-security-panel.view.tsx | 2 +- 7 files changed, 302 insertions(+), 118 deletions(-) create mode 100644 packages/mosaic/src/blocks/destructive/destructive.controller.test.ts delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.controller.test.ts create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.test.tsx diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts new file mode 100644 index 00000000000..180b4587704 --- /dev/null +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts @@ -0,0 +1,142 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ReverificationController } from '../../features/reverification'; +import { deferred } from '../../machines/__tests__/test-utils'; +import { useDestructiveController } from './destructive.controller'; + +const idleReverification = { status: 'idle', phase: 'inactive' } as ReverificationController; + +describe('useDestructiveController', () => { + it('starts closed and opens from the opener or from onOpenChange', () => { + const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve() })); + expect(result.current.open).toBe(false); + expect(result.current.isDeleting).toBe(false); + + act(() => result.current.openDestructiveDialog()); + expect(result.current.open).toBe(true); + + act(() => result.current.onOpenChange(false)); + expect(result.current.open).toBe(false); + + act(() => result.current.onOpenChange(true)); + expect(result.current.open).toBe(true); + expect(result.current.isDeleting).toBe(false); + }); + + it('ignores the opener once the dialog is already open', () => { + const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve() })); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); + + act(() => result.current.openDestructiveDialog()); + + expect(result.current.isDeleting).toBe(true); + }); + + it('stays open and pending until the action resolves, then closes', async () => { + const pending = deferred(); + const onDelete = vi.fn(() => pending.promise); + const { result } = renderHook(() => useDestructiveController({ onDelete })); + act(() => result.current.onOpenChange(true)); + + act(() => { + void result.current.onDelete(); + }); + expect(onDelete).toHaveBeenCalledOnce(); + expect(result.current.open).toBe(true); + expect(result.current.isDeleting).toBe(true); + + await act(async () => { + pending.resolve(); + await pending.promise; + }); + await waitFor(() => expect(result.current.open).toBe(false)); + expect(result.current.isDeleting).toBe(false); + }); + + it('stays open with a message when the action rejects, and a retry can succeed', async () => { + const onDelete = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('Your subscription is still active.')) + .mockResolvedValueOnce(undefined); + const { result } = renderHook(() => useDestructiveController({ onDelete })); + act(() => result.current.onOpenChange(true)); + + await act(async () => { + await result.current.onDelete(); + }); + expect(result.current.open).toBe(true); + expect(result.current.isDeleting).toBe(false); + expect(result.current.errorMessage).toBe('Something went wrong'); + + await act(async () => { + await result.current.onDelete(); + }); + await waitFor(() => expect(result.current.open).toBe(false)); + expect(onDelete).toHaveBeenCalledTimes(2); + }); + + it('closes without a message when reverification is cancelled', async () => { + const onDelete = vi.fn(() => + Promise.reject(new ClerkRuntimeError('cancelled', { code: 'reverification_cancelled' })), + ); + const { result } = renderHook(() => useDestructiveController({ onDelete, reverification: idleReverification })); + act(() => result.current.onOpenChange(true)); + + await act(async () => { + await result.current.onDelete(); + }); + + expect(result.current.open).toBe(false); + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('ignores a close while the action is in flight, unless reverification is active', async () => { + const pending = deferred(); + const { result } = renderHook(() => + useDestructiveController({ onDelete: () => pending.promise, reverification: idleReverification }), + ); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); + + act(() => result.current.onOpenChange(false)); + expect(result.current.open).toBe(true); + expect(result.current.isDeleting).toBe(true); + + await act(async () => { + pending.resolve(); + await pending.promise; + }); + }); + + it('lets an active reverification close the dialog while the action is still pending', () => { + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => new Promise(() => {}), + reverification: { status: 'ready', phase: 'active' } as ReverificationController, + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); + + act(() => result.current.onOpenChange(false)); + + expect(result.current.open).toBe(false); + }); + + it('passes reverification through', () => { + const { result } = renderHook(() => + useDestructiveController({ onDelete: () => Promise.resolve(), reverification: idleReverification }), + ); + + expect(result.current.reverification).toBe(idleReverification); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.controller.test.ts deleted file mode 100644 index ac1c8ac584d..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.controller.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { createActor } from '../../../machine/createActor'; -import { - userProfileDeleteSectionMachine, - useUserProfileDeleteSectionController, -} from '../user-profile-delete-section/user-profile-delete-section.controller'; - -function start(deleteAccount: () => Promise) { - const actor = createActor(userProfileDeleteSectionMachine, { context: { deleteAccount } }).start(); - actor.send({ type: 'OPEN' }); - return actor; -} - -describe('userProfileDeleteSectionMachine', () => { - it('finishes in deleted when the account goes', async () => { - const actor = start(() => Promise.resolve()); - actor.send({ type: 'CONFIRM' }); - expect(actor.getSnapshot().value).toBe('deleting'); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('deleted')); - expect(actor.getSnapshot().status).toBe('done'); - }); - - it('returns to confirming with the reason when the delete fails', async () => { - const actor = start(() => Promise.reject(new Error('Your subscription is still active.'))); - actor.send({ type: 'CONFIRM' }); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('confirming')); - expect(actor.getSnapshot().context.errorMessage).toBe('Your subscription is still active.'); - }); - - it('falls back to generic copy when the rejection is not an Error', async () => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test - const actor = start(() => Promise.reject('nope')); - actor.send({ type: 'CONFIRM' }); - - await vi.waitFor(() => - expect(actor.getSnapshot().context.errorMessage).toBe('Something went wrong. Please try again.'), - ); - }); - - it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => Promise.reject(new Error('nope'))); - actor.send({ type: 'CONFIRM' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.errorMessage).toBe('nope')); - - actor.send({ type: 'CANCEL' }); - - expect(actor.getSnapshot().value).toBe('idle'); - expect(actor.getSnapshot().context.errorMessage).toBeUndefined(); - }); -}); - -describe('useUserProfileDeleteSectionController', () => { - it('holds the dialog open across confirming and deleting', async () => { - const { result } = renderHook(() => useUserProfileDeleteSectionController({ onDelete: () => Promise.resolve() })); - expect(result.current.isOpen).toBe(false); - - act(() => result.current.onOpenChange(true)); - expect(result.current.isOpen).toBe(true); - expect(result.current.isDeleting).toBe(false); - - act(() => result.current.onConfirm()); - expect(result.current.isOpen).toBe(true); - expect(result.current.isDeleting).toBe(true); - - await waitFor(() => expect(result.current.isOpen).toBe(false)); - }); - - it('cancels on close', () => { - const { result } = renderHook(() => useUserProfileDeleteSectionController({ onDelete: () => Promise.resolve() })); - act(() => result.current.onOpenChange(true)); - - act(() => result.current.onOpenChange(false)); - - expect(result.current.isOpen).toBe(false); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.test.tsx new file mode 100644 index 00000000000..b9e32d8d4ce --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.test.tsx @@ -0,0 +1,95 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileDeleteSection } from '../user-profile-delete-section/user-profile-delete-section'; + +let isLoaded: boolean; +let deleteSelfEnabled: boolean; +let signedInSessions: { user?: { id: string } }[]; +let deleteUser: ReturnType; +let setActive: ReturnType; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ + isLoaded, + isSignedIn: isLoaded, + user: isLoaded ? { id: 'user_1', deleteSelfEnabled, delete: deleteUser } : undefined, + }), + useSession: () => ({ session: { id: 'sess_1' } }), + useClerk: () => ({ + setActive, + client: { signedInSessions }, + buildAfterSignOutUrl: () => '/signed-out', + buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left', + __internal_getOption: () => undefined, + }), + useReverification: (fetcher: () => Promise) => fetcher, + }; +}); + +function renderSection(fallback?: React.ReactNode) { + return render( + + + , + ); +} + +describe('UserProfileDeleteSection', () => { + beforeEach(() => { + isLoaded = true; + deleteSelfEnabled = true; + signedInSessions = []; + deleteUser = vi.fn(() => Promise.resolve()); + setActive = vi.fn(() => Promise.resolve()); + }); + + it('renders the fallback until the user has loaded', () => { + isLoaded = false; + renderSection(

Loading account

); + + expect(screen.getByText('Loading account')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Danger zone' })).not.toBeInTheDocument(); + }); + + it('renders nothing when the instance does not allow deleting the account', () => { + deleteSelfEnabled = false; + const { container } = renderSection(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('deletes the account and signs out to the after-sign-out url', async () => { + const user = userEvent.setup(); + renderSection(); + + await user.click(screen.getByRole('button', { name: 'Delete account' })); + const dialog = screen.getByRole('dialog'); + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); + + await waitFor(() => expect(deleteUser).toHaveBeenCalledOnce()); + expect(setActive).toHaveBeenCalledWith({ session: null, redirectUrl: '/signed-out' }); + }); + + it('uses the single-session sign-out url when another account is still signed in', async () => { + signedInSessions = [{ user: { id: 'user_1' } }, { user: { id: 'user_2' } }]; + const user = userEvent.setup(); + renderSection(); + + await user.click(screen.getByRole('button', { name: 'Delete account' })); + const dialog = screen.getByRole('dialog'); + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); + + await waitFor(() => + expect(setActive).toHaveBeenCalledWith({ session: null, redirectUrl: '/one-session-left' }), + ); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx index f8e7727686d..adebb27067d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx @@ -2,22 +2,23 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import type { DestructiveController } from '../../../blocks/destructive/destructive.controller'; import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserProfileDeleteSectionViewProps } from '../user-profile-delete-section/user-profile-delete-section.view'; import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view'; -function viewProps(overrides: Partial = {}): UserProfileDeleteSectionViewProps { +function viewProps(overrides: Partial = {}): DestructiveController { return { - isOpen: false, + open: false, onOpenChange: vi.fn(), - onConfirm: vi.fn(), + onDelete: vi.fn(), isDeleting: false, errorMessage: undefined, + openDestructiveDialog: vi.fn(), ...overrides, }; } -function view(props: UserProfileDeleteSectionViewProps) { +function view(props: DestructiveController) { return ( @@ -25,7 +26,7 @@ function view(props: UserProfileDeleteSectionViewProps) { ); } -function renderView(overrides: Partial = {}) { +function renderView(overrides: Partial = {}) { const props = viewProps(overrides); return { ...render(view(props)), props }; } @@ -36,9 +37,10 @@ describe('UserProfileDeleteSectionView', () => { const { props } = renderView(); expect(screen.getByRole('heading', { name: 'Danger zone' })).toBeInTheDocument(); - expect( - screen.getByText('Permanently delete this account and all its data. This cannot be undone.'), - ).toBeInTheDocument(); + expect(screen.getByText('Delete account', { selector: '.cl-section-label' })).toBeInTheDocument(); + expect(screen.getByText('Permanently delete this account and all its data. This cannot be undone.')).toHaveClass( + 'cl-section-description', + ); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Delete account' })); @@ -46,9 +48,9 @@ describe('UserProfileDeleteSectionView', () => { }); it('confirms only after the phrase is typed back', async () => { - const onConfirm = vi.fn(); + const onDelete = vi.fn(); const user = userEvent.setup(); - renderView({ isOpen: true, onConfirm }); + renderView({ open: true, onDelete }); const dialog = screen.getByRole('dialog'); const confirm = within(dialog).getByRole('button', { name: 'Delete account' }); @@ -59,11 +61,11 @@ describe('UserProfileDeleteSectionView', () => { await user.type(within(dialog).getByRole('textbox'), 'Delete account'); await user.click(confirm); - expect(onConfirm).toHaveBeenCalledOnce(); + expect(onDelete).toHaveBeenCalledOnce(); }); it('renders a delete failure while keeping the dialog open', () => { - renderView({ isOpen: true, errorMessage: 'Your subscription is still active.' }); + renderView({ open: true, errorMessage: 'Your subscription is still active.' }); expect(screen.getByText('Your subscription is still active.')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument(); @@ -71,7 +73,7 @@ describe('UserProfileDeleteSectionView', () => { it('clears the phrase when the dialog is cancelled', async () => { const user = userEvent.setup(); - const props = viewProps({ isOpen: true }); + const props = viewProps({ open: true }); const { rerender } = render(view(props)); const dialog = screen.getByRole('dialog'); @@ -79,9 +81,9 @@ describe('UserProfileDeleteSectionView', () => { await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); - rerender(view({ ...props, isOpen: false })); + rerender(view({ ...props, open: false })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - rerender(view({ ...props, isOpen: true })); + rerender(view({ ...props, open: true })); expect(screen.getByRole('textbox')).toHaveValue(''); }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 94f16ce7583..9591b6bf6c0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -1,3 +1,4 @@ +import type * as SharedReact from '@clerk/shared/react'; import { createDeferredPromise } from '@clerk/shared/utils'; import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -7,6 +8,27 @@ import { MosaicProvider } from '../../../MosaicProvider'; import type { UserProfileProfilePanelViewProps } from '../user-profile-profile-panel.view'; import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ + isLoaded: true, + isSignedIn: true, + user: { id: 'user_1', deleteSelfEnabled: true, delete: () => Promise.resolve() }, + }), + useSession: () => ({ session: { id: 'sess_1' } }), + useClerk: () => ({ + setActive: () => Promise.resolve(), + client: { signedInSessions: [] }, + buildAfterSignOutUrl: () => '/signed-out', + buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left', + __internal_getOption: () => undefined, + }), + useReverification: (fetcher: () => Promise) => fetcher, + }; +}); + const props: UserProfileProfilePanelViewProps = { allowMultipleAccounts: true, name: 'Preston Booth', @@ -285,16 +307,12 @@ describe('UserProfileProfilePanelView', () => { expect(within(phoneSection).getByRole('button', { name: 'Add phone number' })).toBeInTheDocument(); }); - it('renders connected accounts and the danger zone when provided', async () => { - const onRemoveConnectedAccount = vi.fn(); - const onDeleteAccount = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); + it('renders connected accounts and the danger zone when provided', () => { renderView({ connectedAccounts: [ { id: 'google', provider: 'Google', identifier: 'test@google.com', iconUrl: 'https://example.com/google.svg' }, ], - onRemoveConnectedAccount, - onDeleteAccount, + onDeleteAccount: () => Promise.resolve(), }); expect(screen.getByRole('heading', { level: 3, name: 'Connected accounts' })).toBeInTheDocument(); @@ -304,12 +322,6 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByText('Permanently delete this account and all its data. This cannot be undone.')).toHaveClass( 'cl-section-description', ); - await user.click(screen.getByRole('button', { name: 'Delete account' })); - const deleteDialog = screen.getByRole('dialog'); - await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account'); - await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' })); - - expect(onDeleteAccount).toHaveBeenCalledOnce(); }); it('renders connected provider and Web3 images inside icon frames', () => { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 052f8b22877..5d95372167a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -1,3 +1,4 @@ +import type * as SharedReact from '@clerk/shared/react'; import { createDeferredPromise } from '@clerk/shared/utils'; import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -7,6 +8,27 @@ import { MosaicProvider } from '../../../MosaicProvider'; import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ + isLoaded: true, + isSignedIn: true, + user: { id: 'user_1', deleteSelfEnabled: true, delete: () => Promise.resolve() }, + }), + useSession: () => ({ session: { id: 'sess_1' } }), + useClerk: () => ({ + setActive: () => Promise.resolve(), + client: { signedInSessions: [] }, + buildAfterSignOutUrl: () => '/signed-out', + buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left', + __internal_getOption: () => undefined, + }), + useReverification: (fetcher: () => Promise) => fetcher, + }; +}); + const props: UserProfileSecurityPanelViewProps = { hasPassword: true, passkeys: [ @@ -106,7 +128,6 @@ describe('UserProfileSecurityPanelView', () => { const onRemovePasskey = vi.fn(); const onSignOutDevice = vi.fn(); const onSignOutAllOtherDevices = vi.fn(); - const onDeleteAccount = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); renderView({ @@ -115,7 +136,6 @@ describe('UserProfileSecurityPanelView', () => { onRemovePasskey, onSignOutDevice, onSignOutAllOtherDevices, - onDeleteAccount, }); await user.click(screen.getByRole('button', { name: 'Add passkey' })); @@ -146,18 +166,11 @@ describe('UserProfileSecurityPanelView', () => { await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - // The danger zone confirms in a modal, so it goes last: nothing else is clickable while it is open. - await user.click(screen.getByRole('button', { name: 'Delete account' })); - const deleteDialog = screen.getByRole('dialog'); - await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account'); - await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' })); - expect(onAddPasskey).toHaveBeenCalledOnce(); expect(onRenamePasskey).toHaveBeenCalledWith('passkey_1', 'Work laptop'); expect(onRemovePasskey).toHaveBeenCalledWith('passkey_1'); expect(onSignOutDevice).toHaveBeenCalledWith('mobile'); expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce(); - expect(onDeleteAccount).toHaveBeenCalledOnce(); }); it('keeps supported empty authentication methods actionable', () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index b53fbce6a6b..b8d35a40cef 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -125,7 +125,7 @@ export function UserProfileSecurityPanelView({ /> ) : null} {/* TODO: This is temporary, a view should not render a connected component */} - {onDeleteAccount ? : null} + {onDeleteAccount ? : null} ); From e1e1a570d67f9ebe3f0fcc363234d97b3835e81f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 24 Sep 2026 14:35:56 +0200 Subject: [PATCH 14/40] Rewrite delete account section tests as integration tests --- ...rofile-delete-section.integration.test.tsx | 167 ++++++++++++++++++ .../user-profile-delete-section.test.tsx | 95 ---------- .../user-profile-delete-section.view.test.tsx | 89 ---------- 3 files changed, 167 insertions(+), 184 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx new file mode 100644 index 00000000000..5bc1d710a06 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx @@ -0,0 +1,167 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { createDeferredPromise } from '@clerk/shared/utils'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileDeleteSection } from '../user-profile-delete-section/user-profile-delete-section'; + +let isLoaded: boolean; +let deleteSelfEnabled: boolean; +let signedInSessions: { user?: { id: string } }[]; +let deleteUser: ReturnType; +let setActive: ReturnType; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ + isLoaded, + isSignedIn: isLoaded, + user: isLoaded ? { id: 'user_1', deleteSelfEnabled, delete: deleteUser } : undefined, + }), + useSession: () => ({ session: { id: 'sess_1' } }), + useClerk: () => ({ + setActive, + client: { signedInSessions }, + buildAfterSignOutUrl: () => '/signed-out', + buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left', + __internal_getOption: () => undefined, + }), + useReverification: (fetcher: () => Promise) => fetcher, + }; +}); + +function renderSection(fallback?: React.ReactNode) { + return render( + + + , + ); +} + +async function openDialog(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'Delete account' })); + return screen.getByRole('dialog'); +} + +describe('UserProfileDeleteSection', () => { + beforeEach(() => { + isLoaded = true; + deleteSelfEnabled = true; + signedInSessions = []; + deleteUser = vi.fn(() => Promise.resolve()); + setActive = vi.fn(() => Promise.resolve()); + }); + + it('renders the fallback until the user has loaded', () => { + isLoaded = false; + renderSection(

Loading account

); + + expect(screen.getByText('Loading account')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Danger zone' })).not.toBeInTheDocument(); + }); + + it('renders nothing when the instance does not allow deleting the account', () => { + deleteSelfEnabled = false; + const { container } = renderSection(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the danger zone and opens the dialog from the action', async () => { + const user = userEvent.setup(); + renderSection(); + + expect(screen.getByRole('heading', { name: 'Danger zone' })).toBeInTheDocument(); + expect(screen.getByText('Delete account', { selector: '.cl-section-label' })).toBeInTheDocument(); + expect(screen.getByText('Permanently delete this account and all its data. This cannot be undone.')).toHaveClass( + 'cl-section-description', + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + const dialog = await openDialog(user); + expect(within(dialog).getByText('Type “Delete account” below to continue')).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Delete account' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('deletes only after the phrase is typed, then signs out', async () => { + const user = userEvent.setup(); + renderSection(); + const dialog = await openDialog(user); + const confirm = within(dialog).getByRole('button', { name: 'Delete account' }); + + await user.type(within(dialog).getByRole('textbox'), 'Delete accoun'); + expect(confirm).toHaveAttribute('aria-disabled', 'true'); + await user.click(confirm); + expect(deleteUser).not.toHaveBeenCalled(); + + await user.type(within(dialog).getByRole('textbox'), 't'); + await user.click(confirm); + + await waitFor(() => expect(deleteUser).toHaveBeenCalledOnce()); + expect(setActive).toHaveBeenCalledWith({ session: null, redirectUrl: '/signed-out' }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('uses the single-session sign-out url when another account is still signed in', async () => { + signedInSessions = [{ user: { id: 'user_1' } }, { user: { id: 'user_2' } }]; + const user = userEvent.setup(); + renderSection(); + const dialog = await openDialog(user); + + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); + + await waitFor(() => + expect(setActive).toHaveBeenCalledWith({ session: null, redirectUrl: '/one-session-left' }), + ); + }); + + it('keeps the dialog up with a message when the delete fails', async () => { + deleteUser = vi.fn(() => Promise.reject(new Error('Your subscription is still active.'))); + const user = userEvent.setup(); + renderSection(); + const dialog = await openDialog(user); + + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); + + expect(await screen.findByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(setActive).not.toHaveBeenCalled(); + }); + + it('holds the dialog open while the delete is in flight', async () => { + const pending = createDeferredPromise(); + deleteUser = vi.fn(() => pending.promise); + const user = userEvent.setup(); + renderSection(); + const dialog = await openDialog(user); + + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); + + expect(within(dialog).getByRole('button', { name: 'Delete account' })).toHaveAttribute('aria-busy', 'true'); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + pending.resolve(); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('clears the phrase when the dialog is cancelled', async () => { + const user = userEvent.setup(); + renderSection(); + const dialog = await openDialog(user); + + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + const reopened = await openDialog(user); + expect(within(reopened).getByRole('textbox')).toHaveValue(''); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.test.tsx deleted file mode 100644 index b9e32d8d4ce..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type * as SharedReact from '@clerk/shared/react'; -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileDeleteSection } from '../user-profile-delete-section/user-profile-delete-section'; - -let isLoaded: boolean; -let deleteSelfEnabled: boolean; -let signedInSessions: { user?: { id: string } }[]; -let deleteUser: ReturnType; -let setActive: ReturnType; - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useUser: () => ({ - isLoaded, - isSignedIn: isLoaded, - user: isLoaded ? { id: 'user_1', deleteSelfEnabled, delete: deleteUser } : undefined, - }), - useSession: () => ({ session: { id: 'sess_1' } }), - useClerk: () => ({ - setActive, - client: { signedInSessions }, - buildAfterSignOutUrl: () => '/signed-out', - buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left', - __internal_getOption: () => undefined, - }), - useReverification: (fetcher: () => Promise) => fetcher, - }; -}); - -function renderSection(fallback?: React.ReactNode) { - return render( - - - , - ); -} - -describe('UserProfileDeleteSection', () => { - beforeEach(() => { - isLoaded = true; - deleteSelfEnabled = true; - signedInSessions = []; - deleteUser = vi.fn(() => Promise.resolve()); - setActive = vi.fn(() => Promise.resolve()); - }); - - it('renders the fallback until the user has loaded', () => { - isLoaded = false; - renderSection(

Loading account

); - - expect(screen.getByText('Loading account')).toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: 'Danger zone' })).not.toBeInTheDocument(); - }); - - it('renders nothing when the instance does not allow deleting the account', () => { - deleteSelfEnabled = false; - const { container } = renderSection(); - - expect(container).toBeEmptyDOMElement(); - }); - - it('deletes the account and signs out to the after-sign-out url', async () => { - const user = userEvent.setup(); - renderSection(); - - await user.click(screen.getByRole('button', { name: 'Delete account' })); - const dialog = screen.getByRole('dialog'); - await user.type(within(dialog).getByRole('textbox'), 'Delete account'); - await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); - - await waitFor(() => expect(deleteUser).toHaveBeenCalledOnce()); - expect(setActive).toHaveBeenCalledWith({ session: null, redirectUrl: '/signed-out' }); - }); - - it('uses the single-session sign-out url when another account is still signed in', async () => { - signedInSessions = [{ user: { id: 'user_1' } }, { user: { id: 'user_2' } }]; - const user = userEvent.setup(); - renderSection(); - - await user.click(screen.getByRole('button', { name: 'Delete account' })); - const dialog = screen.getByRole('dialog'); - await user.type(within(dialog).getByRole('textbox'), 'Delete account'); - await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); - - await waitFor(() => - expect(setActive).toHaveBeenCalledWith({ session: null, redirectUrl: '/one-session-left' }), - ); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx deleted file mode 100644 index adebb27067d..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import type { DestructiveController } from '../../../blocks/destructive/destructive.controller'; -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view'; - -function viewProps(overrides: Partial = {}): DestructiveController { - return { - open: false, - onOpenChange: vi.fn(), - onDelete: vi.fn(), - isDeleting: false, - errorMessage: undefined, - openDestructiveDialog: vi.fn(), - ...overrides, - }; -} - -function view(props: DestructiveController) { - return ( - - - - ); -} - -function renderView(overrides: Partial = {}) { - const props = viewProps(overrides); - return { ...render(view(props)), props }; -} - -describe('UserProfileDeleteSectionView', () => { - it('renders the danger zone and asks to open the dialog', async () => { - const user = userEvent.setup(); - const { props } = renderView(); - - expect(screen.getByRole('heading', { name: 'Danger zone' })).toBeInTheDocument(); - expect(screen.getByText('Delete account', { selector: '.cl-section-label' })).toBeInTheDocument(); - expect(screen.getByText('Permanently delete this account and all its data. This cannot be undone.')).toHaveClass( - 'cl-section-description', - ); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Delete account' })); - expect(props.onOpenChange).toHaveBeenCalledWith(true, expect.anything()); - }); - - it('confirms only after the phrase is typed back', async () => { - const onDelete = vi.fn(); - const user = userEvent.setup(); - renderView({ open: true, onDelete }); - - const dialog = screen.getByRole('dialog'); - const confirm = within(dialog).getByRole('button', { name: 'Delete account' }); - - expect(within(dialog).getByText('Type “Delete account” below to continue')).toBeInTheDocument(); - expect(confirm).toHaveAttribute('aria-disabled', 'true'); - - await user.type(within(dialog).getByRole('textbox'), 'Delete account'); - await user.click(confirm); - - expect(onDelete).toHaveBeenCalledOnce(); - }); - - it('renders a delete failure while keeping the dialog open', () => { - renderView({ open: true, errorMessage: 'Your subscription is still active.' }); - - expect(screen.getByText('Your subscription is still active.')).toBeInTheDocument(); - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - it('clears the phrase when the dialog is cancelled', async () => { - const user = userEvent.setup(); - const props = viewProps({ open: true }); - const { rerender } = render(view(props)); - - const dialog = screen.getByRole('dialog'); - await user.type(within(dialog).getByRole('textbox'), 'Delete account'); - await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); - - expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); - rerender(view({ ...props, open: false })); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - rerender(view({ ...props, open: true })); - expect(screen.getByRole('textbox')).toHaveValue(''); - }); -}); From dfcea4486baf85c5912e530d69e834dd8aaa70db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 24 Sep 2026 17:08:32 +0200 Subject: [PATCH 15/40] Dedupe and format --- packages/swingset/src/stories/destructive.mdx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/swingset/src/stories/destructive.mdx b/packages/swingset/src/stories/destructive.mdx index 809af4724b3..df7f0dc1085 100644 --- a/packages/swingset/src/stories/destructive.mdx +++ b/packages/swingset/src/stories/destructive.mdx @@ -141,22 +141,22 @@ A machine maps onto those same props: Controlled: -| Prop | Type | Default | Description | -| ------------------- | --------------------------- | ------------ | -------------------------------------------------------------------------------- | -| `open` | `boolean` | — (required) | Whether the confirmation is showing. Controlled, the way any dialog is. | -| `onOpenChange` | `(open: boolean) => void` | — (required) | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | -| `trigger` | `ReactNode` | — | The button that asks to open the dialog. | -| `title` | `string` | — (required) | Names what is about to happen. | -| `description` | `ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | -| `fieldLabel` | `string` | — (required) | Labels the confirmation field. | -| `confirmationValue` | `string` | — (required) | The phrase the user has to type back. Also the field's placeholder. | -| `actionLabel` | `string` | — (required) | The confirming button's label. | -| `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | -| `finalFocus` | `DialogFocusTarget` | the trigger | Where focus returns on close, for a removal that unmounts the trigger. | -| `onDelete` | `() => void` | — (required) | Asks the caller to run the action, once the typed phrase matches. | -| `isDeleting` | `boolean` | `false` | Disables the field, renders the action pending, and ignores further submissions. | -| `errorMessage` | `string` | — | Marks the field invalid and renders under it. | -| `reverification` | `ReverificationController` | — | Renders reverification in the same dialog after the action requests it. | +| Prop | Type | Default | Description | +| ------------------- | -------------------------- | ------------ | -------------------------------------------------------------------------------- | +| `open` | `boolean` | — (required) | Whether the confirmation is showing. Controlled, the way any dialog is. | +| `onOpenChange` | `(open: boolean) => void` | — (required) | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | +| `trigger` | `ReactNode` | — | The button that asks to open the dialog. | +| `title` | `string` | — (required) | Names what is about to happen. | +| `description` | `ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | +| `fieldLabel` | `string` | — (required) | Labels the confirmation field. | +| `confirmationValue` | `string` | — (required) | The phrase the user has to type back. Also the field's placeholder. | +| `actionLabel` | `string` | — (required) | The confirming button's label. | +| `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | +| `finalFocus` | `DialogFocusTarget` | the trigger | Where focus returns on close, for a removal that unmounts the trigger. | +| `onDelete` | `() => void` | — (required) | Asks the caller to run the action, once the typed phrase matches. | +| `isDeleting` | `boolean` | `false` | Disables the field, renders the action pending, and ignores further submissions. | +| `errorMessage` | `string` | — | Marks the field invalid and renders under it. | +| `reverification` | `ReverificationController` | — | Renders reverification in the same dialog after the action requests it. | With a handle: From f924b24a27108f7388b919de08ca0405dc9a7703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 24 Sep 2026 17:19:38 +0200 Subject: [PATCH 16/40] Format --- .../mosaic/src/blocks/destructive/destructive.tsx | 2 +- .../user-profile-delete-section.integration.test.tsx | 4 +--- .../user-profile-delete-section.tsx | 12 ++++++------ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/mosaic/src/blocks/destructive/destructive.tsx b/packages/mosaic/src/blocks/destructive/destructive.tsx index 40a4867e676..41d96b429b1 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.tsx @@ -80,7 +80,7 @@ function DestructiveCard({ event.preventDefault(); if (isConfirmed && !isDeleting) { // Errors are meant to be handled on the outside, so we swallow them here - onDelete()?.catch(() => { }); + onDelete()?.catch(() => {}); } }; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx index 5bc1d710a06..b55fda0f5cc 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx @@ -115,9 +115,7 @@ describe('UserProfileDeleteSection', () => { await user.type(within(dialog).getByRole('textbox'), 'Delete account'); await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); - await waitFor(() => - expect(setActive).toHaveBeenCalledWith({ session: null, redirectUrl: '/one-session-left' }), - ); + await waitFor(() => expect(setActive).toHaveBeenCalledWith({ session: null, redirectUrl: '/one-session-left' })); }); it('keeps the dialog up with a message when the delete fails', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx index d481dc74f2e..294daef41f4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx @@ -6,7 +6,7 @@ import { UserProfileDeleteSectionView } from './user-profile-delete-section.view export type UserProfileDeleteSectionProps = { fallback?: React.ReactNode; -} +}; export function UserProfileDeleteSection(props: UserProfileDeleteSectionProps) { // -- Model -- @@ -25,22 +25,22 @@ export function UserProfileDeleteSection(props: UserProfileDeleteSectionProps) { await user.delete(); const hasOtherSessions = client.signedInSessions.filter(s => s.user?.id !== user?.id).length > 0; - const redirectUrl = hasOtherSessions ? - clerk.buildAfterMultiSessionSingleSignOutUrl() : - clerk.buildAfterSignOutUrl(); + const redirectUrl = hasOtherSessions + ? clerk.buildAfterMultiSessionSingleSignOutUrl() + : clerk.buildAfterSignOutUrl(); return await setActive({ session: null, redirectUrl, }); - } + }; // -- Controllers -- const [deleteAccountWithReverification, reverificationState] = useReverificationFlow(deleteAccount); const destructiveController = useDestructiveController({ onDelete: deleteAccountWithReverification, reverification: reverificationState, - }) + }); // -- View -- if (!isLoaded) { From 748d4b3ee140ec5f8485923e7709ceae565a8b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 24 Sep 2026 19:56:18 +0200 Subject: [PATCH 17/40] Change profile and security panels to take deleteAccountSlot instead of rendering directly --- .../user-profile-profile-panel.view.test.tsx | 29 +++++-------------- .../user-profile-security-panel.view.test.tsx | 29 +++++-------------- .../user-profile-profile-panel.view.tsx | 13 ++++----- .../user-profile-security-panel.view.tsx | 10 +++---- .../{user-profile.ts => user-profile.tsx} | 11 +++++-- .../user-profile-profile-panel.stories.tsx | 3 +- 6 files changed, 35 insertions(+), 60 deletions(-) rename packages/swingset/src/stories/fixtures/{user-profile.ts => user-profile.tsx} (93%) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 9591b6bf6c0..ef4ca5532de 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -1,33 +1,18 @@ -import type * as SharedReact from '@clerk/shared/react'; import { createDeferredPromise } from '@clerk/shared/utils'; import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view'; import type { UserProfileProfilePanelViewProps } from '../user-profile-profile-panel.view'; import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useUser: () => ({ - isLoaded: true, - isSignedIn: true, - user: { id: 'user_1', deleteSelfEnabled: true, delete: () => Promise.resolve() }, - }), - useSession: () => ({ session: { id: 'sess_1' } }), - useClerk: () => ({ - setActive: () => Promise.resolve(), - client: { signedInSessions: [] }, - buildAfterSignOutUrl: () => '/signed-out', - buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left', - __internal_getOption: () => undefined, - }), - useReverification: (fetcher: () => Promise) => fetcher, - }; -}); +function DeleteAccount() { + const controller = useDestructiveController({ onDelete: () => Promise.resolve() }); + return ; +} const props: UserProfileProfilePanelViewProps = { allowMultipleAccounts: true, @@ -312,7 +297,7 @@ describe('UserProfileProfilePanelView', () => { connectedAccounts: [ { id: 'google', provider: 'Google', identifier: 'test@google.com', iconUrl: 'https://example.com/google.svg' }, ], - onDeleteAccount: () => Promise.resolve(), + deleteAccountSlot: , }); expect(screen.getByRole('heading', { level: 3, name: 'Connected accounts' })).toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 5d95372167a..f7035959062 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -1,33 +1,18 @@ -import type * as SharedReact from '@clerk/shared/react'; import { createDeferredPromise } from '@clerk/shared/utils'; import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view'; import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useUser: () => ({ - isLoaded: true, - isSignedIn: true, - user: { id: 'user_1', deleteSelfEnabled: true, delete: () => Promise.resolve() }, - }), - useSession: () => ({ session: { id: 'sess_1' } }), - useClerk: () => ({ - setActive: () => Promise.resolve(), - client: { signedInSessions: [] }, - buildAfterSignOutUrl: () => '/signed-out', - buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left', - __internal_getOption: () => undefined, - }), - useReverification: (fetcher: () => Promise) => fetcher, - }; -}); +function DeleteAccount() { + const controller = useDestructiveController({ onDelete: () => Promise.resolve() }); + return ; +} const props: UserProfileSecurityPanelViewProps = { hasPassword: true, @@ -80,7 +65,7 @@ function renderView(overrides: Partial = {}) describe('UserProfileSecurityPanelView', () => { it('composes authentication, active devices, and the danger zone', () => { - renderView({ onDeleteAccount: vi.fn(() => Promise.resolve()) }); + renderView({ deleteAccountSlot: }); expect(screen.getByRole('heading', { level: 2, name: 'Security' })).toBeInTheDocument(); const authentication = screen.getByRole('region', { name: 'Authentication' }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index 1449f145a47..10fbec595d4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -1,4 +1,5 @@ -import type { ReactElement } from 'react'; + +import type { ReactElement, ReactNode } from 'react'; import { useRef } from 'react'; import { Panel } from '../../components/panel'; @@ -14,7 +15,6 @@ import type { UserProfileConnectionProvider, } from './user-profile-connected-accounts-section.view'; import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view'; -import { UserProfileDeleteSection } from './user-profile-delete-section/user-profile-delete-section'; import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view'; @@ -36,8 +36,8 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect onConnectWeb3Wallet?: (id: string) => void; onSetPrimaryWeb3Wallet?: (id: string) => void; onRemoveWeb3Wallet?: (id: string) => void | Promise; - /** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */ - onDeleteAccount?: () => Promise; + /** Danger zone. Omit to hide it. */ + deleteAccountSlot?: ReactNode; } export function UserProfileProfilePanelView({ @@ -80,7 +80,7 @@ export function UserProfileProfilePanelView({ onConnectWeb3Wallet, onSetPrimaryWeb3Wallet, onRemoveWeb3Wallet, - onDeleteAccount, + deleteAccountSlot, }: UserProfileProfilePanelViewProps): ReactElement { const pageTitle = useRef(null); return ( @@ -139,8 +139,7 @@ export function UserProfileProfilePanelView({ onRemove={onRemoveWeb3Wallet} onSetPrimary={onSetPrimaryWeb3Wallet} /> - {/* TODO: This is temporary, a view should not render a connected component */} - {onDeleteAccount ? : null} + {deleteAccountSlot} ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index b8d35a40cef..eb49f62f71a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -8,7 +8,6 @@ import type { UserProfileDevice, } from './user-profile-active-devices-section.view'; import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view'; -import { UserProfileDeleteSection } from './user-profile-delete-section/user-profile-delete-section'; import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view'; import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; @@ -50,8 +49,8 @@ export interface UserProfileSecurityPanelViewProps onRegenerateBackupCodes?: () => void; onRemoveMfaMethod?: (id: string) => void | Promise; onSetDefaultMfaMethod?: (id: string) => void | Promise; - /** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */ - onDeleteAccount?: () => Promise; + /** Danger zone. Omit to hide it. */ + deleteAccountSlot?: ReactNode; } export function UserProfileSecurityPanelView({ @@ -75,7 +74,7 @@ export function UserProfileSecurityPanelView({ onSetDefaultMfaMethod, onSignOutDevice, onSignOutAllOtherDevices, - onDeleteAccount, + deleteAccountSlot, }: UserProfileSecurityPanelViewProps): ReactElement { const showPassword = hasPassword || Boolean(onSubmitPassword) || Boolean(managedBy); const showPasskeys = passkeys !== undefined && passkeysVisible; @@ -124,8 +123,7 @@ export function UserProfileSecurityPanelView({ onSignOutDevice={onSignOutDevice} /> ) : null} - {/* TODO: This is temporary, a view should not render a connected component */} - {onDeleteAccount ? : null} + {deleteAccountSlot} ); diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.tsx similarity index 93% rename from packages/swingset/src/stories/fixtures/user-profile.ts rename to packages/swingset/src/stories/fixtures/user-profile.tsx index dc9375177fd..2115579375a 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -1,4 +1,6 @@ +import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import type { UserProfileViewProps } from '@clerk/mosaic/features/user-profile/user-profile.view'; +import { UserProfileDeleteSectionView } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.view'; import type { UserProfilePaymentMethod, UserProfileSubscription, @@ -25,6 +27,11 @@ import { useUserProfileMfaExample } from './user-profile-mfa-example'; import { usePasskeysFixture } from './user-profile-passkeys'; import { useWeb3WalletsFixture } from './user-profile-web3-wallets'; +export function UserProfileDeleteAccountPreview() { + const controller = useDestructiveController({ onDelete: () => Promise.resolve() }); + return ; +} + export interface UserProfileFixtureOptions { /** Replaces the default OTP flow, e.g. for a custom dialog example. */ onAddEmail?: () => void; @@ -126,7 +133,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions ...createUserProfileAddPhoneFixture({ onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), }), - onDeleteAccount: () => Promise.resolve(), + deleteAccountSlot: , onManageEmail: () => undefined, onManagePhone: () => undefined, onProfilePictureChange: showFile, @@ -148,7 +155,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions ...mfa.security, devices: activeDevices.devices, onAddPasskey: passkeys.onAdd, - onDeleteAccount: () => Promise.resolve(), + deleteAccountSlot: , onRemovePasskey: passkeys.onRemove, onSignOutAllOtherDevices: activeDevices.onSignOutAllOtherDevices, onSignOutDevice: activeDevices.onSignOutDevice, diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index c0fb9ce4bf4..aabb012c536 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -8,6 +8,7 @@ import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; +import { UserProfileDeleteAccountPreview } from './fixtures/user-profile'; import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useConnectedAccountsFixture } from './fixtures/user-profile-connected-accounts'; @@ -64,7 +65,7 @@ export function Default(_args: Record) { onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), })} onConnectAccount={connections.onConnect} - onDeleteAccount={() => Promise.resolve()} + deleteAccountSlot={} onManageEmail={() => undefined} onManagePhone={() => undefined} onProfilePictureChange={showFile} From a552597be7f49ba18aa36660668febf81baa1972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 29 Sep 2026 12:57:27 +0200 Subject: [PATCH 18/40] Fix component unmount bug in useReverificationWithState --- .../use-reverification-with-state.test.tsx | 17 +++++++++++++++++ .../use-reverification-with-state.ts | 2 ++ 2 files changed, 19 insertions(+) diff --git a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx index bd72cf8c55f..94bd5f2c343 100644 --- a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -392,6 +392,23 @@ describe('useReverificationWithState', () => { expect(challengeCancel).toHaveBeenCalledOnce(); }); + it('cancels the challenge when the owner unmounts before reverification is needed', async () => { + const response = deferred(); + const fetcher = vi.fn().mockImplementationOnce(() => response.promise); + const { result, unmount } = renderHook(() => useReverificationWithState(fetcher)); + + let pending!: Promise; + act(() => { + pending = result.current[0](); + }); + unmount(); + + const settled = expect(pending).rejects.toMatchObject({ code: 'reverification_cancelled' }); + response.resolve({ reverificationLevel: 'first_factor' }); + await settled; + expect(challengeCancel).toHaveBeenCalledOnce(); + }); + it('does not cancel when the owner unmounts during retry', async () => { const retry = deferred(); const fetcher = vi diff --git a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts index 7a7e8d60501..31254ac009b 100644 --- a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts +++ b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts @@ -100,6 +100,8 @@ export function useReverificationWithState( onNeedsReverification: ({ complete, cancel, level }) => { const operation = runtimeRef.current.operation; if (operation.status !== 'requesting') { + // This can happen e.g. when the component unmounts mid-flight + cancel(); return; } From 872e4c9687c158ef21c30e193c89a26ff69829eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 29 Sep 2026 13:12:55 +0200 Subject: [PATCH 19/40] Get rid of unnecessary abortRequested that was causing a bug --- .../destructive.controller.test.ts | 18 +++++ .../reverification.controller.test.tsx | 78 ++++++++++++++++--- .../reverification.controller.ts | 34 ++------ 3 files changed, 90 insertions(+), 40 deletions(-) diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts index 180b4587704..8c36f93f5d4 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts @@ -132,6 +132,24 @@ describe('useDestructiveController', () => { expect(result.current.open).toBe(false); }); + it('ignores a close while reverification is retrying the action', () => { + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => new Promise(() => {}), + reverification: { status: 'ready', phase: 'retrying' } as ReverificationController, + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); + + act(() => result.current.onOpenChange(false)); + + expect(result.current.open).toBe(true); + expect(result.current.isDeleting).toBe(true); + }); + it('passes reverification through', () => { const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve(), reverification: idleReverification }), diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx index 5f4ef4e3ef9..cea74363b71 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx @@ -132,7 +132,7 @@ describe('reverificationMachine', () => { expect(actor.getSnapshot().value).toBe('methodPicker'); }); - it('does not leave submitting when reset until the attempt settles', async () => { + it('leaves submitting immediately when reset and ignores a late failure', async () => { const attempt = deferred(); const cancel = vi.fn(); const actor = startActor(seatedDeps({ attempt: () => attempt.promise, cancel })); @@ -142,16 +142,16 @@ describe('reverificationMachine', () => { expect(actor.getSnapshot().value).toBe('submitting'); actor.send({ type: 'RESET' }); - expect(actor.getSnapshot().value).toBe('submitting'); - expect(cancel).not.toHaveBeenCalled(); + expect(actor.getSnapshot().value).toBe('inactive'); attempt.reject(new Error('cancelled')); await tick(); - expect(actor.getSnapshot().value).toBe('done'); - expect(cancel).toHaveBeenCalledOnce(); + expect(actor.getSnapshot().value).toBe('inactive'); + expect(actor.getSnapshot().context.errorMessage).toBeUndefined(); + expect(cancel).not.toHaveBeenCalled(); }); - it('cancels a successful attempt that settled after reset', async () => { + it('ignores a successful attempt that settled after reset', async () => { const attempt = deferred(); const finish = vi.fn(async () => {}); const cancel = vi.fn(); @@ -163,9 +163,9 @@ describe('reverificationMachine', () => { attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); await tick(); - expect(actor.getSnapshot().value).toBe('done'); - expect(cancel).toHaveBeenCalledOnce(); + expect(actor.getSnapshot().value).toBe('inactive'); expect(finish).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); }); it('finishes on success without replacing the active method', async () => { @@ -396,7 +396,7 @@ describe('useReverificationController', () => { }); }); - it('resets once while an attempt is in flight', async () => { + it('resets immediately while an attempt is in flight', async () => { const attempt = deferred(); const finish = vi.fn(() => Promise.resolve()); const cancel = vi.fn(); @@ -423,10 +423,12 @@ describe('useReverificationController', () => { rerender({ model: { ...activeModel, phase: 'inactive' } }); expect(result.current.status).toBe('idle'); - act(() => { + await act(async () => { attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); + await tick(); }); - await waitFor(() => expect(cancel).toHaveBeenCalledOnce()); + expect(result.current.status).toBe('idle'); + expect(cancel).not.toHaveBeenCalled(); expect(finish).not.toHaveBeenCalled(); }); @@ -459,11 +461,63 @@ describe('useReverificationController', () => { }); expect(cancel).toHaveBeenCalledOnce(); + await act(async () => { + attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); + await tick(); + }); + expect(cancel).toHaveBeenCalledOnce(); + expect(finish).not.toHaveBeenCalled(); + }); + + it('starts a fresh flow when reopened while a cancelled attempt is still in flight', async () => { + const attempt = deferred(); + const start = vi.fn(async () => firstFactorResult()); + const finish = vi.fn(() => Promise.resolve()); + const cancel = vi.fn(); + const model = readyModel({ start, attempt: () => attempt.promise, finish, cancel }); + const { result, rerender } = renderHook( + ({ model }: { model: ReverificationModel }) => useReverificationController(model), + { initialProps: { model } }, + ); + + 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: { ...model, phase: 'inactive' } }); + expect(result.current.status).toBe('idle'); + + rerender({ model: { ...model, phase: 'active' } }); + await waitFor(() => expect(start).toHaveBeenCalledTimes(2)); + await waitFor(() => { + expect(result.current.status).toBe('ready'); + if (result.current.status === 'ready') { + expect(result.current.isPending).toBe(false); + expect(result.current.value).toBe(''); + } + }); + + await act(async () => { attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); + await tick(); }); - await waitFor(() => expect(cancel).toHaveBeenCalledTimes(2)); + + expect(cancel).not.toHaveBeenCalled(); expect(finish).not.toHaveBeenCalled(); + expect(result.current.status).toBe('ready'); + if (result.current.status === 'ready') { + expect(result.current.isPending).toBe(false); + } }); it('stays on the current step pending while finish runs', async () => { diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index f3f6e5b10b1..afb21c50018 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -35,7 +35,6 @@ interface ReverificationContext { activeMethod: ReverificationMethod | null; methods: readonly ReverificationMethod[]; resendAvailableAt: number | undefined; - abortRequested: boolean; submitRequested: boolean; frozenActiveMethodId: string | undefined; overlayFrom: OverlayFrom; @@ -137,17 +136,6 @@ const afterResult = [ { 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 })), @@ -183,7 +171,6 @@ export const reverificationMachine = createMachine({ activeMethod: null, methods: [], resendAvailableAt: undefined, - abortRequested: false, submitRequested: false, frozenActiveMethodId: undefined, overlayFrom: 'factor', @@ -195,7 +182,6 @@ export const reverificationMachine = createMachine({ entry: assign(() => ({ inputValue: '', errorMessage: undefined, - abortRequested: false, submitRequested: false, ...unlockResend(), })), @@ -282,21 +268,13 @@ export const reverificationMachine = createMachine({ }, submitting: { - on: { - RESET: { - guard: ctx => !ctx.abortRequested, - actions: assign(() => ({ abortRequested: true })), - }, - }, + on: { RESET: 'inactive' }, invoke: fromPromise(submit, { - onDone: [abortAfterInvoke, ...afterResult], - onError: [ - abortAfterInvoke, - { - target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), - }, - ], + onDone: afterResult, + onError: { + target: 'verifying', + actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), + }, }), }, From 68cef3d4bf28c473353d0f91e417c79a7bf822f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 29 Sep 2026 13:36:10 +0200 Subject: [PATCH 20/40] Fix broken tests --- .../reverification/__tests__/reverification.model.test.tsx | 4 ++-- .../__tests__/use-reverification-with-state.test.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx index 06d2110f9cc..ab3f0ae3637 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx @@ -301,7 +301,7 @@ describe('useReverificationModel', () => { const { result } = renderHook(() => useReverificationModel(activeProps())); await expect( ready(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'bad'), - ).rejects.toThrow('That password is incorrect.'); + ).rejects.toMatchObject({ message: 'That password is incorrect.' }); }); it('activates the verified session before complete', async () => { @@ -327,7 +327,7 @@ describe('useReverificationModel', () => { const props = activeProps(); const { result } = renderHook(() => useReverificationModel(props)); - await expect(ready(result.current).finish()).rejects.toThrow('Session could not be activated.'); + await expect(ready(result.current).finish()).rejects.toMatchObject({ message: 'Session could not be activated.' }); expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' }); expect(props.complete).not.toHaveBeenCalled(); }); diff --git a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx index 94bd5f2c343..1a714e11ddc 100644 --- a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -144,7 +144,7 @@ describe('useReverificationWithState', () => { retry.reject(new Error('Mock delete failed.')); await act(async () => { - await expect(pending).rejects.toThrow('Mock delete failed.'); + await expect(pending).rejects.toMatchObject({ message: 'Mock delete failed.' }); }); await waitFor(() => expect(result.current[1]).toEqual({ phase: 'inactive' })); expect(challengeCancel).not.toHaveBeenCalled(); From fc6c9b71c12dbdfd1f60d4fade121d58cf7a2264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 29 Sep 2026 13:44:50 +0200 Subject: [PATCH 21/40] Remove unused options from useReverificationWithState hook --- .../src/features/reverification/index.ts | 5 +---- .../reverification/reverification.tsx | 4 +--- .../use-reverification-with-state.ts | 19 +++++-------------- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/packages/mosaic/src/features/reverification/index.ts b/packages/mosaic/src/features/reverification/index.ts index dcc2bf3dd64..1c56d70fda6 100644 --- a/packages/mosaic/src/features/reverification/index.ts +++ b/packages/mosaic/src/features/reverification/index.ts @@ -10,8 +10,5 @@ export type { ReverificationStrategy, ReverificationViewProps, } from './reverification.types'; -export type { - UseReverificationWithStateOptions, - UseReverificationWithStateResult, -} from './use-reverification-with-state'; +export type { UseReverificationWithStateResult } from './use-reverification-with-state'; export { useReverificationWithState } from './use-reverification-with-state'; diff --git a/packages/mosaic/src/features/reverification/reverification.tsx b/packages/mosaic/src/features/reverification/reverification.tsx index 04cc862bafc..ab75fff6c80 100644 --- a/packages/mosaic/src/features/reverification/reverification.tsx +++ b/packages/mosaic/src/features/reverification/reverification.tsx @@ -4,7 +4,6 @@ import { ReverificationPending, ReverificationUnavailable, ReverificationView } import { type ReverificationFetcher, useReverificationWithState, - type UseReverificationWithStateOptions, type UseReverificationWithStateResult, } from './use-reverification-with-state'; @@ -15,9 +14,8 @@ export type UseReverificationFlowResult( fetcher: F, - options?: UseReverificationWithStateOptions, ): UseReverificationFlowResult { - const [wrappedFetcher, reverificationState] = useReverificationWithState(fetcher, options); + const [wrappedFetcher, reverificationState] = useReverificationWithState(fetcher); const model = useReverificationModel(reverificationState); const controller = useReverificationController(model); diff --git a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts index 31254ac009b..41daad8e368 100644 --- a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts +++ b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts @@ -6,10 +6,6 @@ import type { ReverificationState } from './reverification.types'; export type ReverificationFetcher = (...args: any[]) => Promise | undefined; -type UseReverificationOptions = NonNullable[1]>; - -export type UseReverificationWithStateOptions = Omit; - export type UseReverificationWithStateResult = readonly [ ReturnType>, ReverificationState, @@ -42,15 +38,12 @@ function requestAlreadyInProgressError(): ClerkRuntimeError { } /** - * This wraps useReverification, but does not pop the default UI and instead manages - * the lifecycle, use the returned `phase` and callbacks to build your custom UI. - * - * useReverificationFlow pairs this lifecycle with . + * Wraps useReverification without the default UI. Returns [handler, state]: call handler + * to run the action, and render your own UI from state.phase ('inactive' | 'active' | + * 'retrying'). While 'active', state.complete and state.cancel end the challenge. * - * In contrast to useReverification, the returned handler is only allowed to run - * in serial. Any new calls that happen while it's still pending will throw - * request_already_in_progress. If you always guard against double-invocation, - * you wont see this error. + * The handler runs one call at a time. A call made while another is pending rejects with a + * ClerkRuntimeError with code 'request_already_in_progress'. */ // The reason we need to enforce single-flight is that we need a direct link between // a single invocation of the handler and a specific reverification. useReverification @@ -60,7 +53,6 @@ function requestAlreadyInProgressError(): ClerkRuntimeError { // behavior by first fixing the useReverification hook. export function useReverificationWithState( fetcher: F, - options?: UseReverificationWithStateOptions, ): UseReverificationWithStateResult { const { session } = useSession(); // The return is observable and needs to be driven by React state @@ -96,7 +88,6 @@ export function useReverificationWithState( }, []); const wrapped = useReverification(fetcher, { - ...options, onNeedsReverification: ({ complete, cancel, level }) => { const operation = runtimeRef.current.operation; if (operation.status !== 'requesting') { From c74de68480fc8469cf462789cf4131c3e5899d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 29 Sep 2026 14:37:04 +0200 Subject: [PATCH 22/40] Make retrying a proper state in model+controller and remove phase --- .../destructive.controller.test.ts | 6 +- .../destructive/destructive.controller.ts | 4 +- .../blocks/destructive/destructive.test.tsx | 20 ++- .../reverification.composition.test.tsx | 17 ++- .../reverification.controller.test.tsx | 131 ++++++++++++------ .../__tests__/reverification.model.test.tsx | 60 ++++---- .../__tests__/reverification.test.tsx | 11 +- .../reverification.controller.ts | 104 +++++++------- .../reverification/reverification.model.tsx | 37 ++--- .../app/(clerk)/live/reverification/page.tsx | 16 +-- .../swingset/src/stories/reverification.mdx | 13 +- 11 files changed, 233 insertions(+), 186 deletions(-) diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts index 8c36f93f5d4..526a1bddd85 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts @@ -6,7 +6,7 @@ import type { ReverificationController } from '../../features/reverification'; import { deferred } from '../../machines/__tests__/test-utils'; import { useDestructiveController } from './destructive.controller'; -const idleReverification = { status: 'idle', phase: 'inactive' } as ReverificationController; +const idleReverification = { status: 'idle' } as ReverificationController; describe('useDestructiveController', () => { it('starts closed and opens from the opener or from onOpenChange', () => { @@ -119,7 +119,7 @@ describe('useDestructiveController', () => { const { result } = renderHook(() => useDestructiveController({ onDelete: () => new Promise(() => {}), - reverification: { status: 'ready', phase: 'active' } as ReverificationController, + reverification: { status: 'ready', onCancel: vi.fn() } as ReverificationController, }), ); act(() => result.current.onOpenChange(true)); @@ -136,7 +136,7 @@ describe('useDestructiveController', () => { const { result } = renderHook(() => useDestructiveController({ onDelete: () => new Promise(() => {}), - reverification: { status: 'ready', phase: 'retrying' } as ReverificationController, + reverification: { status: 'retrying' } as ReverificationController, }), ); act(() => result.current.onOpenChange(true)); diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.ts index f6db12e9eab..01793da8321 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.ts @@ -66,8 +66,8 @@ export function useDestructiveController({ } }, onOpenChange: nextIsOpen => { - const pendingForReverification = reverification?.phase === 'active'; - if (!nextIsOpen && isDeleting && !pendingForReverification) { + const canCancel = Boolean(reverification?.onCancel); + if (!nextIsOpen && isDeleting && !canCancel) { return; } diff --git a/packages/mosaic/src/blocks/destructive/destructive.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.test.tsx index 4d41d45d2ad..49104752346 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.test.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.test.tsx @@ -11,7 +11,7 @@ import type { DestructiveControlledProps, DestructiveHandleProps } from './destr import { Destructive } from './destructive'; vi.mock('../../features/reverification', () => ({ - Reverification: ({ phase }: { phase: string }) => {phase}, + Reverification: ({ status }: { status: string }) => {status}, })); function renderBlock(overrides: Partial = {}) { @@ -35,18 +35,15 @@ function renderBlock(overrides: Partial = {}) { const confirmButton = () => screen.getByRole('button', { name: 'Delete account' }); const startingReverification = { status: 'loading' as const, - phase: 'active' as const, onCancel: vi.fn(), }; -function readyReverification(phase: 'active' | 'retrying'): ReverificationController { - return { - status: 'ready', - phase, - step: 'password', +function readyReverification(status: 'ready' | 'retrying'): ReverificationController { + const view = { + step: 'password' as const, value: '', onValueChange: () => {}, - isPending: phase === 'retrying', + isPending: status === 'retrying', onSubmit: () => {}, onShowMethods: () => {}, onShowHelp: () => {}, @@ -57,6 +54,7 @@ function readyReverification(phase: 'active' | 'retrying'): ReverificationContro methods: [], onSelectMethod: () => {}, }; + return status === 'retrying' ? { status, ...view } : { status, ...view }; } describe('Destructive', () => { @@ -184,12 +182,12 @@ describe('Destructive', () => { }); it('shows reverification in the same dialog and card', () => { - renderBlock({ isDeleting: true, reverification: readyReverification('active') }); + renderBlock({ isDeleting: true, reverification: readyReverification('ready') }); expect(screen.getAllByRole('dialog')).toHaveLength(1); expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); - expect(screen.getByTestId('reverification')).toHaveTextContent('active'); + expect(screen.getByTestId('reverification')).toHaveTextContent('ready'); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); }); @@ -237,7 +235,7 @@ describe('Destructive', () => {
, ); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx index 9f83caa897f..a4746e00b54 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx @@ -9,12 +9,11 @@ import { Reverification } from '../reverification'; import type { ReverificationController } from '../reverification.controller'; import type { ReverificationViewProps } from '../reverification.types'; -let controller: ReverificationController = { status: 'idle', phase: 'inactive' }; +let controller: ReverificationController = { status: 'idle' }; function surface(overrides: Partial = {}): ReverificationController { return { status: 'ready', - phase: 'active', step: 'password', value: '', onValueChange: vi.fn(), @@ -75,7 +74,7 @@ function Nested({ step }: { step: 'confirm' | 'verify' | 'finalizing' }) { describe('reverification inside an outer flow', () => { it('keeps one dialog and one card, and nests a flow only while verifying', () => { - controller = { status: 'idle', phase: 'inactive' }; + controller = { status: 'idle' }; const { rerender } = render(); expect(screen.getAllByRole('dialog')).toHaveLength(1); @@ -84,7 +83,7 @@ describe('reverification inside an outer flow', () => { expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); expect(screen.getByText('Confirm the mock delete.')).toBeInTheDocument(); - controller = { status: 'loading', phase: 'active' }; + controller = { status: 'loading' }; rerender(); const flows = document.querySelectorAll('.cl-flow-root'); @@ -110,7 +109,7 @@ describe('reverification inside an outer flow', () => { expect(screen.getByLabelText('Password')).toBeInTheDocument(); expect(screen.queryByText('Confirm the mock delete.')).not.toBeInTheDocument(); - controller = { status: 'idle', phase: 'inactive' }; + controller = { status: 'idle' }; rerender(); expect(screen.getAllByRole('dialog')).toHaveLength(1); @@ -127,7 +126,7 @@ describe('reverification inside an outer flow', () => { expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); expect(screen.getByLabelText('Password')).toBeInTheDocument(); - controller = { status: 'idle', phase: 'inactive' }; + controller = { status: 'idle' }; rerender(); expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); @@ -151,7 +150,7 @@ describe('reverification inside an outer flow', () => { describe('reverification card states', () => { it('keeps one card from the pending state through a factor', () => { - controller = { status: 'loading', phase: 'active' }; + controller = { status: 'loading' }; const { container, rerender } = render( @@ -185,7 +184,7 @@ describe('reverification card states', () => { }); it('shows a dismiss button on the pending card inside a dialog', () => { - controller = { status: 'loading', phase: 'active' }; + controller = { status: 'loading' }; render( @@ -204,7 +203,7 @@ describe('reverification card states', () => { }); it('renders unavailable outside the factor flow, then mounts that flow in the same card', () => { - controller = { status: 'unavailable', phase: 'active' }; + controller = { status: 'unavailable' }; const { container, rerender } = render( diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx index cea74363b71..1089355dd26 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx @@ -8,7 +8,7 @@ import { reverificationMachine, useReverificationController, } from '../reverification.controller'; -import type { ReverificationModel, ReverificationReadyModel } from '../reverification.model'; +import type { ReverificationActiveModel, ReverificationModel } from '../reverification.model'; import type { ReverificationMethod, ReverificationResult } from '../reverification.types'; const password: ReverificationMethod = { id: 'password', stage: 'first', strategy: 'password' }; @@ -47,10 +47,9 @@ function startActor(deps: ReverificationDeps = seatedDeps()) { return actor; } -function readyModel(overrides: Partial = {}): ReverificationReadyModel { +function buildActiveModel(overrides: Partial = {}): ReverificationActiveModel { return { - status: 'ready', - phase: 'active', + status: 'active', supportEmail: 'support@example.com', start: vi.fn(async () => firstFactorResult()), prepare: vi.fn(async () => {}), @@ -180,10 +179,10 @@ describe('reverificationMachine', () => { actor.send({ type: 'TYPE', value: 'secret' }); actor.send({ type: 'SUBMIT' }); await tick(); - expect(actor.getSnapshot().value).toBe('completing'); + expect(actor.getSnapshot().value).toBe('finishing'); expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('password'); finish.resolve(); - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('done')); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('retrying')); }); it('returns to the current method with the error when finish fails', async () => { @@ -244,7 +243,7 @@ describe('reverificationMachine', () => { prepare.resolve(); await tick(); expect(attempt).toHaveBeenCalledOnce(); - expect(actor.getSnapshot().value).toBe('completing'); + expect(actor.getSnapshot().value).toBe('finishing'); }); it('keeps the factor interactive and queues submit while resend prepares', async () => { @@ -290,15 +289,14 @@ describe('reverificationMachine', () => { describe('useReverificationController', () => { it('is idle when reverification is not active', () => { - const { result } = renderHook(() => useReverificationController(readyModel({ phase: 'inactive' }))); - expect(result.current).toEqual({ status: 'idle', phase: 'inactive' }); + const { result } = renderHook(() => useReverificationController({ status: 'inactive' })); + expect(result.current).toEqual({ status: 'idle' }); }); it('is loading while the model is still waiting on Clerk', () => { const cancel = vi.fn(); const loading: ReverificationModel = { status: 'loading', - phase: 'active', cancel, }; const { result } = renderHook(() => useReverificationController(loading)); @@ -314,7 +312,7 @@ describe('useReverificationController', () => { const cancel = vi.fn(); const { result } = renderHook(() => useReverificationController( - readyModel({ start: vi.fn(async () => Promise.reject(new Error('no session'))), cancel }), + buildActiveModel({ start: vi.fn(async () => Promise.reject(new Error('no session'))), cancel }), ), ); await waitFor(() => expect(result.current.status).toBe('unavailable')); @@ -325,7 +323,7 @@ describe('useReverificationController', () => { const cancel = vi.fn(); const { result } = renderHook(() => useReverificationController( - readyModel({ + buildActiveModel({ start: vi.fn(async () => firstFactorResult({ methods: [], startingMethod: null })), cancel, }), @@ -338,7 +336,7 @@ describe('useReverificationController', () => { it('still passes onShowMethods when only one method is available', async () => { const { result } = renderHook(() => useReverificationController( - readyModel({ + buildActiveModel({ start: vi.fn(async () => firstFactorResult({ methods: [password], startingMethod: password })), }), ), @@ -356,7 +354,7 @@ describe('useReverificationController', () => { it('keeps onResend during the resend cooldown', async () => { const { result } = renderHook(() => useReverificationController( - readyModel({ + buildActiveModel({ start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), }), ), @@ -374,7 +372,9 @@ describe('useReverificationController', () => { it('marks the current step pending while an attempt is in flight', async () => { const attempt = deferred(); - const { result } = renderHook(() => useReverificationController(readyModel({ attempt: () => attempt.promise }))); + const { result } = renderHook(() => + useReverificationController(buildActiveModel({ attempt: () => attempt.promise })), + ); await waitFor(() => expect(result.current.status).toBe('ready')); act(() => { @@ -400,7 +400,7 @@ describe('useReverificationController', () => { const attempt = deferred(); const finish = vi.fn(() => Promise.resolve()); const cancel = vi.fn(); - const activeModel = readyModel({ attempt: () => attempt.promise, finish, cancel }); + const activeModel = buildActiveModel({ attempt: () => attempt.promise, finish, cancel }); const { result, rerender } = renderHook( ({ model }: { model: ReverificationModel }) => useReverificationController(model), { initialProps: { model: activeModel } }, @@ -420,7 +420,7 @@ describe('useReverificationController', () => { } }); - rerender({ model: { ...activeModel, phase: 'inactive' } }); + rerender({ model: { status: 'inactive' } }); expect(result.current.status).toBe('idle'); await act(async () => { @@ -437,7 +437,7 @@ describe('useReverificationController', () => { const finish = vi.fn(() => Promise.resolve()); const cancel = vi.fn(); const { result } = renderHook(() => - useReverificationController(readyModel({ attempt: () => attempt.promise, finish, cancel })), + useReverificationController(buildActiveModel({ attempt: () => attempt.promise, finish, cancel })), ); await waitFor(() => expect(result.current.status).toBe('ready')); @@ -474,7 +474,7 @@ describe('useReverificationController', () => { const start = vi.fn(async () => firstFactorResult()); const finish = vi.fn(() => Promise.resolve()); const cancel = vi.fn(); - const model = readyModel({ start, attempt: () => attempt.promise, finish, cancel }); + const model = buildActiveModel({ start, attempt: () => attempt.promise, finish, cancel }); const { result, rerender } = renderHook( ({ model }: { model: ReverificationModel }) => useReverificationController(model), { initialProps: { model } }, @@ -494,10 +494,10 @@ describe('useReverificationController', () => { } }); - rerender({ model: { ...model, phase: 'inactive' } }); + rerender({ model: { status: 'inactive' } }); expect(result.current.status).toBe('idle'); - rerender({ model: { ...model, phase: 'active' } }); + rerender({ model }); await waitFor(() => expect(start).toHaveBeenCalledTimes(2)); await waitFor(() => { expect(result.current.status).toBe('ready'); @@ -524,7 +524,7 @@ describe('useReverificationController', () => { const finish = deferred(); const { result } = renderHook(() => useReverificationController( - readyModel({ + buildActiveModel({ attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), finish: () => finish.promise, }), @@ -550,13 +550,13 @@ describe('useReverificationController', () => { act(() => { finish.resolve(); }); - await waitFor(() => expect(result.current.status).toBe('loading')); + await waitFor(() => expect(result.current.status).toBe('retrying')); }); it('stays on the current step with the error when finish fails', async () => { const { result } = renderHook(() => useReverificationController( - readyModel({ + buildActiveModel({ attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), finish: vi.fn(async () => Promise.reject(new Error('Session could not be activated.'))), }), @@ -585,13 +585,13 @@ describe('useReverificationController', () => { const start = vi.fn(async () => firstFactorResult()); const { result, rerender } = renderHook( ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { initialProps: { model: readyModel({ start }) } }, + { initialProps: { model: buildActiveModel({ start }) } }, ); await waitFor(() => expect(result.current.status).toBe('ready')); expect(start).toHaveBeenCalledOnce(); - rerender({ model: { status: 'loading', phase: 'active', cancel: vi.fn() } }); + rerender({ model: { status: 'loading', cancel: vi.fn() } }); expect(result.current.status).toBe('ready'); if (result.current.status === 'ready') { expect(result.current.step).toBe('password'); @@ -606,7 +606,7 @@ describe('useReverificationController', () => { ({ model }: { model: ReverificationModel }) => useReverificationController(model), { initialProps: { - model: readyModel({ + model: buildActiveModel({ attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), finish: finishFn, }), @@ -629,7 +629,7 @@ describe('useReverificationController', () => { } }); - rerender({ model: { status: 'loading', phase: 'active', cancel: vi.fn() } }); + rerender({ model: { status: 'loading', cancel: vi.fn() } }); expect(result.current.status).toBe('ready'); if (result.current.status === 'ready') { expect(result.current.step).toBe('password'); @@ -639,7 +639,7 @@ describe('useReverificationController', () => { act(() => { finish.resolve(); }); - await waitFor(() => expect(result.current.status).toBe('loading')); + await waitFor(() => expect(result.current.status).toBe('retrying')); expect(finishFn).toHaveBeenCalledOnce(); }); @@ -647,14 +647,14 @@ describe('useReverificationController', () => { const start = vi.fn(async () => firstFactorResult()); const { result, rerender } = renderHook( ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { initialProps: { model: readyModel({ start }) } }, + { initialProps: { model: buildActiveModel({ start }) } }, ); await waitFor(() => expect(result.current.status).toBe('ready')); - rerender({ model: readyModel({ start, phase: 'inactive' }) }); - expect(result.current).toEqual({ status: 'idle', phase: 'inactive' }); + rerender({ model: { status: 'inactive' } }); + expect(result.current).toEqual({ status: 'idle' }); - rerender({ model: readyModel({ start, phase: 'active' }) }); + rerender({ model: buildActiveModel({ start }) }); await waitFor(() => expect(result.current.status).toBe('ready')); expect(start).toHaveBeenCalledTimes(2); }); @@ -663,7 +663,7 @@ describe('useReverificationController', () => { const prepare = deferred(); const { result } = renderHook(() => useReverificationController( - readyModel({ + buildActiveModel({ start: vi.fn(async () => firstFactorResult({ methods: [email, password], startingMethod: email })), prepare: () => prepare.promise, }), @@ -705,7 +705,7 @@ describe('useReverificationController', () => { const attempt = vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); const { result } = renderHook(() => useReverificationController( - readyModel({ + buildActiveModel({ start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), prepare: () => prepare.promise, attempt, @@ -735,7 +735,7 @@ describe('useReverificationController', () => { const prepare = deferred(); const { result } = renderHook(() => useReverificationController( - readyModel({ + buildActiveModel({ prepare: () => prepare.promise, }), ), @@ -782,7 +782,7 @@ describe('useReverificationController', () => { const prepare = deferred(); const { result } = renderHook(() => useReverificationController( - readyModel({ + buildActiveModel({ prepare: () => prepare.promise, }), ), @@ -814,7 +814,7 @@ describe('useReverificationController', () => { const finish = deferred(); const start = vi.fn(() => Promise.resolve(firstFactorResult())); const cancel = vi.fn(); - const active = readyModel({ + const active = buildActiveModel({ start, cancel, attempt: vi.fn(() => @@ -842,7 +842,7 @@ describe('useReverificationController', () => { } }); - rerender({ model: { ...active, phase: 'retrying' } }); + rerender({ model: { status: 'retrying' } }); expect(result.current.status).toBe('ready'); if (result.current.status === 'ready') { expect(result.current.step).toBe('password'); @@ -855,15 +855,60 @@ describe('useReverificationController', () => { finish.resolve(); }); await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { + expect(result.current.status).toBe('retrying'); + if (result.current.status === 'retrying') { expect(result.current.step).toBe('password'); expect(result.current.isPending).toBe(true); + expect(result.current.onCancel).toBeUndefined(); } }); expect(cancel).not.toHaveBeenCalled(); - rerender({ model: { ...active, phase: 'inactive' } }); - expect(result.current).toEqual({ status: 'idle', phase: 'inactive' }); + rerender({ model: { status: 'inactive' } }); + expect(result.current).toEqual({ status: 'idle' }); + }); + + it('is retrying without onCancel as soon as finish resolves', async () => { + const { result } = renderHook(() => + useReverificationController( + buildActiveModel({ + attempt: vi.fn(() => + Promise.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), + ), + }), + ), + ); + + 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('retrying')); + expect(result.current.onCancel).toBeUndefined(); + }); + + it('shows the pending card, not unavailable, when start completes without a factor', async () => { + const finish = deferred(); + const { result } = renderHook(() => + useReverificationController( + buildActiveModel({ + start: vi.fn(() => + Promise.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), + ), + finish: () => finish.promise, + }), + ), + ); + + await waitFor(() => expect(result.current.status).toBe('loading')); + act(() => { + finish.resolve(); + }); + await tick(); + expect(result.current.status).toBe('loading'); }); }); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx index ab3f0ae3637..97a327c59ea 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx @@ -5,15 +5,15 @@ import { renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + type ReverificationActiveModel, 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'); +function active(model: ReverificationModel): ReverificationActiveModel { + expect(model.status).toBe('active'); + if (model.status !== 'active') { + throw new Error('expected active'); } return model; } @@ -104,7 +104,6 @@ describe('useReverificationModel', () => { const cancel = vi.fn(); const { result } = renderHook(() => useReverificationModel({ ...activeProps(), cancel })); expect(result.current.status).toBe('loading'); - expect(result.current.phase).toBe('active'); if (result.current.status === 'loading') { result.current.cancel(); } @@ -115,25 +114,32 @@ describe('useReverificationModel', () => { supportEmail = undefined; const { result } = renderHook(() => useReverificationModel(activeProps())); expect(result.current.status).toBe('loading'); - expect(result.current.phase).toBe('active'); }); it('is active when props are active', () => { const { result } = renderHook(() => useReverificationModel(activeProps())); - expect(result.current.phase).toBe('active'); - expect(ready(result.current).supportEmail).toBe('support@example.com'); + expect(active(result.current).supportEmail).toBe('support@example.com'); }); - it('is inactive when props are idle', () => { + it('is inactive when props are idle, regardless of hydration', () => { + session = null; + environmentHydrated = false; const { result } = renderHook(() => useReverificationModel({ phase: 'inactive' })); - expect(result.current.phase).toBe('inactive'); + expect(result.current).toEqual({ status: 'inactive' }); + }); + + it('is retrying when props are retrying, regardless of hydration', () => { + session = null; + environmentHydrated = false; + const { result } = renderHook(() => useReverificationModel({ phase: 'retrying' })); + expect(result.current).toEqual({ status: 'retrying' }); }); 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(); + await active(result.current).start(); expect(session?.startVerification).toHaveBeenCalledWith({ level: 'second_factor' }); }); @@ -157,7 +163,7 @@ describe('useReverificationModel', () => { ); const { result } = renderHook(() => useReverificationModel(activeProps())); - const started = await ready(result.current).start(); + const started = await active(result.current).start(); expect(session?.startVerification).toHaveBeenCalledWith({ level: 'first_factor' }); expect(started.methods.map(method => method.strategy)).toEqual(['password', 'email_code']); @@ -179,7 +185,7 @@ describe('useReverificationModel', () => { ); const { result } = renderHook(() => useReverificationModel(activeProps())); - const started = await ready(result.current).start(); + const started = await active(result.current).start(); expect(started.startingMethod?.strategy).toBe('passkey'); }); @@ -197,7 +203,7 @@ describe('useReverificationModel', () => { ); const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: 'second_factor' })); - const started = await ready(result.current).start(); + const started = await active(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({ @@ -215,8 +221,8 @@ describe('useReverificationModel', () => { session?.attemptFirstFactorVerification.mockResolvedValue(resource({ status: 'complete' })); const { result } = renderHook(() => useReverificationModel(activeProps())); - await ready(result.current).start(); - await ready(result.current).prepare({ + await active(result.current).start(); + await active(result.current).prepare({ id: 'email_code:idn_1', stage: 'first', strategy: 'email_code', @@ -228,13 +234,13 @@ describe('useReverificationModel', () => { emailAddressId: 'idn_1', }); - await ready(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'secret'); + await active(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'secret'); expect(session?.attemptFirstFactorVerification).toHaveBeenCalledWith({ strategy: 'password', password: 'secret', }); - await ready(result.current).prepare({ + await active(result.current).prepare({ id: 'phone_code:pn_1', stage: 'first', strategy: 'phone_code', @@ -259,8 +265,8 @@ describe('useReverificationModel', () => { session?.attemptSecondFactorVerification.mockResolvedValue(resource({ status: 'complete' })); const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: 'second_factor' })); - await ready(result.current).start(); - await ready(result.current).prepare({ + await active(result.current).start(); + await active(result.current).prepare({ id: 'phone_code:pn_1', stage: 'second', strategy: 'phone_code', @@ -272,7 +278,7 @@ describe('useReverificationModel', () => { phoneNumberId: 'pn_1', }); - await ready(result.current).attempt({ id: 'totp', stage: 'second', strategy: 'totp' }, '123456'); + await active(result.current).attempt({ id: 'totp', stage: 'second', strategy: 'totp' }, '123456'); expect(session?.attemptSecondFactorVerification).toHaveBeenCalledWith({ strategy: 'totp', code: '123456' }); }); @@ -280,7 +286,7 @@ describe('useReverificationModel', () => { session?.verifyWithPasskey.mockResolvedValue(resource({ status: 'complete' })); const { result } = renderHook(() => useReverificationModel(activeProps())); - await ready(result.current).attempt({ id: 'passkey', stage: 'first', strategy: 'passkey' }, ''); + await active(result.current).attempt({ id: 'passkey', stage: 'first', strategy: 'passkey' }, ''); expect(session?.verifyWithPasskey).toHaveBeenCalledOnce(); }); @@ -300,7 +306,7 @@ describe('useReverificationModel', () => { const { result } = renderHook(() => useReverificationModel(activeProps())); await expect( - ready(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'bad'), + active(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'bad'), ).rejects.toMatchObject({ message: 'That password is incorrect.' }); }); @@ -315,9 +321,9 @@ describe('useReverificationModel', () => { order.push('complete'); }); const { result } = renderHook(() => useReverificationModel(props)); - const started = await ready(result.current).start(); + const started = await active(result.current).start(); expect(started).toEqual({ status: 'complete', methods: [], startingMethod: null }); - await ready(result.current).finish(); + await active(result.current).finish(); expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' }); expect(order).toEqual(['setActive', 'complete']); }); @@ -327,7 +333,7 @@ describe('useReverificationModel', () => { const props = activeProps(); const { result } = renderHook(() => useReverificationModel(props)); - await expect(ready(result.current).finish()).rejects.toMatchObject({ message: 'Session could not be activated.' }); + await expect(active(result.current).finish()).rejects.toMatchObject({ message: 'Session could not be activated.' }); expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' }); expect(props.complete).not.toHaveBeenCalled(); }); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx index 22342fb0356..645285c2c08 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx @@ -5,7 +5,7 @@ import { Reverification } from '../reverification'; import type { ReverificationController } from '../reverification.controller'; import type { ReverificationViewProps } from '../reverification.types'; -let controller: ReverificationController = { status: 'idle', phase: 'inactive' }; +let controller: ReverificationController = { status: 'idle' }; vi.mock('../reverification.view', () => ({ ReverificationPending: () => , @@ -16,7 +16,6 @@ vi.mock('../reverification.view', () => ({ function ready(overrides: Partial = {}): ReverificationController { return { status: 'ready', - phase: 'active', step: 'password', value: '', onValueChange: vi.fn(), @@ -36,18 +35,18 @@ function ready(overrides: Partial = {}): Reverification describe('Reverification', () => { it('renders nothing while reverification is inactive', () => { - controller = { status: 'idle', phase: 'inactive' }; + controller = { status: 'idle' }; const { container } = render(); expect(container).toBeEmptyDOMElement(); }); it('renders pending, unavailable, or the factor view', () => { - controller = { status: 'loading', phase: 'active' }; + controller = { status: 'loading' }; const { rerender } = render(); expect(screen.getByTestId('pending')).toBeInTheDocument(); expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - controller = { status: 'unavailable', phase: 'active' }; + controller = { status: 'unavailable' }; rerender(); expect(screen.getByTestId('unavailable')).toBeInTheDocument(); expect(screen.queryByTestId('view')).not.toBeInTheDocument(); @@ -62,7 +61,7 @@ describe('Reverification', () => { const { rerender } = render(); expect(screen.getByTestId('view')).toHaveTextContent('password'); - controller = { status: 'idle', phase: 'inactive' }; + controller = { status: 'idle' }; rerender(); expect(screen.queryByTestId('view')).not.toBeInTheDocument(); }); diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index afb21c50018..9252d54ae71 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -4,29 +4,25 @@ 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 { ReverificationActiveModel, ReverificationModel } from './reverification.model'; import type { ReverificationMethod, ReverificationResult, - ReverificationState, ReverificationStep, ReverificationViewProps, } from './reverification.types'; import { needsPrepare, otpChannelFor } from './reverification.utils'; export type ReverificationController = - | { status: 'idle'; phase: 'inactive' } - | { status: 'loading'; phase: Exclude; onCancel?: () => void } - | { status: 'unavailable'; phase: Exclude; onCancel?: () => void } - | ({ - status: 'ready'; - phase: Exclude; - onCancel?: () => void; - } & ReverificationViewProps); + | { status: 'idle'; onCancel?: undefined } + | { status: 'loading'; onCancel?: () => void } + | { status: 'unavailable'; onCancel?: () => void } + | ({ status: 'ready'; onCancel?: () => void } & ReverificationViewProps) + | ({ status: 'retrying'; onCancel?: undefined } & ReverificationViewProps); type OverlayFrom = 'factor' | 'method-picker'; -export type ReverificationDeps = Pick; +export type ReverificationDeps = Pick; interface ReverificationContext { inputValue: string; @@ -116,8 +112,8 @@ 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 + target: 'finishing' as const, + // We don't apply the result here as we want to keep the old one visible as we are finishing }, { guard: (_: ReverificationContext, event: DoneInvokeEvent) => @@ -321,10 +317,10 @@ export const reverificationMachine = createMachine({ on: { RESET: 'inactive' }, }, - completing: { + finishing: { on: { RESET: 'inactive' }, invoke: fromPromise(ctx => ctx.deps.finish(), { - onDone: 'done', + onDone: 'retrying', onError: { target: 'verifying', actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), @@ -332,13 +328,16 @@ export const reverificationMachine = createMachine({ }), }, - done: { + // This means we are retrying the action after successful reverification + // This state is usually left by the model state going to 'inactive', + // which it does when the retry has completed + retrying: { on: { RESET: 'inactive' }, }, }, }); -const pendingStates = new Set(['submitting', 'completing']); +const pendingStates = new Set(['submitting', 'finishing', 'retrying']); function factorStep(method: ReverificationMethod): ReverificationStep { if (method.strategy === 'password') { @@ -360,7 +359,13 @@ function viewStep(value: string, method: ReverificationMethod | null): Reverific if (value === 'help') { return 'help'; } - if (value === 'verifying' || value === 'submitting' || value === 'preparing' || value === 'completing') { + if ( + value === 'verifying' || + value === 'submitting' || + value === 'preparing' || + value === 'finishing' || + value === 'retrying' + ) { if (!method) { return undefined; } @@ -372,26 +377,28 @@ function viewStep(value: string, method: ReverificationMethod | null): Reverific /** * Machine - State internal to the controller, not all steps are exposed to the UI * Return 'ReverificationController' - The view state + * - status: 'idle' renders nothing * - status: 'loading' | 'unavailable' carry no factor props - * - status: 'ready' carries the factor view, including step + * - status: 'ready' | 'retrying' carry the factor view, including step * * There are two pending presentations. * - status: 'loading' is the pending card rendered in place, before a factor exists * - status: 'ready' && isPending is the inline pending state of the current factor * - * `phase` is part of this return. The factor machine runs while `active` and resets only when - * `phase` returns to `inactive`. `retrying` keeps the last factor pending. + * 'retrying' is the point of no return: verification succeeded and the original action is being + * retried, so it keeps the last factor pending and has no `onCancel`. The factor machine runs + * while the model is active and resets when the model returns to inactive. */ export function useReverificationController(model: ReverificationModel): ReverificationController { - const ready = model.status === 'ready' ? model : null; + const active = model.status === 'active' ? model : null; const [snapshot, send] = useMachine( reverificationMachine, - ready + active ? { context: { - supportEmail: ready.supportEmail, - deps: ready, + supportEmail: active.supportEmail, + deps: active, }, } : undefined, @@ -410,16 +417,18 @@ export function useReverificationController(model: ReverificationModel): Reverif return () => window.clearInterval(id); }, [countingDown, resendAvailableAt]); - const reverificationPhase = model.phase; - const onCancel = - reverificationPhase === 'active' - ? () => { - send({ type: 'RESET' }); - model.cancel(); - } + const cancelModel = + (model.status === 'loading' || model.status === 'active') && snapshot.value !== 'retrying' + ? model.cancel : undefined; - const needsStart = reverificationPhase === 'active' && Boolean(ready) && snapshot.value === 'inactive'; - const needsReset = reverificationPhase === 'inactive' && snapshot.value !== 'inactive'; + const onCancel = cancelModel + ? () => { + send({ type: 'RESET' }); + cancelModel(); + } + : undefined; + const needsStart = active !== null && snapshot.value === 'inactive'; + const needsReset = model.status === 'inactive' && snapshot.value !== 'inactive'; useEffect(() => { if (needsStart) { send({ type: 'START' }); @@ -428,29 +437,21 @@ export function useReverificationController(model: ReverificationModel): Reverif } }, [needsStart, needsReset, send]); - if (reverificationPhase === 'inactive') { - return { status: 'idle', phase: reverificationPhase }; + if (model.status === 'inactive') { + return { status: 'idle' }; } const { context } = snapshot; const activeMethod = context.activeMethod; if (snapshot.value === 'unavailable') { - return { status: 'unavailable', phase: reverificationPhase, onCancel }; + return { status: 'unavailable', onCancel }; } - // If the action is retrying after success and the component is rendered, we stay - // on the last factor that was visible in a pending state until the retry is done - const step = - reverificationPhase === 'retrying' && snapshot.value === 'done' && activeMethod - ? factorStep(activeMethod) - : viewStep(snapshot.value, activeMethod); + const step = viewStep(snapshot.value, activeMethod); if (!step) { - if (snapshot.value === 'inactive' || snapshot.value === 'starting' || snapshot.value === 'done') { - return { status: 'loading', phase: reverificationPhase, onCancel }; - } - return { status: 'unavailable', phase: reverificationPhase, onCancel }; + return { status: 'loading', onCancel }; } // If we are currently on the alternative methods screen and preparing a factor, activeMethod will @@ -459,15 +460,13 @@ export function useReverificationController(model: ReverificationModel): Reverif const methods = context.methods.filter(method => method.id !== excludeId); const pendingMethodId = snapshot.value === 'methodPickerPreparing' ? activeMethod?.id : undefined; - return { - status: 'ready', - phase: reverificationPhase, + const view: ReverificationViewProps = { step, direction: context.direction, value: context.inputValue, onValueChange: (value: string) => send({ type: 'TYPE', value }), errorMessage: context.errorMessage, - isPending: reverificationPhase === 'retrying' || pendingStates.has(snapshot.value), + isPending: pendingStates.has(snapshot.value), onSubmit: () => send({ type: 'SUBMIT' }), onShowMethods: () => send({ type: 'SHOW_METHODS' }), onShowHelp: () => send({ type: 'SHOW_HELP' }), @@ -482,7 +481,6 @@ export function useReverificationController(model: ReverificationModel): Reverif onSelectMethod: (id: string) => send({ type: 'SELECT_METHOD', id }), otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, onResend: () => send({ type: 'RESEND' }), - onCancel, 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 @@ -495,4 +493,6 @@ export function useReverificationController(model: ReverificationModel): Reverif ? Math.min(RESEND_COOLDOWN_MS / 1000, Math.max(0, Math.ceil((resendAvailableAt - now) / 1000))) : undefined, }; + + return snapshot.value === 'retrying' ? { status: 'retrying', ...view } : { status: 'ready', onCancel, ...view }; } diff --git a/packages/mosaic/src/features/reverification/reverification.model.tsx b/packages/mosaic/src/features/reverification/reverification.model.tsx index b7c87b70745..0650c3f9cae 100644 --- a/packages/mosaic/src/features/reverification/reverification.model.tsx +++ b/packages/mosaic/src/features/reverification/reverification.model.tsx @@ -19,9 +19,8 @@ import type { } from './reverification.types'; import { pickStartingMethod } from './reverification.utils'; -export type ReverificationReadyModel = { - status: 'ready'; - phase: ReverificationState['phase']; +export type ReverificationActiveModel = { + status: 'active'; supportEmail: string; start: () => Promise; prepare: (method: ReverificationPreparableMethod) => Promise; @@ -31,8 +30,10 @@ export type ReverificationReadyModel = { }; export type ReverificationModel = - | { status: 'loading'; phase: ReverificationState['phase']; cancel: () => void } - | ReverificationReadyModel; + | { status: 'inactive' } + | { status: 'loading'; cancel: () => void } + | ReverificationActiveModel + | { status: 'retrying' }; function toError(error: unknown): Error { if (isClerkAPIResponseError(error)) { @@ -121,16 +122,19 @@ export function useReverificationModel(reverificationState: ReverificationState) const clerk = useClerk(); const environment = useMosaicEnvironment(); const supportEmail = useMosaicSupportEmail(); - const phase = reverificationState.phase; - const level = reverificationState.phase === 'active' ? reverificationState.level : undefined; - const cancel = reverificationState.phase === 'active' ? reverificationState.cancel : undefined; - const complete = reverificationState.phase === 'active' ? reverificationState.complete : undefined; - const cancelVerification = () => { - cancel?.(); - }; + + if (reverificationState.phase === 'inactive') { + return { status: 'inactive' }; + } + + if (reverificationState.phase === 'retrying') { + return { status: 'retrying' }; + } + + const { level, cancel, complete } = reverificationState; if (!session || !environment || supportEmail === undefined) { - return { status: 'loading', phase, cancel: cancelVerification }; + return { status: 'loading', cancel }; } const webAuthnSupported = isWebAuthnSupported(); @@ -140,8 +144,7 @@ export function useReverificationModel(reverificationState: ReverificationState) toResult(resource, preferredSignInStrategy, webAuthnSupported); return { - status: 'ready', - phase, + status: 'active', supportEmail, start: async () => { try { @@ -150,7 +153,7 @@ export function useReverificationModel(reverificationState: ReverificationState) throw toError(error); } }, - cancel: cancelVerification, + cancel, prepare: async method => { try { switch (method.strategy) { @@ -213,7 +216,7 @@ export function useReverificationModel(reverificationState: ReverificationState) finish: async () => { try { await clerk.setActive({ session: session.id }); - complete?.(); + complete(); } catch (error) { throw toError(error); } diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx index 4dd6a25508d..b4f1932c2d8 100644 --- a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -41,7 +41,7 @@ function CardHarness() { const [message, setMessage] = useState(null); const [requestPending, setRequestPending] = useState(false); const [deleteAccount, reverification] = useReverificationFlow(() => mockDelete(false)); - const busy = requestPending || reverification.phase !== 'inactive'; + const busy = requestPending || reverification.status !== 'idle'; return (
@@ -106,7 +106,7 @@ function DialogHarness() { const runRef = useRef(false); const nextStep: OuterStep | null = - reverification.phase === 'retrying' && presentation === 'replace' + reverification.status === 'retrying' && presentation === 'replace' ? 'finalizing' : reverification.status === 'ready' || reverification.status === 'unavailable' ? 'verify' @@ -118,7 +118,7 @@ function DialogHarness() { } const continueDelete = () => { - if (runRef.current || reverification.phase !== 'inactive') { + if (runRef.current || reverification.status !== 'idle') { return; } runRef.current = true; @@ -176,14 +176,14 @@ function DialogHarness() { if (next) { return; } - if (requestPending && reverification.phase === 'inactive') { + if (requestPending && reverification.status === 'idle') { return; } if (reverification.status !== 'idle') { reverification.onCancel?.(); } setOpen(false); - if (reverification.phase !== 'retrying') { + if (reverification.status !== 'retrying') { setErrorMessage(null); void resetMockDelete(); } @@ -273,7 +273,7 @@ function DestructiveHarness() { const runRef = useRef(false); const continueDelete = () => { - if (runRef.current || reverification.phase !== 'inactive') { + if (runRef.current || reverification.status !== 'idle') { return; } runRef.current = true; @@ -324,11 +324,11 @@ function DestructiveHarness() { { - if (!next && requestPending && reverification.phase === 'inactive') { + if (!next && requestPending && reverification.status === 'idle') { return; } setOpen(next); - if (!next && reverification.phase !== 'retrying') { + if (!next && reverification.status !== 'retrying') { setErrorMessage(null); void resetMockDelete(); } diff --git a/packages/swingset/src/stories/reverification.mdx b/packages/swingset/src/stories/reverification.mdx index 4cf0f1009ab..370d586c5cc 100644 --- a/packages/swingset/src/stories/reverification.mdx +++ b/packages/swingset/src/stories/reverification.mdx @@ -17,19 +17,16 @@ The example pauses briefly in each pending state. Enter `error` in password or b ## Usage -The feature wrapper reads Clerk state and drives the reverification flow. It renders nothing while `phase` is `inactive`. While a challenge is still preparing, the parent card shows a verification header, a centered spinner, and a disabled continue button, then shows the factor without a step transition. Keep the same card around the component for a ready factor, a pending retry, or the unavailable state. Isolated panel stories below are for visual development only. +The feature wrapper reads Clerk state and drives the reverification flow. It renders nothing while `status` is `idle`. While a challenge is still preparing, the parent card shows a verification header, a centered spinner, and a disabled continue button, then shows the factor without a step transition. Keep the same card around the component for a ready factor, a pending retry, or the unavailable state. Isolated panel stories below are for visual development only. ```tsx import { Card } from '@clerk/mosaic/components/card'; -import { Reverification } from '@clerk/mosaic/features/reverification'; +import { Reverification, useReverificationFlow } from '@clerk/mosaic/features/reverification'; + +const [runAction, reverification] = useReverificationFlow(action); - {}} - cancel={() => {}} - level='first_factor' - /> + ; ``` From 5e32fca388370fe2c610ff9b6863498f255b7b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 29 Sep 2026 14:55:13 +0200 Subject: [PATCH 23/40] Refactor Destructive+Reverification --- .../destructive.controller.test.ts | 57 +++++++++++++++- .../destructive/destructive.controller.ts | 11 +-- .../blocks/destructive/destructive.test.tsx | 67 ++++--------------- .../src/blocks/destructive/destructive.tsx | 28 +++----- .../user-profile-delete-section.tsx | 13 ++-- .../user-profile-delete-section.view.tsx | 6 +- .../app/(clerk)/live/reverification/page.tsx | 64 ++++-------------- packages/swingset/src/stories/destructive.mdx | 10 ++- 8 files changed, 119 insertions(+), 137 deletions(-) diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts index 526a1bddd85..04dac160383 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts @@ -150,11 +150,62 @@ describe('useDestructiveController', () => { expect(result.current.isDeleting).toBe(true); }); - it('passes reverification through', () => { + it('is on the confirm step without reverification', () => { + const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve() })); + + expect(result.current.step).toBe('confirm'); + }); + + it.each([ + ['idle', 'confirm'], + ['loading', 'confirm'], + ['unavailable', 'verify'], + ['ready', 'verify'], + ['retrying', 'verify'], + ] as const)('reverification status %s uses the %s step', (status, step) => { const { result } = renderHook(() => - useDestructiveController({ onDelete: () => Promise.resolve(), reverification: idleReverification }), + useDestructiveController({ + onDelete: () => Promise.resolve(), + reverification: { status } as ReverificationController, + }), ); - expect(result.current.reverification).toBe(idleReverification); + expect(result.current.step).toBe(step); + }); + + it('cancels reverification when the dialog closes', () => { + const onCancel = vi.fn(); + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => new Promise(() => {}), + reverification: { status: 'ready', onCancel } as ReverificationController, + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); + + act(() => result.current.onOpenChange(false)); + + expect(onCancel).toHaveBeenCalledOnce(); + expect(result.current.open).toBe(false); + }); + + it('does not cancel reverification when a close is ignored', () => { + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => new Promise(() => {}), + reverification: { status: 'retrying' } as ReverificationController, + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); + + act(() => result.current.onOpenChange(false)); + + expect(result.current.open).toBe(true); }); }); diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.ts index 01793da8321..43d9729b9c8 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.ts @@ -6,7 +6,7 @@ import type { DestructiveControlledProps } from './destructive'; export type DestructiveController = Pick< DestructiveControlledProps, - 'open' | 'isDeleting' | 'errorMessage' | 'onOpenChange' | 'reverification' + 'open' | 'isDeleting' | 'errorMessage' | 'onOpenChange' | 'step' > & { onDelete: () => Promise; openDestructiveDialog: () => void; @@ -36,12 +36,14 @@ export function useDestructiveController({ }; const isDeleting = status === 'open-pending'; + const step = + reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm'; return { open: status !== 'closed', isDeleting, errorMessage: status === 'open-error' ? destructiveState.errorMessage : undefined, - reverification, + step, openDestructiveDialog, onDelete: async () => { if (status === 'open-needs-confirmation' || status === 'open-error') { @@ -66,14 +68,15 @@ export function useDestructiveController({ } }, onOpenChange: nextIsOpen => { - const canCancel = Boolean(reverification?.onCancel); - if (!nextIsOpen && isDeleting && !canCancel) { + const cancelReverification = reverification?.onCancel; + if (!nextIsOpen && isDeleting && !cancelReverification) { return; } if (nextIsOpen) { setDestructiveState({ status: 'open-needs-confirmation' }); } else { + cancelReverification?.(); setDestructiveState({ status: 'closed' }); } }, diff --git a/packages/mosaic/src/blocks/destructive/destructive.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.test.tsx index 49104752346..38eb375d577 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.test.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.test.tsx @@ -5,15 +5,10 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../components/button'; import { Menu } from '../../components/menu'; -import type { ReverificationController } from '../../features/reverification'; import { MosaicProvider } from '../../MosaicProvider'; import type { DestructiveControlledProps, DestructiveHandleProps } from './destructive'; import { Destructive } from './destructive'; -vi.mock('../../features/reverification', () => ({ - Reverification: ({ status }: { status: string }) => {status}, -})); - function renderBlock(overrides: Partial = {}) { return render( @@ -33,29 +28,7 @@ function renderBlock(overrides: Partial = {}) { } const confirmButton = () => screen.getByRole('button', { name: 'Delete account' }); -const startingReverification = { - status: 'loading' as const, - onCancel: vi.fn(), -}; - -function readyReverification(status: 'ready' | 'retrying'): ReverificationController { - const view = { - step: 'password' as const, - value: '', - onValueChange: () => {}, - isPending: status === 'retrying', - onSubmit: () => {}, - onShowMethods: () => {}, - onShowHelp: () => {}, - onBack: () => {}, - onEmailSupport: () => {}, - onResend: () => {}, - canResend: false, - methods: [], - onSelectMethod: () => {}, - }; - return status === 'retrying' ? { status, ...view } : { status, ...view }; -} +const verificationSlot = Verify; describe('Destructive', () => { it('renders nothing until the caller opens it', () => { @@ -172,42 +145,26 @@ describe('Destructive', () => { expect(onDelete).not.toHaveBeenCalled(); }); - it('keeps the delete confirmation pending while verification is starting', () => { - renderBlock({ isDeleting: true, reverification: startingReverification }); + it('keeps the delete confirmation pending until the step is verify', () => { + renderBlock({ isDeleting: true, verificationSlot }); expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); expect(screen.getByRole('textbox')).toBeDisabled(); expect(confirmButton()).toHaveAttribute('aria-busy', 'true'); - expect(screen.queryByTestId('reverification')).not.toBeInTheDocument(); + expect(screen.queryByTestId('verification')).not.toBeInTheDocument(); }); - it('shows reverification in the same dialog and card', () => { - renderBlock({ isDeleting: true, reverification: readyReverification('ready') }); + it('shows the verification slot in the same dialog and card', () => { + renderBlock({ isDeleting: true, step: 'verify', verificationSlot }); expect(screen.getAllByRole('dialog')).toHaveLength(1); expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); - expect(screen.getByTestId('reverification')).toHaveTextContent('ready'); + expect(screen.getByTestId('verification')).toBeInTheDocument(); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); }); - it('cancels active reverification when the dialog closes', async () => { - const cancel = vi.fn(); - const onOpenChange = vi.fn(); - const user = userEvent.setup(); - renderBlock({ - isDeleting: true, - onOpenChange, - reverification: { ...startingReverification, onCancel: cancel }, - }); - - await user.keyboard('{Escape}'); - - expect(cancel).toHaveBeenCalledOnce(); - expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); - }); - - it('keeps the retrying factor visible until the hook becomes inactive', () => { + it('moves from the verify step back to confirm with the error', () => { const props = { open: true, onOpenChange: vi.fn(), @@ -223,19 +180,21 @@ describe('Destructive', () => { , ); expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); - expect(screen.getByTestId('reverification')).toHaveTextContent('retrying'); + expect(screen.getByTestId('verification')).toBeInTheDocument(); rerender( , ); diff --git a/packages/mosaic/src/blocks/destructive/destructive.tsx b/packages/mosaic/src/blocks/destructive/destructive.tsx index 41d96b429b1..b357c4c34f4 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.tsx @@ -8,7 +8,6 @@ import { Dialog } from '../../components/dialog'; import { Field } from '../../components/field'; import { Flow } from '../../components/flow'; import { Input } from '../../components/input'; -import { Reverification, type ReverificationController } from '../../features/reverification'; import { type FromPayload, resolveFromPayload as resolve } from '../../utils/resolve-from-payload'; import { useConfirmationController } from '../confirmation/confirmation.controller'; @@ -38,7 +37,10 @@ export interface DestructiveControlledProps { isDeleting?: boolean; /** Error message to display if the delete action fails */ errorMessage?: string; - reverification?: ReverificationController; + /** Which step of the dialog is showing. Only has an effect with `verificationSlot` (default: "confirm") */ + step?: 'confirm' | 'verify'; + /** Content shown in place of the confirmation while `step` is "verify", such as a verification flow. Without it the dialog has no verify step */ + verificationSlot?: ReactNode; } type DestructiveCardProps = Omit; @@ -55,7 +57,8 @@ function DestructiveCard({ onDelete, isDeleting = false, errorMessage, - reverification, + step = 'confirm', + verificationSlot, }: DestructiveCardProps) { const formId = useId(); const [typedValue, setTypedValue] = useState(''); @@ -69,8 +72,6 @@ function DestructiveCard({ }, [open]); const isConfirmed = typedValue === confirmationValue; - const step = - reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm'; // The action sits in the footer, outside the form, so `form={formId}` associates the two. // That is what makes Enter in the field submit. Both guards are re-checked here because @@ -137,7 +138,7 @@ function DestructiveCard({ ); - const content = reverification ? ( + const content = verificationSlot ? ( ( <> {confirmation} - {reverification ? : null} + {verificationSlot} )} @@ -169,24 +170,15 @@ function DestructiveCard({ ); } -function ControlledDestructive({ open, onOpenChange, trigger, reverification, ...props }: DestructiveControlledProps) { - const handleOpenChange: NonNullable = (...args) => { - const [nextOpen] = args; - if (!nextOpen && reverification && reverification.status !== 'idle') { - reverification.onCancel?.(); - } - onOpenChange(...args); - }; - +function ControlledDestructive({ open, onOpenChange, trigger, ...props }: DestructiveControlledProps) { return ( {trigger ? : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx index 294daef41f4..534eb8af7c7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx @@ -1,7 +1,7 @@ import { useClerk, useUser } from '@clerk/shared/react'; import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; -import { useReverificationFlow } from '../../reverification'; +import { Reverification, useReverificationFlow } from '../../reverification'; import { UserProfileDeleteSectionView } from './user-profile-delete-section.view'; export type UserProfileDeleteSectionProps = { @@ -36,10 +36,10 @@ export function UserProfileDeleteSection(props: UserProfileDeleteSectionProps) { }; // -- Controllers -- - const [deleteAccountWithReverification, reverificationState] = useReverificationFlow(deleteAccount); + const [deleteAccountWithReverification, reverification] = useReverificationFlow(deleteAccount); const destructiveController = useDestructiveController({ onDelete: deleteAccountWithReverification, - reverification: reverificationState, + reverification, }); // -- View -- @@ -51,5 +51,10 @@ export function UserProfileDeleteSection(props: UserProfileDeleteSectionProps) { return null; } - return ; + return ( + } + /> + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx index d5ecee39f4a..103e7d2c3ef 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx @@ -1,10 +1,14 @@ +import type { ReactNode } from 'react'; + import { Destructive } from '../../../blocks/destructive'; import type { DestructiveController } from '../../../blocks/destructive/destructive.controller'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { fill, useMessages } from '../../../localization'; -export function UserProfileDeleteSectionView(destructiveProps: DestructiveController) { +export function UserProfileDeleteSectionView( + destructiveProps: DestructiveController & { verificationSlot?: ReactNode }, +) { const m = useMessages('userProfileDeleteSection'); return ( diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx index b4f1932c2d8..6b6fb78f1a9 100644 --- a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { Destructive } from '@clerk/mosaic/blocks/destructive'; +import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import { Button } from '@clerk/mosaic/components/button'; import { Card } from '@clerk/mosaic/components/card'; import { Dialog } from '@clerk/mosaic/components/dialog'; @@ -265,45 +266,22 @@ function DialogHarness() { } function DestructiveHarness() { - const [deleteAccount, reverification] = useReverificationFlow(() => mockDelete(true)); - const [open, setOpen] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); const [successMessage, setSuccessMessage] = useState(null); - const [requestPending, setRequestPending] = useState(false); - const runRef = useRef(false); - - const continueDelete = () => { - if (runRef.current || reverification.status !== 'idle') { - return; - } - runRef.current = true; - setRequestPending(true); - setErrorMessage(null); - setSuccessMessage(null); - void (async () => { + const [deleteAccount, reverification] = useReverificationFlow(() => mockDelete(true)); + const destructive = useDestructiveController({ + onDelete: async () => { + setSuccessMessage(null); + await resetMockDelete(); try { - await resetMockDelete(); await deleteAccount(); setSuccessMessage('Mock delete completed. The account was not deleted.'); - setOpen(false); } catch (error) { - if (isClerkRuntimeError(error) && error.code === 'request_already_in_progress') { - return; - } await resetMockDelete(); - if (isReverificationCancelledError(error)) { - setErrorMessage(null); - setOpen(false); - return; - } - setErrorMessage(error instanceof Error ? error.message : 'Mock delete failed.'); - setOpen(true); - } finally { - runRef.current = false; - setRequestPending(false); + throw error; } - })(); - }; + }, + reverification, + }); return (
@@ -311,37 +289,23 @@ function DestructiveHarness() {
{successMessage ?

{successMessage}

: null} { - if (!next && requestPending && reverification.status === 'idle') { - return; - } - setOpen(next); - if (!next && reverification.status !== 'retrying') { - setErrorMessage(null); - void resetMockDelete(); - } - }} + {...destructive} title='Delete account?' description='This mock asks for verification, then waits before a fake success. The account is not deleted.' fieldLabel='Type “Delete account” below to continue' confirmationValue='Delete account' actionLabel='Delete account' - onDelete={continueDelete} - isDeleting={requestPending} - errorMessage={errorMessage ?? undefined} - reverification={reverification} + verificationSlot={} />
); diff --git a/packages/swingset/src/stories/destructive.mdx b/packages/swingset/src/stories/destructive.mdx index df7f0dc1085..76949cf9ca9 100644 --- a/packages/swingset/src/stories/destructive.mdx +++ b/packages/swingset/src/stories/destructive.mdx @@ -50,15 +50,19 @@ A rejection leaves the dialog up. The field is marked invalid for as long as the ## Reverification -Wrap the action with `useReverificationFlow` and pass the result to the controller. Confirmation and verification stay in the same dialog and card. +Wrap the action with `useReverificationFlow` and pass the result to the controller. The controller decides which step is showing, cancels the verification when the dialog closes, and blocks closing while the action is being retried. Render the verification in `verificationSlot`, and confirmation and verification stay in the same dialog and card. ```tsx -import { useReverificationFlow } from '@clerk/mosaic/features/reverification'; +import { Reverification, useReverificationFlow } from '@clerk/mosaic/features/reverification'; const [runDelete, reverification] = useReverificationFlow(deleteAccount); const destructive = useDestructiveController({ onDelete: runDelete, reverification }); -; +} +/>; ``` ## One block, many rows From 8ff3108660b63574aac02f16904dca92ff25a550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 29 Sep 2026 15:24:30 +0200 Subject: [PATCH 24/40] Format --- .../user-profile/user-profile-profile-panel.view.tsx | 1 - .../user-profile/user-profile-security-panel.view.tsx | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index 10fbec595d4..35924d464e2 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -1,4 +1,3 @@ - import type { ReactElement, ReactNode } from 'react'; import { useRef } from 'react'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index eb49f62f71a..9f1fe8b237d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -30,11 +30,11 @@ export type { export interface UserProfileSecurityPanelViewProps extends - Omit, - Pick< - UserProfilePasswordSectionViewProps, - 'hasPassword' | 'requiresCurrentPassword' | 'managedBy' | 'onSubmitPassword' - > { + Omit, + Pick< + UserProfilePasswordSectionViewProps, + 'hasPassword' | 'requiresCurrentPassword' | 'managedBy' | 'onSubmitPassword' + > { passkeys?: UserProfilePasskey[]; passkeysVisible?: boolean; mfaMethods?: UserProfileMfaMethod[]; From 1b138316ef699df9bb243ab411e965f16ac9eae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 29 Sep 2026 16:32:55 +0200 Subject: [PATCH 25/40] Fix import order --- packages/swingset/src/stories/fixtures/user-profile.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index 2115579375a..385c6b61857 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -1,10 +1,10 @@ import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import type { UserProfileViewProps } from '@clerk/mosaic/features/user-profile/user-profile.view'; -import { UserProfileDeleteSectionView } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.view'; import type { UserProfilePaymentMethod, UserProfileSubscription, } from '@clerk/mosaic/features/user-profile/user-profile-billing-panel.view'; +import { UserProfileDeleteSectionView } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.view'; import type { UserProfileEmail, UserProfilePhone, From 294b37be76134faa5e151a754bceb577c3b2073d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 30 Sep 2026 11:40:54 +0200 Subject: [PATCH 26/40] Remove verificationSlot again, let Destructive render Reverification directly --- .../destructive.controller.test.ts | 38 ++------------ .../destructive/destructive.controller.ts | 6 +-- .../blocks/destructive/destructive.test.tsx | 51 ++++++++++++++----- .../src/blocks/destructive/destructive.tsx | 15 +++--- .../user-profile-delete-section.tsx | 13 ++--- .../user-profile-delete-section.view.tsx | 6 +-- .../app/(clerk)/live/reverification/page.tsx | 1 - packages/swingset/src/stories/destructive.mdx | 10 ++-- 8 files changed, 58 insertions(+), 82 deletions(-) diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts index 04dac160383..f98de503414 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts @@ -150,27 +150,12 @@ describe('useDestructiveController', () => { expect(result.current.isDeleting).toBe(true); }); - it('is on the confirm step without reverification', () => { - const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve() })); - - expect(result.current.step).toBe('confirm'); - }); - - it.each([ - ['idle', 'confirm'], - ['loading', 'confirm'], - ['unavailable', 'verify'], - ['ready', 'verify'], - ['retrying', 'verify'], - ] as const)('reverification status %s uses the %s step', (status, step) => { + it('passes reverification through', () => { const { result } = renderHook(() => - useDestructiveController({ - onDelete: () => Promise.resolve(), - reverification: { status } as ReverificationController, - }), + useDestructiveController({ onDelete: () => Promise.resolve(), reverification: idleReverification }), ); - expect(result.current.step).toBe(step); + expect(result.current.reverification).toBe(idleReverification); }); it('cancels reverification when the dialog closes', () => { @@ -191,21 +176,4 @@ describe('useDestructiveController', () => { expect(onCancel).toHaveBeenCalledOnce(); expect(result.current.open).toBe(false); }); - - it('does not cancel reverification when a close is ignored', () => { - const { result } = renderHook(() => - useDestructiveController({ - onDelete: () => new Promise(() => {}), - reverification: { status: 'retrying' } as ReverificationController, - }), - ); - act(() => result.current.onOpenChange(true)); - act(() => { - void result.current.onDelete(); - }); - - act(() => result.current.onOpenChange(false)); - - expect(result.current.open).toBe(true); - }); }); diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.ts index 43d9729b9c8..f2ecc32fd0a 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.ts @@ -6,7 +6,7 @@ import type { DestructiveControlledProps } from './destructive'; export type DestructiveController = Pick< DestructiveControlledProps, - 'open' | 'isDeleting' | 'errorMessage' | 'onOpenChange' | 'step' + 'open' | 'isDeleting' | 'errorMessage' | 'onOpenChange' | 'reverification' > & { onDelete: () => Promise; openDestructiveDialog: () => void; @@ -36,14 +36,12 @@ export function useDestructiveController({ }; const isDeleting = status === 'open-pending'; - const step = - reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm'; return { open: status !== 'closed', isDeleting, errorMessage: status === 'open-error' ? destructiveState.errorMessage : undefined, - step, + reverification, openDestructiveDialog, onDelete: async () => { if (status === 'open-needs-confirmation' || status === 'open-error') { diff --git a/packages/mosaic/src/blocks/destructive/destructive.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.test.tsx index 38eb375d577..3ef0fcd28c1 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.test.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.test.tsx @@ -5,10 +5,15 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../components/button'; import { Menu } from '../../components/menu'; +import type { ReverificationController } from '../../features/reverification'; import { MosaicProvider } from '../../MosaicProvider'; import type { DestructiveControlledProps, DestructiveHandleProps } from './destructive'; import { Destructive } from './destructive'; +vi.mock('../../features/reverification', () => ({ + Reverification: ({ status }: { status: string }) => {status}, +})); + function renderBlock(overrides: Partial = {}) { return render( @@ -28,7 +33,29 @@ function renderBlock(overrides: Partial = {}) { } const confirmButton = () => screen.getByRole('button', { name: 'Delete account' }); -const verificationSlot = Verify; +const startingReverification = { + status: 'loading' as const, + onCancel: vi.fn(), +}; + +function readyReverification(status: 'ready' | 'retrying'): ReverificationController { + const view = { + step: 'password' as const, + value: '', + onValueChange: () => {}, + isPending: status === 'retrying', + onSubmit: () => {}, + onShowMethods: () => {}, + onShowHelp: () => {}, + onBack: () => {}, + onEmailSupport: () => {}, + onResend: () => {}, + canResend: false, + methods: [], + onSelectMethod: () => {}, + }; + return status === 'retrying' ? { status, ...view } : { status, ...view }; +} describe('Destructive', () => { it('renders nothing until the caller opens it', () => { @@ -145,26 +172,26 @@ describe('Destructive', () => { expect(onDelete).not.toHaveBeenCalled(); }); - it('keeps the delete confirmation pending until the step is verify', () => { - renderBlock({ isDeleting: true, verificationSlot }); + it('keeps the delete confirmation pending while verification is starting', () => { + renderBlock({ isDeleting: true, reverification: startingReverification }); expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); expect(screen.getByRole('textbox')).toBeDisabled(); expect(confirmButton()).toHaveAttribute('aria-busy', 'true'); - expect(screen.queryByTestId('verification')).not.toBeInTheDocument(); + expect(screen.queryByTestId('reverification')).not.toBeInTheDocument(); }); - it('shows the verification slot in the same dialog and card', () => { - renderBlock({ isDeleting: true, step: 'verify', verificationSlot }); + it('shows reverification in the same dialog and card', () => { + renderBlock({ isDeleting: true, reverification: readyReverification('ready') }); expect(screen.getAllByRole('dialog')).toHaveLength(1); expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); - expect(screen.getByTestId('verification')).toBeInTheDocument(); + expect(screen.getByTestId('reverification')).toHaveTextContent('ready'); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); }); - it('moves from the verify step back to confirm with the error', () => { + it('keeps the retrying factor visible until the hook becomes inactive', () => { const props = { open: true, onOpenChange: vi.fn(), @@ -180,21 +207,19 @@ describe('Destructive', () => { , ); expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); - expect(screen.getByTestId('verification')).toBeInTheDocument(); + expect(screen.getByTestId('reverification')).toHaveTextContent('retrying'); rerender( , ); diff --git a/packages/mosaic/src/blocks/destructive/destructive.tsx b/packages/mosaic/src/blocks/destructive/destructive.tsx index b357c4c34f4..6f63338e4f0 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.tsx @@ -8,6 +8,7 @@ import { Dialog } from '../../components/dialog'; import { Field } from '../../components/field'; import { Flow } from '../../components/flow'; import { Input } from '../../components/input'; +import { Reverification, type ReverificationController } from '../../features/reverification'; import { type FromPayload, resolveFromPayload as resolve } from '../../utils/resolve-from-payload'; import { useConfirmationController } from '../confirmation/confirmation.controller'; @@ -37,10 +38,7 @@ export interface DestructiveControlledProps { isDeleting?: boolean; /** Error message to display if the delete action fails */ errorMessage?: string; - /** Which step of the dialog is showing. Only has an effect with `verificationSlot` (default: "confirm") */ - step?: 'confirm' | 'verify'; - /** Content shown in place of the confirmation while `step` is "verify", such as a verification flow. Without it the dialog has no verify step */ - verificationSlot?: ReactNode; + reverification?: ReverificationController; } type DestructiveCardProps = Omit; @@ -57,8 +55,7 @@ function DestructiveCard({ onDelete, isDeleting = false, errorMessage, - step = 'confirm', - verificationSlot, + reverification, }: DestructiveCardProps) { const formId = useId(); const [typedValue, setTypedValue] = useState(''); @@ -72,6 +69,8 @@ function DestructiveCard({ }, [open]); const isConfirmed = typedValue === confirmationValue; + const step = + reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm'; // The action sits in the footer, outside the form, so `form={formId}` associates the two. // That is what makes Enter in the field submit. Both guards are re-checked here because @@ -138,7 +137,7 @@ function DestructiveCard({ ); - const content = verificationSlot ? ( + const content = reverification ? ( ( <> {confirmation} - {verificationSlot} + {reverification ? : null} )} diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx index 534eb8af7c7..294daef41f4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx @@ -1,7 +1,7 @@ import { useClerk, useUser } from '@clerk/shared/react'; import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; -import { Reverification, useReverificationFlow } from '../../reverification'; +import { useReverificationFlow } from '../../reverification'; import { UserProfileDeleteSectionView } from './user-profile-delete-section.view'; export type UserProfileDeleteSectionProps = { @@ -36,10 +36,10 @@ export function UserProfileDeleteSection(props: UserProfileDeleteSectionProps) { }; // -- Controllers -- - const [deleteAccountWithReverification, reverification] = useReverificationFlow(deleteAccount); + const [deleteAccountWithReverification, reverificationState] = useReverificationFlow(deleteAccount); const destructiveController = useDestructiveController({ onDelete: deleteAccountWithReverification, - reverification, + reverification: reverificationState, }); // -- View -- @@ -51,10 +51,5 @@ export function UserProfileDeleteSection(props: UserProfileDeleteSectionProps) { return null; } - return ( - } - /> - ); + return ; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx index 103e7d2c3ef..d5ecee39f4a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx @@ -1,14 +1,10 @@ -import type { ReactNode } from 'react'; - import { Destructive } from '../../../blocks/destructive'; import type { DestructiveController } from '../../../blocks/destructive/destructive.controller'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { fill, useMessages } from '../../../localization'; -export function UserProfileDeleteSectionView( - destructiveProps: DestructiveController & { verificationSlot?: ReactNode }, -) { +export function UserProfileDeleteSectionView(destructiveProps: DestructiveController) { const m = useMessages('userProfileDeleteSection'); return ( diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx index 6b6fb78f1a9..6b37915c0ce 100644 --- a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -305,7 +305,6 @@ function DestructiveHarness() { fieldLabel='Type “Delete account” below to continue' confirmationValue='Delete account' actionLabel='Delete account' - verificationSlot={} /> ); diff --git a/packages/swingset/src/stories/destructive.mdx b/packages/swingset/src/stories/destructive.mdx index 76949cf9ca9..f1b72efb103 100644 --- a/packages/swingset/src/stories/destructive.mdx +++ b/packages/swingset/src/stories/destructive.mdx @@ -50,19 +50,15 @@ A rejection leaves the dialog up. The field is marked invalid for as long as the ## Reverification -Wrap the action with `useReverificationFlow` and pass the result to the controller. The controller decides which step is showing, cancels the verification when the dialog closes, and blocks closing while the action is being retried. Render the verification in `verificationSlot`, and confirmation and verification stay in the same dialog and card. +Wrap the action with `useReverificationFlow` and pass the result to the controller. The controller cancels the verification when the dialog closes and blocks closing while the action is being retried. Confirmation and verification stay in the same dialog and card. ```tsx -import { Reverification, useReverificationFlow } from '@clerk/mosaic/features/reverification'; +import { useReverificationFlow } from '@clerk/mosaic/features/reverification'; const [runDelete, reverification] = useReverificationFlow(deleteAccount); const destructive = useDestructiveController({ onDelete: runDelete, reverification }); -} -/>; +; ``` ## One block, many rows From 68ab5a56adb5b4d3df418e3fb7499bdbdb2c7eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 30 Sep 2026 12:28:19 +0200 Subject: [PATCH 27/40] Reverification feature test setup --- .claude/skills/mosaic/references/testing.md | 24 +- .../mosaic/src/__tests__/feature/fake-fapi.ts | 16 +- .../feature/fake-fapi/verification.ts | 262 ++++++++++++++++++ packages/mosaic/src/__tests__/feature/fapi.ts | 52 ++++ .../__tests__/reverification.feature.test.tsx | 88 ++++++ 5 files changed, 436 insertions(+), 6 deletions(-) create mode 100644 packages/mosaic/src/__tests__/feature/fake-fapi/verification.ts create mode 100644 packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx diff --git a/.claude/skills/mosaic/references/testing.md b/.claude/skills/mosaic/references/testing.md index 39a52aec92f..af221d5e4d7 100644 --- a/.claude/skills/mosaic/references/testing.md +++ b/.claude/skills/mosaic/references/testing.md @@ -104,8 +104,9 @@ is the worked example. - **`fapi.ts`**: typed builders for FAPI JSON (`fapiEnvironment`, `fapiUser`, `fapiSession`, `fapiClient`, `fapiOrganization`, `fapiMembership`, - `fapiInvitation`, `fapiSuggestion`, `fapiToken`). They return the wire shapes - from `@clerk/shared/types`, so a fixture can't invent a field. + `fapiInvitation`, `fapiSuggestion`, `fapiToken`, `fapiSessionVerification`, + `fapiVerification`). They return the wire shapes from `@clerk/shared/types`, + so a fixture can't invent a field. `fapiEnvironment(overrides)` shallow-merges per section, for example `{ organization_settings: { enabled: false } }`. - **`fake-fapi.ts`**: @@ -115,6 +116,18 @@ is the worked example. - `holdRequests(method, path)` holds matching requests open. Assert the in-flight UI, then `release()` to let them through to `serveFapi`, or `fail(code)` to answer with a 400 Clerk error. +- **`fake-fapi/`**: fakes for larger endpoint families, kept out of + `fake-fapi.ts` and registered by `serveFapi`. + - `verification.ts` fakes session reverification (`/verify` and its + prepare and attempt endpoints). Seed it with + `serveFapi({ verification: { firstFactors, secondFactors, secrets } })`: + the factors the user has, and the password or code each strategy accepts. + The fake follows the server: level `second_factor` starts on the second + factor, `multi_factor` goes first factor then second, and a user with no + second factor is downgraded to `first_factor`. It rejects calls the server + rejects: before start, in the wrong status, with a strategy the user + doesn't have, and an email, phone, or passkey attempt that wasn't + prepared. Wrong passwords and codes answer 422. - **`render.tsx`**: `renderWithClerk(ui)` renders inside `ClerkContextProvider` and `MosaicProvider`, loads Clerk, and returns `{ clerk, navigate, ... }`. `navigate` is the router Clerk was loaded with, called with the path. @@ -149,8 +162,11 @@ click. - `setActive` only touches the session when the document has focus. The setup focuses the window, so don't blur it by accident. -- clerk-js treats a **422** as "unauthenticated" and refetches the client. Use - 400s for ordinary failures, which is what `holdRequests(...).fail()` sends. +- clerk-js treats a **422** from a session touch (`setActive`, focus) or a token + request as "unauthenticated" and refetches the client. Answer those with + 400s, which is what `holdRequests(...).fail()` sends. Form errors on other + endpoints are real 422s, for example a wrong password or code, and are + handled as ordinary errors. - After a touch, `getToken` is served from the token the touch returned, so there is no tokens request to hold during an organization switch. To observe Clerk's transitive state, route the action through navigation and make diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index f622ef0d6a3..a9bb741067d 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -8,6 +8,12 @@ import type { import { http, HttpResponse, type JsonBodyType } from 'msw'; import { setupWorker } from 'msw/browser'; +import { + createVerificationState, + type FakeVerificationSeed, + type FakeVerificationState, + verificationHandlers, +} from './fake-fapi/verification'; import { fapiClient, type FapiEnvironment, @@ -29,9 +35,12 @@ export interface FakeFapiState { memberships: OrganizationMembershipJSON[]; invitations: UserOrganizationInvitationJSON[]; suggestions: OrganizationSuggestionJSON[]; + verification: FakeVerificationState; } -export type FakeFapiSeed = Partial; +export type FakeFapiSeed = Partial> & { + verification?: FakeVerificationSeed; +}; const unhandled: string[] = []; @@ -76,16 +85,19 @@ function missing() { } export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { + const { verification, ...rest } = seed; const state: FakeFapiState = { environment: fapiEnvironment(), client: fapiClient(), memberships: [], invitations: [], suggestions: [], - ...seed, + ...rest, + verification: createVerificationState(verification), }; worker.use( + ...verificationHandlers(state, fapiUrl), http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)), http.get(fapiUrl('/v1/client'), () => envelope(state.client, null)), http.post(fapiUrl('/v1/client/sessions/:id/tokens'), ({ params }) => { diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/verification.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/verification.ts new file mode 100644 index 00000000000..3f699fbc766 --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/verification.ts @@ -0,0 +1,262 @@ +import type { + ClientJSON, + SessionJSON, + SessionVerificationLevel, + SessionVerificationStatus, + SignInFirstFactorJSON, + SignInSecondFactorJSON, + VerificationJSON, +} from '@clerk/shared/types'; +import { http, HttpResponse } from 'msw'; + +import { fapiSessionVerification, fapiVerification } from '../fapi'; + +export type VerificationSecretStrategy = 'password' | 'email_code' | 'phone_code' | 'totp' | 'backup_code'; + +export interface FakeVerificationState { + level: SessionVerificationLevel; + status: SessionVerificationStatus | null; + firstFactors: SignInFirstFactorJSON[]; + secondFactors: SignInSecondFactorJSON[]; + secrets: Partial>; + firstFactorVerification: VerificationJSON | null; + secondFactorVerification: VerificationJSON | null; +} + +export type FakeVerificationSeed = Partial>; + +export interface VerificationHost { + client: ClientJSON; + verification: FakeVerificationState; +} + +export function createVerificationState(seed: FakeVerificationSeed = {}): FakeVerificationState { + return { + level: 'first_factor', + status: null, + firstFactors: [{ strategy: 'password' }], + secondFactors: [], + secrets: {}, + firstFactorVerification: null, + secondFactorVerification: null, + ...seed, + }; +} + +function envelope(response: object, client: ClientJSON) { + return HttpResponse.json({ response, client }); +} + +function findSession(state: VerificationHost, id: unknown): SessionJSON | undefined { + return state.client.sessions.find(session => session.id === id); +} + +function missing() { + return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); +} + +type Stage = 'first' | 'second'; + +type VerificationRequest = { params: Record; request: Request }; + +const REVERIFICATION_LEVELS: readonly string[] = ['first_factor', 'second_factor', 'multi_factor']; +const PREPARABLE_STRATEGIES: readonly string[] = ['email_code', 'phone_code', 'passkey']; +const CODE_STRATEGIES: readonly string[] = ['email_code', 'phone_code', 'passkey']; + +function apiError(status: number, code: string, message: string, longMessage = message, paramName?: string) { + return HttpResponse.json( + { + errors: [ + { + code, + message, + long_message: longMessage, + ...(paramName ? { meta: { param_name: paramName } } : {}), + }, + ], + }, + { status }, + ); +} + +const verificationErrors = { + notStarted: () => + apiError( + 400, + 'session_reverification_missing', + 'is missing', + 'You need to start a new session verification flow first', + ), + invalidState: (action: string, resolution: string) => + apiError( + 400, + 'invalid_action_for_session_reverification', + 'Invalid action for session reverification', + `We were unable to ${action} for this session, as it's not ready for ${resolution}.`, + ), + invalidParam: (param: string, value: string | null) => + apiError( + 422, + 'form_param_value_invalid', + 'is invalid', + `${value} does not match one of the allowed values for parameter ${param}`, + param, + ), + missingResource: (param: string) => + apiError( + 422, + 'form_resource_not_found', + 'is missing', + `The resource associated with the supplied ${param} was not found.`, + param, + ), + notSent: () => + apiError( + 400, + 'verification_not_sent', + 'not sent', + 'You need to send a verification code before attempting to verify.', + ), + passwordIncorrect: () => + apiError( + 422, + 'form_password_incorrect', + 'Password is incorrect. Try again, or use another method.', + undefined, + 'password', + ), + codeIncorrect: () => apiError(422, 'form_code_incorrect', 'is incorrect', 'Incorrect code', 'code'), +}; + +function verificationReply(state: VerificationHost, session: SessionJSON) { + const { level, status, firstFactors, secondFactors, firstFactorVerification, secondFactorVerification } = + state.verification; + const complete = status === 'complete'; + return envelope( + fapiSessionVerification(session, { + level, + status: status ?? 'needs_first_factor', + supported_first_factors: complete ? null : firstFactors, + supported_second_factors: status === 'needs_second_factor' ? secondFactors : null, + first_factor_verification: firstFactorVerification, + second_factor_verification: secondFactorVerification, + }), + state.client, + ); +} + +function setStageVerification(state: VerificationHost, stage: Stage, verification: VerificationJSON) { + state.verification = + stage === 'first' + ? { ...state.verification, firstFactorVerification: verification } + : { ...state.verification, secondFactorVerification: verification }; +} + +export function verificationHandlers(state: VerificationHost, url: (path: string) => string) { + const guard = (stage: Stage, action: string, session: SessionJSON | undefined, strategy: string | null) => { + if (!session) { + return missing(); + } + const { status, firstFactors, secondFactors } = state.verification; + if (status === null) { + return verificationErrors.notStarted(); + } + const expected = stage === 'first' ? 'needs_first_factor' : 'needs_second_factor'; + if (status !== expected) { + return verificationErrors.invalidState(`${action} ${stage} factor`, `${stage} factor verification`); + } + const factors: Array<{ strategy: string }> = stage === 'first' ? firstFactors : secondFactors; + if (!factors.some(factor => factor.strategy === strategy)) { + return verificationErrors.invalidParam('strategy', strategy); + } + return undefined; + }; + + const prepare = + (stage: Stage) => + async ({ params, request }: VerificationRequest) => { + const session = findSession(state, params.id); + const body = new URLSearchParams(await request.text()); + const strategy = body.get('strategy'); + const rejected = + guard(stage, 'prepare', session, strategy) ?? + (strategy && PREPARABLE_STRATEGIES.includes(strategy) + ? undefined + : verificationErrors.invalidParam('strategy', strategy)); + if (rejected || !session || !strategy) { + return rejected; + } + const idParam = + strategy === 'email_code' ? 'email_address_id' : strategy === 'phone_code' ? 'phone_number_id' : null; + if (idParam) { + const factors: Array> = + stage === 'first' ? state.verification.firstFactors : state.verification.secondFactors; + const id = body.get(idParam); + if (!factors.some(factor => factor.strategy === strategy && factor[idParam] === id)) { + return verificationErrors.missingResource(idParam); + } + } + setStageVerification(state, stage, fapiVerification(strategy)); + return verificationReply(state, session); + }; + + const attempt = + (stage: Stage) => + async ({ params, request }: VerificationRequest) => { + const session = findSession(state, params.id); + const body = new URLSearchParams(await request.text()); + const strategy = body.get('strategy'); + const rejected = guard(stage, 'attempt', session, strategy); + if (rejected || !session || !strategy) { + return rejected; + } + const prepared = + stage === 'first' ? state.verification.firstFactorVerification : state.verification.secondFactorVerification; + if (CODE_STRATEGIES.includes(strategy) && prepared?.strategy !== strategy) { + return verificationErrors.notSent(); + } + if (strategy === 'password') { + if (body.get('password') !== state.verification.secrets.password) { + return verificationErrors.passwordIncorrect(); + } + } else if (strategy !== 'passkey') { + if (body.get('code') !== state.verification.secrets[strategy as VerificationSecretStrategy]) { + return verificationErrors.codeIncorrect(); + } + } + setStageVerification(state, stage, fapiVerification(strategy, { status: 'verified', attempts: 1 })); + const status = + stage === 'first' && state.verification.level === 'multi_factor' ? 'needs_second_factor' : 'complete'; + state.verification = { ...state.verification, status }; + return verificationReply(state, session); + }; + + return [ + http.post(url('/v1/client/sessions/:id/verify'), async ({ params, request }) => { + const session = findSession(state, params.id); + if (!session) { + return missing(); + } + const requested = new URLSearchParams(await request.text()).get('level'); + if (!requested || !REVERIFICATION_LEVELS.includes(requested)) { + return verificationErrors.invalidParam('level', requested); + } + const level = + requested === 'first_factor' || state.verification.secondFactors.length === 0 + ? 'first_factor' + : (requested as SessionVerificationLevel); + state.verification = { + ...state.verification, + level, + status: level === 'second_factor' ? 'needs_second_factor' : 'needs_first_factor', + firstFactorVerification: null, + secondFactorVerification: null, + }; + return verificationReply(state, session); + }), + http.post(url('/v1/client/sessions/:id/verify/prepare_first_factor'), prepare('first')), + http.post(url('/v1/client/sessions/:id/verify/prepare_second_factor'), prepare('second')), + http.post(url('/v1/client/sessions/:id/verify/attempt_first_factor'), attempt('first')), + http.post(url('/v1/client/sessions/:id/verify/attempt_second_factor'), attempt('second')), + ]; +} diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index 1473d6e67c6..b8d310f53d8 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -11,12 +11,15 @@ import type { OrganizationMembershipJSON, OrganizationSettingsJSON, OrganizationSuggestionJSON, + PublicKeyCredentialRequestOptionsJSON, PublicOrganizationDataJSON, SessionJSON, + SessionVerificationJSON, TokenJSON, UserJSON, UserOrganizationInvitationJSON, UserSettingsJSON, + VerificationJSON, } from '@clerk/shared/types'; type Settings = Omit; @@ -292,6 +295,55 @@ export function fapiSession(overrides: Partial & Pick = { + password: 'verification_password', + email_code: 'verification_otp', + phone_code: 'verification_otp', + totp: 'verification_totp', + backup_code: 'verification_backup_code', + passkey: 'verification_passkey', +}; + +export function fapiPasskeyRequestOptions(): PublicKeyCredentialRequestOptionsJSON { + return { + allowCredentials: [], + challenge: 'Y2hhbGxlbmdl', + rpId: 'clerk.abcef.12345.prod.lclclerk.com', + timeout: 60000, + userVerification: 'preferred', + }; +} + +export function fapiVerification(strategy: string, overrides: Partial = {}): VerificationJSON { + return { + object: verificationObjects[strategy] ?? 'verification', + status: 'unverified', + strategy, + attempts: 0, + expire_at: farFuture, + ...(strategy === 'passkey' ? { nonce: JSON.stringify(fapiPasskeyRequestOptions()) } : {}), + ...overrides, + } as VerificationJSON; +} + +export function fapiSessionVerification( + session: SessionJSON, + overrides: Partial = {}, +): SessionVerificationJSON { + return { + object: 'session_verification', + id: `sessverif_${session.id}`, + status: 'needs_first_factor', + level: 'first_factor', + session, + first_factor_verification: null, + second_factor_verification: null, + supported_first_factors: [], + supported_second_factors: [], + ...overrides, + }; +} + export function fapiClient(sessions: SessionJSON[] = []): ClientJSON { return { object: 'client', diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx new file mode 100644 index 00000000000..2ebaa4971b0 --- /dev/null +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -0,0 +1,88 @@ +import { reverificationError } from '@clerk/shared/authorization-errors'; +import type { ReverificationConfig } from '@clerk/shared/types'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, it, vi } from 'vitest'; + +import { type FakeFapiSeed, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiEmailAddress, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { Card } from '../../../components/card'; +import { Reverification, useReverificationFlow } from '../reverification'; + +const alice = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })], +}); + +const aliceSession = fapiSession({ id: 'sess_1', user: alice }); + +function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed { + return { client: fapiClient([aliceSession]), ...overrides }; +} + +function guardedAction(config?: ReverificationConfig) { + return vi.fn().mockResolvedValueOnce(reverificationError(config)).mockResolvedValue({ done: true }); +} + +function errorCode(error: unknown) { + if (error instanceof Error) { + return 'code' in error && typeof error.code === 'string' ? error.code : error.message; + } + return 'unknown'; +} + +function Host({ action }: { action: () => Promise }) { + const [run, reverification] = useReverificationFlow(action); + const [outcome, setOutcome] = useState(''); + + return ( + <> + + {outcome} + + + {reverification.onCancel ? ( + + ) : null} + + + ); +} + +function renderReverification(action = guardedAction(), seed: FakeFapiSeed = signedIn()) { + const fapi = serveFapi(seed); + return renderWithClerk().then(view => ({ ...view, fapi, action })); +} + +async function startVerification() { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Run action' })); + return user; +} + +const outcome = () => screen.getByLabelText('Outcome').textContent; +const dismiss = (user: ReturnType) => + user.click(screen.getByRole('button', { name: 'Dismiss' })); + +describe('Reverification', () => { + it.todo('completes the challenge and retries the action'); +}); From ad08b9df666c7189f15fbb0998341cc40039dfee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 30 Sep 2026 16:14:05 +0200 Subject: [PATCH 28/40] Restore step in destructive controller --- .../destructive.controller.test.ts | 23 +++++++++++++++++++ .../destructive/destructive.controller.ts | 11 +++++---- .../blocks/destructive/destructive.test.tsx | 4 +++- .../src/blocks/destructive/destructive.tsx | 5 ++-- packages/swingset/src/stories/destructive.mdx | 2 +- 5 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts index f98de503414..d4cf5b8ef62 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts @@ -158,6 +158,29 @@ describe('useDestructiveController', () => { expect(result.current.reverification).toBe(idleReverification); }); + it('is on the confirm step without reverification', () => { + const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve() })); + + expect(result.current.step).toBe('confirm'); + }); + + it.each([ + ['idle', 'confirm'], + ['loading', 'confirm'], + ['unavailable', 'verify'], + ['ready', 'verify'], + ['retrying', 'verify'], + ] as const)('reverification status %s uses the %s step', (status, step) => { + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => Promise.resolve(), + reverification: { status } as ReverificationController, + }), + ); + + expect(result.current.step).toBe(step); + }); + it('cancels reverification when the dialog closes', () => { const onCancel = vi.fn(); const { result } = renderHook(() => diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.ts index f2ecc32fd0a..fec4a2b6124 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.ts @@ -6,7 +6,7 @@ import type { DestructiveControlledProps } from './destructive'; export type DestructiveController = Pick< DestructiveControlledProps, - 'open' | 'isDeleting' | 'errorMessage' | 'onOpenChange' | 'reverification' + 'open' | 'isDeleting' | 'errorMessage' | 'onOpenChange' | 'reverification' | 'step' > & { onDelete: () => Promise; openDestructiveDialog: () => void; @@ -36,12 +36,15 @@ export function useDestructiveController({ }; const isDeleting = status === 'open-pending'; + const step = + reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm'; return { open: status !== 'closed', isDeleting, errorMessage: status === 'open-error' ? destructiveState.errorMessage : undefined, reverification, + step, openDestructiveDialog, onDelete: async () => { if (status === 'open-needs-confirmation' || status === 'open-error') { @@ -66,15 +69,15 @@ export function useDestructiveController({ } }, onOpenChange: nextIsOpen => { - const cancelReverification = reverification?.onCancel; - if (!nextIsOpen && isDeleting && !cancelReverification) { + const canCancel = Boolean(reverification?.onCancel); + if (!nextIsOpen && isDeleting && !canCancel) { return; } if (nextIsOpen) { setDestructiveState({ status: 'open-needs-confirmation' }); } else { - cancelReverification?.(); + reverification?.onCancel?.(); setDestructiveState({ status: 'closed' }); } }, diff --git a/packages/mosaic/src/blocks/destructive/destructive.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.test.tsx index 3ef0fcd28c1..dcf2d17d95d 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.test.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.test.tsx @@ -182,7 +182,7 @@ describe('Destructive', () => { }); it('shows reverification in the same dialog and card', () => { - renderBlock({ isDeleting: true, reverification: readyReverification('ready') }); + renderBlock({ isDeleting: true, step: 'verify', reverification: readyReverification('ready') }); expect(screen.getAllByRole('dialog')).toHaveLength(1); expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); @@ -207,6 +207,7 @@ describe('Destructive', () => {
, @@ -219,6 +220,7 @@ describe('Destructive', () => {
, diff --git a/packages/mosaic/src/blocks/destructive/destructive.tsx b/packages/mosaic/src/blocks/destructive/destructive.tsx index 6f63338e4f0..cec3702b8ea 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.tsx @@ -39,6 +39,7 @@ export interface DestructiveControlledProps { /** Error message to display if the delete action fails */ errorMessage?: string; reverification?: ReverificationController; + step?: 'confirm' | 'verify'; } type DestructiveCardProps = Omit; @@ -56,6 +57,7 @@ function DestructiveCard({ isDeleting = false, errorMessage, reverification, + step = 'confirm', }: DestructiveCardProps) { const formId = useId(); const [typedValue, setTypedValue] = useState(''); @@ -69,9 +71,6 @@ function DestructiveCard({ }, [open]); const isConfirmed = typedValue === confirmationValue; - const step = - reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm'; - // The action sits in the footer, outside the form, so `form={formId}` associates the two. // That is what makes Enter in the field submit. Both guards are re-checked here because // neither spelling stops a native submit: `focusableWhenDisabled` only marks the button diff --git a/packages/swingset/src/stories/destructive.mdx b/packages/swingset/src/stories/destructive.mdx index f1b72efb103..4dd9998b8ac 100644 --- a/packages/swingset/src/stories/destructive.mdx +++ b/packages/swingset/src/stories/destructive.mdx @@ -50,7 +50,7 @@ A rejection leaves the dialog up. The field is marked invalid for as long as the ## Reverification -Wrap the action with `useReverificationFlow` and pass the result to the controller. The controller cancels the verification when the dialog closes and blocks closing while the action is being retried. Confirmation and verification stay in the same dialog and card. +Wrap the action with `useReverificationFlow` and pass the result to the controller. The controller decides which step is showing, cancels the verification when the dialog closes, and blocks closing while the action is being retried. Confirmation and verification stay in the same dialog and card. ```tsx import { useReverificationFlow } from '@clerk/mosaic/features/reverification'; From 7a5968bbacb01f49507e3b0449473de4e089bba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 30 Sep 2026 16:52:50 +0200 Subject: [PATCH 29/40] Add initial feature test stubs --- .../__tests__/reverification.feature.test.tsx | 347 +++++++++++++++++- 1 file changed, 346 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index 2ebaa4971b0..1475931458e 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -84,5 +84,350 @@ const dismiss = (user: ReturnType) => user.click(screen.getByRole('button', { name: 'Dismiss' })); describe('Reverification', () => { - it.todo('completes the challenge and retries the action'); + describe('starting', () => { + it.todo('passes through when no reverification is needed', () => { + // Nothing is rendered before the action is run + // The action resolves with its own result + // No challenge card opens + }); + + it.todo('opens on the first factor when the hint asks for the first factor level', () => { + // The first factor step opens + // No second factor step follows + }); + + it.todo('opens on the second factor when the hint asks for the second factor level', () => { + // The second factor step opens without a first factor step + }); + + it.todo('starts verification at the second factor level when the hint carries no level', () => { + // The second factor step opens + }); + + it.todo('lets the user dismiss the pending card while verification is starting', () => { + // A pending card is shown while the start request is in flight + // Dismissing rejects the action with reverification_cancelled + // The card closes + }); + + // This can't use renderWithClerk since that awaits Clerk.load() + it.todo('shows the pending card while Clerk is still loading', () => { + // A pending card is shown while the session or environment is missing + // The first step opens once Clerk is ready + }); + + // Currently wrong: entering `unavailable` calls cancel(), which closes the challenge, so the card only flashes and then disappears. Needs a product decision (keep the card until dismissed, or assert only the rejection). + it.todo('shows the unavailable card when verification cannot be started', () => { + // The unavailable card is shown + // The action is rejected as cancelled + }); + + // Currently wrong: same flash as above. Enterprise SSO is dropped by the model mapping, which leaves no usable factor. + it.todo('shows the unavailable card when none of the factors are supported', () => { + // The unavailable card is shown when the only factor is enterprise SSO + // The action is rejected as cancelled + }); + }); + + describe('choosing the starting method', () => { + it.todo('starts with a passkey when the browser supports WebAuthn and the user has one', () => { + // The passkey step opens even when the instance prefers the password + }); + + it.todo('starts with the password when the instance prefers password sign in', () => { + // The password step opens + }); + + it.todo('starts with a code method when the instance prefers code sign in', () => { + // The code step opens for the email address or phone number + }); + + it.todo('starts the second factor on the authenticator app when the user has one', () => { + // The authenticator app step opens, even when a phone code is also available + }); + + it.todo('starts the second factor on a phone code when there is no authenticator app', () => { + // The phone code step opens + }); + + it.todo('starts the second factor on the first remaining method when there is neither', () => { + // The first listed method opens + }); + + // This is a future behavior we will need to account for, here's the PR that builds it for the existing reverification: https://github.com/clerk/javascript/pull/9127 + it.todo('offer a passkey listed as a second factor', () => { + // The passkey is not offered on the second factor step + }); + }); + + describe('password', () => { + // Currently wrong: the error shown is the server's English text, not a localized message. See the errors group. + it.todo('recovers from a wrong password and completes the challenge', () => { + // The password step opens when the action needs reverification + // A wrong password shows the server message and stays on the step + // The typed value is kept after the error + // Typing again clears the error + // Pressing Enter submits the password + // The form is disabled and shows the verifying label while the attempt is in flight + // The last step stays visible and pending while the session is being activated + // The action is retried once the session is activated and resolves with its result + // The card closes once the retried action has settled + }); + }); + + describe('email code', () => { + // Unsure about test implementation: the fake has no request log, so counting requests needs holdRequests or counters added to the fake. + // Currently wrong: the error shown is the server's English text, not a localized message. See the errors group. + it.todo('sends a code and completes the challenge with the correct code', () => { + // A code is sent once when the step opens + // The field is usable while the code is being sent + // A wrong code submits automatically on the last digit and shows the server error + // The field is disabled and shows the verifying label while the attempt is in flight + // The correct code completes the challenge + }); + + it.todo('holds a submit made while a code is being sent', () => { + // A submit made while the first code is being sent is held + // It is sent once the code has been sent + // The same holds for a submit made while a resend is in flight + }); + + it.todo('discards a held submit when sending the code fails', () => { + // The error is shown on the step + // The held submit is not sent + // The user can resend immediately + }); + + // Unsure about test implementation: resend is locked for 30s after the first prepare, so every resend case needs fake timers, and mixing fake Date with MSW, Clerk and userEvent in Chromium is untested. + it.todo('resends a code once the cooldown has passed', () => { + // Resend is disabled and shows the countdown while the cooldown runs + // Resend is enabled again after the cooldown + // Resending sends a new code and clears the typed value + }); + }); + + describe('phone code', () => { + it.todo('sends an SMS code for the first factor and completes the challenge', () => { + // The SMS code is sent through the first factor endpoints when the step opens + // The correct code completes the challenge + }); + }); + + describe('moving from the first to the second factor', () => { + it.todo('completes a multi factor challenge', () => { + // The first factor step opens first + // The second factor step opens after the first factor is verified + // The second factor starts with an empty field and no error + // The picker lists second factors only + // The SMS code is sent through the second factor endpoints when the step opens + // The correct code completes the challenge + }); + }); + + describe('second factor alternatives', () => { + // Currently wrong: the error shown is the server's English text, not a localized message. See the errors group. + it.todo('switches from the authenticator app to a backup code', () => { + // The authenticator app step opens first + // The picker offers the backup code + // An invalid backup code shows the server error + // A valid backup code completes the challenge + }); + }); + + describe('passkey', () => { + // Unsure about test implementation: the fake needs clerk.__internal_getPublicCredentials stubbed to provide a credential. + // Currently wrong: the banner shows the browser or clerk-js error message as is, which is not localized. + it.todo('completes the challenge with the passkey', () => { + // The passkey step opens + // A refused credential shows an error banner and stays on the step + // The button is disabled and shows the verifying label while the credential is being verified + // A second attempt with an accepted credential completes the challenge + }); + + // Unsure about test implementation: needs window.PublicKeyCredential stubbed out, and it is not certain isWebAuthnSupported() reacts to that in Chromium. + it.todo('does not offer a passkey when the browser does not support WebAuthn', () => { + // The passkey is not the starting method + // The passkey is not listed in the picker + }); + }); + + describe('switching methods', () => { + // Unsure about behavior: Back keeps the typed value while picking a method clears it. It is not clear whether keeping it on Back is intended. + // Unsure about test implementation: focus is not moved for the initially active step, only on transitions, so focus must not be asserted on the first step. + it.todo('walks through the picker and help steps', () => { + // The picker lists every method except the one currently shown + // Each email address and phone number is its own method + // Help opens from the picker and offers to email support + // Back from help returns to the picker + // Back from the picker returns to the method the user came from + // Focus moves to the first field of the step the user switches to + }); + + it.todo('does not offer another method when the user has only one', () => { + // "Use another method" is not shown + }); + + it.todo('does not list factors Mosaic cannot verify', () => { + // Enterprise SSO is not listed in the picker + }); + + it.todo('completes the challenge with a method picked from the picker', () => { + // Picking the password shows the password step without sending anything + // Picking an email code shows a pending row and disables the other actions while the code is sent + // The code step opens with an empty field and no error once the code is sent + // Picking a phone code sends the SMS code through the first factor endpoints + // The correct code completes the challenge + }); + + it.todo('lands on the picked method with an error when the code cannot be sent', () => { + // The code step opens for the picked method + // The error is shown on the step + }); + + it.todo('shows the support email from the environment', () => { + // The help step shows the configured support email + }); + + it.todo('falls back to the default support email when the environment has none', () => { + // The help step shows the default support email + }); + }); + + describe('finishing', () => { + // Currently wrong: the server verification is already complete at this point, so resubmitting the same code fails with invalid_action_for_session_reverification and the user can't recover. Needs a product decision (retry finish, or restart the verification). + it.todo('returns to the last step with an error when the session cannot be activated', () => { + // The last step stays visible and pending while the session is being activated + // The error is shown on the last step + // The action is not retried + }); + + it.todo('cannot be interrupted while the action is being retried', () => { + // The last step stays visible and pending while the action is being retried + // Dismissing is not available + // A second call rejects with request_already_in_progress + // An error thrown by the retried action rejects the action and closes the card + // A later call opens a new challenge from the first step with empty fields + }); + }); + + describe('dismissing', () => { + it.todo('dismisses the challenge and starts fresh the next time', () => { + // Dismissing rejects the action with reverification_cancelled + // The card closes + // Running the action again opens from the first step with empty fields + }); + + it.todo('can be dismissed from the method picker', () => { + // The action is rejected with reverification_cancelled + // The card closes + }); + + it.todo('can be dismissed from the help step', () => { + // The action is rejected with reverification_cancelled + // The card closes + }); + + it.todo('can be dismissed while a code is being sent', () => { + // The action is rejected with reverification_cancelled + // The card closes + }); + + // Unsure if this should be a test: the view has no dismiss button, so this only happens if the consumer keeps its own close control enabled during an attempt. Sign out mid attempt is the more realistic trigger. + // Unsure about test implementation: depends on the timing of the late response, which needs a held request to control. + it.todo('ignores a late failure from an attempt that was in flight when dismissed', () => { + // The card closes and the action is rejected as cancelled + // The late failure does not reopen the card or show an error + }); + + // Unsure if this should be a test: same as the late failure case above. + // Unsure about test implementation: same as the late failure case above. + it.todo('ignores a late success from an attempt that was in flight when dismissed', () => { + // The card closes and the action is rejected as cancelled + // The session is not activated + // The action is not retried + }); + + // Unsure about test implementation: depends on the timing of the dismissed attempt's late response, which needs a held request to control. + it.todo('starts a fresh challenge when the action is run again while a dismissed attempt is in flight', () => { + // The new challenge opens from the first step with empty fields + // The late response from the dismissed attempt has no effect + }); + }); + + describe('action lifecycle', () => { + it.todo('rejects a second call while a challenge is open', () => { + // The second call rejects with request_already_in_progress + // The open challenge is unaffected + }); + + // Unsure about test implementation: needs the fake to support signing out mid challenge (clerk.signOut against the fake DELETE handler). + it.todo('cancels the challenge when the user signs out', () => { + // The card closes + // The action is rejected as cancelled + }); + + it.todo('passes through an error that is unrelated to reverification', () => { + // The action rejects with the original error + // No challenge card opens + }); + }); + + describe('errors', () => { + // Currently wrong: the model keeps only the server's longMessage or message (toError drops the error code), so nothing downstream can localize it. Known codes such as form_password_incorrect, form_code_incorrect and verification_not_sent should map to localized messages the way the old UI does. + it.todo('shows a localized message for a wrong password', () => { + // The message for form_password_incorrect follows the configured locale + }); + + it.todo('shows a localized message for a wrong code', () => { + // The message for form_code_incorrect follows the configured locale + }); + + it.todo('shows the long message from the server for a Clerk error code without a localized message', () => { + // The long message is shown + }); + + it.todo('shows the short message from the server when there is no long message', () => { + // The short message is shown + }); + + // Currently wrong: the generic message is hardcoded English in both the model and the controller, and the localizable unstable__errors__generic message is unused. + it.todo('shows a localized generic message when the failure is not a Clerk error', () => { + // The generic message is shown on the step + // The message follows the configured locale + }); + + // Unsure about behavior: a network failure surfaces as a ClerkRuntimeError (an Error) with its own English message. Needs a decision on what the user should see for network failures. + // Unsure about test implementation: the generic fallback may only be reachable by throwing a non-Error. + it.todo('shows a message for a network failure', () => { + // The step stays open with an error + // The user can try again + }); + + // Unsure about behavior: a start failure ends in the unavailable card without the reason, and a session activation failure reuses the last step's field error. Neither has a dedicated error surface. + it.todo('explains why verification cannot continue when it cannot be started', () => { + // The reason is shown instead of only the unavailable message + }); + }); }); + +/* + Since this file already lists a lot of the behaviors worth knowing about for reverification, here + are a few others that might be worth knowing about, but don't deserve tests here: + + Owned by the server (tested in clerk_go/tests/fapi/session_reverification_test.go): + - A second_factor or multi_factor request falls back to first factor when the user has no second + factor (TestSessionReverification_FallbackLevelFirstFactor) + - The password factor is omitted when password reverification is disabled, and a second factor + request then falls back to first factor (PasswordDisabled, PasswordDisabled_SecondFactorFallback) + - Second factor only strategies are rejected as first factors (SecondFactorsRejectedAsFirstFactors) + - Phone numbers reserved for second factor are rejected as first factors, and a phone number must + be reserved for second factor to be used as one (FirstFactor_RejectsReservedForSecondFactorPhone, + SecondFactor_PhoneCode) + - A passkey can satisfy the second factor, which completes a multi factor request without a second + step, unless the instance opts out (MultiFactor_PasskeySatisfiesSecondFactor and _Disabled) + - A TOTP code that was already consumed is rejected (TOTP_RejectsConsumedCode) + - A pwned password is still accepted for reverification (FirstFactor_PwnedPassword) + - Wrong passwords and codes are 422 (form_password_incorrect, form_code_incorrect). Acting in the + wrong state is 400 (invalid_action_for_session_reverification), as is attempting a code that was + never sent (verification_not_sent) and acting before starting (session_reverification_missing) +*/ From ee4df21f8ee8780c6ffdcd499100746d7dbf3ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 1 Oct 2026 09:18:36 +0200 Subject: [PATCH 30/40] Implement all straightforward tests --- .../__tests__/reverification.feature.test.tsx | 673 ++++++++++++++++-- 1 file changed, 604 insertions(+), 69 deletions(-) diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index 1475931458e..73cf63b3900 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -1,12 +1,14 @@ import { reverificationError } from '@clerk/shared/authorization-errors'; -import type { ReverificationConfig } from '@clerk/shared/types'; -import { screen } from '@testing-library/react'; +import type { ReverificationConfig, SignInFirstFactorJSON, SignInSecondFactorJSON } from '@clerk/shared/types'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; import { useState } from 'react'; -import { describe, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { type FakeFapiSeed, serveFapi } from '../../../__tests__/feature/fake-fapi'; -import { fapiClient, fapiEmailAddress, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; +import type { FakeVerificationSeed } from '../../../__tests__/feature/fake-fapi/verification'; +import { fapiClient, fapiEmailAddress, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { Card } from '../../../components/card'; import { Reverification, useReverificationFlow } from '../reverification'; @@ -20,10 +22,71 @@ const alice = fapiUser({ const aliceSession = fapiSession({ id: 'sess_1', user: alice }); +const VERIFY = '/v1/client/sessions/sess_1/verify'; +const PREPARE_FIRST = `${VERIFY}/prepare_first_factor`; +const ATTEMPT_FIRST = `${VERIFY}/attempt_first_factor`; +const TOUCH = '/v1/client/sessions/sess_1/touch'; + +const PASSWORD = 'hunter2'; +const CODE = '123456'; +const WRONG_CODE = '000000'; +const BACKUP_CODE = 'backup-1'; + +const SECRETS = { password: PASSWORD, email_code: CODE, phone_code: CODE, totp: CODE, backup_code: BACKUP_CODE }; + +const passwordFactor: SignInFirstFactorJSON = { strategy: 'password' }; +const emailFactor: SignInFirstFactorJSON = { + strategy: 'email_code', + email_address_id: 'idn_alice', + safe_identifier: 'alice@example.com', +}; +const workEmailFactor: SignInFirstFactorJSON = { + strategy: 'email_code', + email_address_id: 'idn_work', + safe_identifier: 'alice@work.example', +}; +const phoneFactor: SignInFirstFactorJSON = { + strategy: 'phone_code', + phone_number_id: 'idn_phone', + safe_identifier: '+1 555 0100', +}; +const passkeyFactor: SignInFirstFactorJSON = { strategy: 'passkey' }; +const enterpriseSsoFactor = { + strategy: 'enterprise_sso', + enterprise_connection_id: 'ec_1', + enterprise_connection_name: 'Acme SSO', +} as unknown as SignInFirstFactorJSON; + +const totpFactor: SignInSecondFactorJSON = { strategy: 'totp' }; +const backupCodeFactor: SignInSecondFactorJSON = { strategy: 'backup_code' }; +const secondPhoneFactor: SignInSecondFactorJSON = { + strategy: 'phone_code', + phone_number_id: 'idn_phone', + safe_identifier: '+1 555 0100', +}; + +const TITLE = 'Verification required'; +const STEP = { + password: 'Enter your current password to continue', + emailCode: 'Enter the code sent to your email to continue', + phoneCode: 'Enter the code sent to your phone to continue', + totp: 'Enter the code generated by your authenticator app to continue', + backupCode: 'Enter the backup code you received when setting up two-step authentication', + passkey: /Using your passkey confirms your identity/, +}; + function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed { return { client: fapiClient([aliceSession]), ...overrides }; } +function verificationSeed(verification: FakeVerificationSeed, overrides: FakeFapiSeed = {}): FakeFapiSeed { + return signedIn({ verification: { secrets: SECRETS, ...verification }, ...overrides }); +} + +function preferring(strategy: 'password' | 'otp') { + return fapiEnvironment({ display_config: { preferred_sign_in_strategy: strategy } }); +} + function guardedAction(config?: ReverificationConfig) { return vi.fn().mockResolvedValueOnce(reverificationError(config)).mockResolvedValue({ done: true }); } @@ -73,6 +136,8 @@ function renderReverification(action = guardedAction(), seed: FakeFapiSeed = sig return renderWithClerk().then(view => ({ ...view, fapi, action })); } +type User = ReturnType; + async function startVerification() { const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Run action' })); @@ -80,34 +145,103 @@ async function startVerification() { } const outcome = () => screen.getByLabelText('Outcome').textContent; -const dismiss = (user: ReturnType) => - user.click(screen.getByRole('button', { name: 'Dismiss' })); +const dismiss = (user: User) => user.click(screen.getByRole('button', { name: 'Dismiss' })); +const passwordField = () => screen.getByLabelText('Password'); +const slots = () => + within(screen.getByRole('group', { name: 'Verification code' })).getAllByRole('textbox'); +const slotValues = () => slots().map(slot => slot.value); +const verifying = () => screen.getByRole('progressbar', { name: 'Verifying' }); +const useAnotherMethod = (user: User) => user.click(screen.getByRole('button', { name: 'Use another method' })); +const method = (name: RegExp | string) => screen.getByRole('button', { name }); + +function pretendToBeARealBrowser() { + Object.defineProperty(navigator, 'webdriver', { value: false, configurable: true }); + return () => { + Reflect.deleteProperty(navigator, 'webdriver'); + }; +} + +async function pasteCode(user: User, code: string) { + await user.click(slots()[0]); + await user.paste(code); +} + +async function untilResolved() { + await waitFor(() => expect(outcome()).toBe('resolved')); +} + +async function untilClosed() { + await waitFor(() => expect(screen.queryByText(TITLE)).toBeNull()); +} describe('Reverification', () => { describe('starting', () => { - it.todo('passes through when no reverification is needed', () => { - // Nothing is rendered before the action is run - // The action resolves with its own result - // No challenge card opens + it('passes through when no reverification is needed', async () => { + const action = vi.fn().mockResolvedValue({ done: true }); + await renderReverification(action); + expect(screen.queryByText(TITLE)).toBeNull(); + + await startVerification(); + + await untilResolved(); + expect(action).toHaveBeenCalledTimes(1); + expect(screen.queryByText(TITLE)).toBeNull(); }); - it.todo('opens on the first factor when the hint asks for the first factor level', () => { - // The first factor step opens - // No second factor step follows + it('opens on the first factor when the hint asks for the first factor level', async () => { + const { action } = await renderReverification( + guardedAction({ level: 'first_factor', afterMinutes: 10 }), + verificationSeed({ firstFactors: [passwordFactor], secondFactors: [totpFactor] }), + ); + + const user = await startVerification(); + + expect(await screen.findByText(STEP.password)).toBeVisible(); + await user.type(passwordField(), `${PASSWORD}{Enter}`); + await untilResolved(); + expect(action).toHaveBeenCalledTimes(2); }); - it.todo('opens on the second factor when the hint asks for the second factor level', () => { - // The second factor step opens without a first factor step + it('opens on the second factor when the hint asks for the second factor level', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ firstFactors: [passwordFactor], secondFactors: [totpFactor] }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.totp)).toBeVisible(); + expect(screen.queryByLabelText('Password')).toBeNull(); }); - it.todo('starts verification at the second factor level when the hint carries no level', () => { - // The second factor step opens + it('starts verification at the second factor level when the hint carries no level', async () => { + const levelessHint = { clerk_error: { type: 'forbidden', reason: 'reverification-error' } }; + const action = vi.fn().mockResolvedValueOnce(levelessHint).mockResolvedValue({ done: true }); + await renderReverification( + action, + verificationSeed({ firstFactors: [passwordFactor], secondFactors: [totpFactor] }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.totp)).toBeVisible(); }); - it.todo('lets the user dismiss the pending card while verification is starting', () => { - // A pending card is shown while the start request is in flight - // Dismissing rejects the action with reverification_cancelled - // The card closes + it('lets the user dismiss the pending card while verification is starting', async () => { + await renderReverification(guardedAction(), verificationSeed({})); + const start = holdRequests('post', VERIFY); + + const user = await startVerification(); + + await waitFor(() => expect(start.requests).toHaveLength(1)); + expect(screen.getByText(TITLE)).toBeVisible(); + expect(screen.queryByLabelText('Password')).toBeNull(); + + await dismiss(user); + + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + start.release(); }); // This can't use renderWithClerk since that awaits Clerk.load() @@ -130,28 +264,76 @@ describe('Reverification', () => { }); describe('choosing the starting method', () => { - it.todo('starts with a passkey when the browser supports WebAuthn and the user has one', () => { - // The passkey step opens even when the instance prefers the password + it('starts with a passkey when the browser supports WebAuthn and the user has one', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] }, { environment: preferring('password') }), + ); + const restore = pretendToBeARealBrowser(); + + try { + await startVerification(); + + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + expect(screen.queryByLabelText('Password')).toBeNull(); + } finally { + restore(); + } }); - it.todo('starts with the password when the instance prefers password sign in', () => { - // The password step opens + it('starts with the password when the instance prefers password sign in', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [emailFactor, passwordFactor] }, { environment: preferring('password') }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.password)).toBeVisible(); }); - it.todo('starts with a code method when the instance prefers code sign in', () => { - // The code step opens for the email address or phone number + it('starts with a code method when the instance prefers code sign in', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, emailFactor] }, { environment: preferring('otp') }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); }); - it.todo('starts the second factor on the authenticator app when the user has one', () => { - // The authenticator app step opens, even when a phone code is also available + it('starts the second factor on the authenticator app when the user has one', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [backupCodeFactor, secondPhoneFactor, totpFactor] }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.totp)).toBeVisible(); }); - it.todo('starts the second factor on a phone code when there is no authenticator app', () => { - // The phone code step opens + it('starts the second factor on a phone code when there is no authenticator app', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [backupCodeFactor, secondPhoneFactor] }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); }); - it.todo('starts the second factor on the first remaining method when there is neither', () => { - // The first listed method opens + it('starts the second factor on the first remaining method when there is neither', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [backupCodeFactor] }), + ); + + await startVerification(); + + expect(await screen.findByText(STEP.backupCode)).toBeVisible(); }); // This is a future behavior we will need to account for, here's the PR that builds it for the existing reverification: https://github.com/clerk/javascript/pull/9127 @@ -162,40 +344,131 @@ describe('Reverification', () => { describe('password', () => { // Currently wrong: the error shown is the server's English text, not a localized message. See the errors group. - it.todo('recovers from a wrong password and completes the challenge', () => { + it('recovers from a wrong password and completes the challenge', async () => { + const { action } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor] }), + ); + expect(screen.queryByText(TITLE)).toBeNull(); + // The password step opens when the action needs reverification + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + // A wrong password shows the server message and stays on the step + // Pressing Enter submits the password + await user.type(passwordField(), 'wrong{Enter}'); + expect(await screen.findByText(/Password is incorrect/)).toBeInTheDocument(); + // The typed value is kept after the error + expect(passwordField()).toHaveValue('wrong'); + // Typing again clears the error - // Pressing Enter submits the password + await user.clear(passwordField()); + await user.type(passwordField(), PASSWORD); + expect(screen.queryByText(/Password is incorrect/)).toBeNull(); + // The form is disabled and shows the verifying label while the attempt is in flight + const attempt = holdRequests('post', ATTEMPT_FIRST); + const touch = holdRequests('post', TOUCH); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + expect(passwordField()).toBeDisabled(); + expect(verifying()).toBeInTheDocument(); + attempt.release(); + // The last step stays visible and pending while the session is being activated + await waitFor(() => expect(touch.requests).toHaveLength(1)); + expect(passwordField()).toBeDisabled(); + expect(verifying()).toBeInTheDocument(); + expect(action).toHaveBeenCalledTimes(1); + touch.release(); + // The action is retried once the session is activated and resolves with its result + await untilResolved(); + expect(action).toHaveBeenCalledTimes(2); + // The card closes once the retried action has settled + await untilClosed(); }); }); describe('email code', () => { - // Unsure about test implementation: the fake has no request log, so counting requests needs holdRequests or counters added to the fake. // Currently wrong: the error shown is the server's English text, not a localized message. See the errors group. - it.todo('sends a code and completes the challenge with the correct code', () => { + it('sends a code and completes the challenge with the correct code', async () => { + const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); + const prepare = holdRequests('post', PREPARE_FIRST); + // A code is sent once when the step opens + const user = await startVerification(); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + // The field is usable while the code is being sent + await user.click(slots()[0]); + await user.keyboard(WRONG_CODE.slice(0, 3)); + expect(slotValues()).toEqual(['0', '0', '0', '', '', '']); + prepare.release(); + await waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('email_code')); + expect(prepare.requests).toHaveLength(1); + // A wrong code submits automatically on the last digit and shows the server error // The field is disabled and shows the verifying label while the attempt is in flight + const attempt = holdRequests('post', ATTEMPT_FIRST); + await user.keyboard(WRONG_CODE.slice(3)); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + expect(slots().every(slot => slot.disabled)).toBe(true); + expect(verifying()).toBeInTheDocument(); + attempt.release(); + expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); + await waitFor(() => expect(slots().every(slot => !slot.disabled)).toBe(true)); + // The correct code completes the challenge + await user.click(slots()[5]); + await user.keyboard('{Backspace>6/}'); + expect(slotValues()).toEqual(['', '', '', '', '', '']); + await pasteCode(user, CODE); + await untilResolved(); }); - it.todo('holds a submit made while a code is being sent', () => { + it('holds a submit made while a code is being sent', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); + const prepare = holdRequests('post', PREPARE_FIRST); + const attempt = holdRequests('post', ATTEMPT_FIRST); + const user = await startVerification(); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + // A submit made while the first code is being sent is held + await pasteCode(user, CODE); + expect(attempt.requests).toHaveLength(0); + // It is sent once the code has been sent - // The same holds for a submit made while a resend is in flight + prepare.release(); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + attempt.release(); + await untilResolved(); }); - it.todo('discards a held submit when sending the code fails', () => { + it('discards a held submit when sending the code fails', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); + const prepare = holdRequests('post', PREPARE_FIRST); + const attempt = holdRequests('post', ATTEMPT_FIRST); + const user = await startVerification(); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + await pasteCode(user, CODE); + + prepare.fail(); + // The error is shown on the step + expect(await screen.findByText('form_param_invalid')).toBeInTheDocument(); + expect(screen.getByText(STEP.emailCode)).toBeVisible(); + // The held submit is not sent + expect(attempt.requests).toHaveLength(0); + attempt.release(); + // The user can resend immediately + expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(); }); // Unsure about test implementation: resend is locked for 30s after the first prepare, so every resend case needs fake timers, and mixing fake Date with MSW, Clerk and userEvent in Chromium is untested. @@ -203,34 +476,87 @@ describe('Reverification', () => { // Resend is disabled and shows the countdown while the cooldown runs // Resend is enabled again after the cooldown // Resending sends a new code and clears the typed value + // A submit made while a resend is in flight is held and sent once the resend has been sent }); }); describe('phone code', () => { - it.todo('sends an SMS code for the first factor and completes the challenge', () => { + it('sends an SMS code for the first factor and completes the challenge', async () => { + const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [phoneFactor] })); + + const user = await startVerification(); + // The SMS code is sent through the first factor endpoints when the step opens + expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); + await waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('phone_code')); + // The correct code completes the challenge + await pasteCode(user, CODE); + await untilResolved(); }); }); describe('moving from the first to the second factor', () => { - it.todo('completes a multi factor challenge', () => { + it('completes a multi factor challenge', async () => { + const { fapi } = await renderReverification( + guardedAction({ level: 'multi_factor', afterMinutes: 10 }), + verificationSeed({ firstFactors: [passwordFactor], secondFactors: [secondPhoneFactor, backupCodeFactor] }), + ); + // The first factor step opens first + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + await user.type(passwordField(), `${PASSWORD}{Enter}`); + // The second factor step opens after the first factor is verified + expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); + await waitFor(() => expect(screen.queryByLabelText('Password')).toBeNull()); + // The second factor starts with an empty field and no error - // The picker lists second factors only + expect(slotValues()).toEqual(['', '', '', '', '', '']); + expect(screen.queryByText('Incorrect code')).toBeNull(); + // The SMS code is sent through the second factor endpoints when the step opens + await waitFor(() => expect(fapi.verification.secondFactorVerification?.strategy).toBe('phone_code')); + + // The picker lists second factors only + await useAnotherMethod(user); + expect(await screen.findByRole('button', { name: 'Use a backup code' })).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Continue with your password' })).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Back' })); + // The correct code completes the challenge + expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); + await pasteCode(user, CODE); + await untilResolved(); }); }); describe('second factor alternatives', () => { // Currently wrong: the error shown is the server's English text, not a localized message. See the errors group. - it.todo('switches from the authenticator app to a backup code', () => { + it('switches from the authenticator app to a backup code', async () => { + await renderReverification( + guardedAction({ level: 'second_factor', afterMinutes: 10 }), + verificationSeed({ secondFactors: [totpFactor, backupCodeFactor] }), + ); + // The authenticator app step opens first + const user = await startVerification(); + expect(await screen.findByText(STEP.totp)).toBeVisible(); + // The picker offers the backup code + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Use a backup code' })); + expect(await screen.findByText(STEP.backupCode)).toBeVisible(); + // An invalid backup code shows the server error + await user.type(screen.getByLabelText('Backup code'), 'invalid{Enter}'); + expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); + // A valid backup code completes the challenge + await user.clear(screen.getByLabelText('Backup code')); + await user.type(screen.getByLabelText('Backup code'), `${BACKUP_CODE}{Enter}`); + await untilResolved(); }); }); @@ -254,40 +580,132 @@ describe('Reverification', () => { describe('switching methods', () => { // Unsure about behavior: Back keeps the typed value while picking a method clears it. It is not clear whether keeping it on Back is intended. // Unsure about test implementation: focus is not moved for the initially active step, only on transitions, so focus must not be asserted on the first step. - it.todo('walks through the picker and help steps', () => { + it('walks through the picker and help steps', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, emailFactor, workEmailFactor, phoneFactor] }), + ); + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + // The picker lists every method except the one currently shown // Each email address and phone number is its own method + await useAnotherMethod(user); + expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); + expect(method('Email code to alice@work.example')).toBeVisible(); + expect(method('Send SMS code to +1 555 0100')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Continue with your password' })).toBeNull(); + // Help opens from the picker and offers to email support + await user.click(screen.getByRole('button', { name: 'Get help' })); + expect(await screen.findByText(/If you have trouble verifying your account/)).toBeVisible(); + expect(screen.getByRole('button', { name: 'Email support' })).toBeVisible(); + // Back from help returns to the picker + await user.click(screen.getByRole('button', { name: 'Back' })); + expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); + // Back from the picker returns to the method the user came from + await user.click(screen.getByRole('button', { name: 'Back' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + // Focus moves to the first field of the step the user switches to + await waitFor(() => expect(passwordField()).toHaveFocus()); }); - it.todo('does not offer another method when the user has only one', () => { - // "Use another method" is not shown + it('does not offer another method when the user has only one', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + + await startVerification(); + + expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Use another method' })).toBeNull(); }); - it.todo('does not list factors Mosaic cannot verify', () => { - // Enterprise SSO is not listed in the picker + it('does not list factors Mosaic cannot verify', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, enterpriseSsoFactor, emailFactor] }), + ); + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + + await useAnotherMethod(user); + + expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); + expect(screen.queryByText(/SSO/i)).toBeNull(); + expect(screen.queryByText(/enterprise/i)).toBeNull(); }); - it.todo('completes the challenge with a method picked from the picker', () => { + it('completes the challenge with a method picked from the picker', async () => { + const { fapi } = await renderReverification( + guardedAction(), + verificationSeed( + { firstFactors: [emailFactor, passwordFactor, phoneFactor] }, + { environment: preferring('otp') }, + ), + ); + const firstPrepare = holdRequests('post', PREPARE_FIRST); + const user = await startVerification(); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + await waitFor(() => expect(firstPrepare.requests).toHaveLength(1)); + firstPrepare.release(); + // Picking the password shows the password step without sending anything + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Continue with your password' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(firstPrepare.requests).toHaveLength(1); + // Picking an email code shows a pending row and disables the other actions while the code is sent + const secondPrepare = holdRequests('post', PREPARE_FIRST); + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Email code to alice@example.com' })); + await waitFor(() => expect(secondPrepare.requests).toHaveLength(1)); + expect(method('Send SMS code to +1 555 0100')).toBeDisabled(); + expect(method('Back')).toBeDisabled(); + secondPrepare.release(); + // The code step opens with an empty field and no error once the code is sent + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + expect(slotValues()).toEqual(['', '', '', '', '', '']); + expect(screen.queryByText('Incorrect code')).toBeNull(); + // Picking a phone code sends the SMS code through the first factor endpoints + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Send SMS code to +1 555 0100' })); + expect(await screen.findByText(STEP.phoneCode)).toBeVisible(); + expect(fapi.verification.firstFactorVerification?.strategy).toBe('phone_code'); + // The correct code completes the challenge + await pasteCode(user, CODE); + await untilResolved(); }); - it.todo('lands on the picked method with an error when the code cannot be sent', () => { + it('lands on the picked method with an error when the code cannot be sent', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] })); + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + const prepare = holdRequests('post', PREPARE_FIRST); + + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Email code to alice@example.com' })); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + prepare.fail(); + // The code step opens for the picked method + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + // The error is shown on the step + expect(await screen.findByText('form_param_invalid')).toBeInTheDocument(); }); + // Unsure about test implementation: the support email is only used in a mailto: link (window.location.assign), which can't be observed in a browser test. The help step doesn't render the address. it.todo('shows the support email from the environment', () => { // The help step shows the configured support email }); + // Unsure about test implementation: same as above, the default address is only used in the mailto: link. it.todo('falls back to the default support email when the environment has none', () => { // The help step shows the default support email }); @@ -301,35 +719,110 @@ describe('Reverification', () => { // The action is not retried }); - it.todo('cannot be interrupted while the action is being retried', () => { + it('cannot be interrupted while the action is being retried', async () => { + let rejectRetry: (error: Error) => void = () => {}; + const retry = new Promise((_, reject) => { + rejectRetry = reject; + }); + const action = vi + .fn() + .mockResolvedValueOnce(reverificationError()) + .mockImplementationOnce(() => retry) + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValue({ done: true }); + await renderReverification(action, verificationSeed({ firstFactors: [passwordFactor] })); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + // The last step stays visible and pending while the action is being retried + await waitFor(() => expect(action).toHaveBeenCalledTimes(2)); + expect(passwordField()).toBeDisabled(); + expect(verifying()).toBeInTheDocument(); + // Dismissing is not available + expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull(); + // A second call rejects with request_already_in_progress + await user.click(screen.getByRole('button', { name: 'Run action' })); + await waitFor(() => expect(outcome()).toBe('rejected: request_already_in_progress')); + expect(passwordField()).toBeDisabled(); + // An error thrown by the retried action rejects the action and closes the card + rejectRetry(new Error('retry failed')); + await waitFor(() => expect(outcome()).toBe('rejected: retry failed')); + await untilClosed(); + // A later call opens a new challenge from the first step with empty fields + await user.click(screen.getByRole('button', { name: 'Run action' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(passwordField()).toHaveValue(''); + expect(passwordField()).toBeEnabled(); }); }); describe('dismissing', () => { - it.todo('dismisses the challenge and starts fresh the next time', () => { + it('dismisses the challenge and starts fresh the next time', async () => { + const action = vi + .fn() + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValue({ done: true }); + await renderReverification(action, verificationSeed({ firstFactors: [passwordFactor] })); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), 'half-typed'); + // Dismissing rejects the action with reverification_cancelled + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + // The card closes + await untilClosed(); + // Running the action again opens from the first step with empty fields + await user.click(screen.getByRole('button', { name: 'Run action' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(passwordField()).toHaveValue(''); }); - it.todo('can be dismissed from the method picker', () => { - // The action is rejected with reverification_cancelled - // The card closes + it('can be dismissed from the method picker', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] })); + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + await useAnotherMethod(user); + expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); + + await dismiss(user); + + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); }); - it.todo('can be dismissed from the help step', () => { - // The action is rejected with reverification_cancelled - // The card closes + it('can be dismissed from the help step', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] })); + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Get help' })); + expect(await screen.findByRole('button', { name: 'Email support' })).toBeVisible(); + + await dismiss(user); + + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); }); - it.todo('can be dismissed while a code is being sent', () => { - // The action is rejected with reverification_cancelled - // The card closes + it('can be dismissed while a code is being sent', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); + const prepare = holdRequests('post', PREPARE_FIRST); + const user = await startVerification(); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + + await dismiss(user); + + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + prepare.release(); }); // Unsure if this should be a test: the view has no dismiss button, so this only happens if the consumer keeps its own close control enabled during an attempt. Sign out mid attempt is the more realistic trigger. @@ -355,9 +848,19 @@ describe('Reverification', () => { }); describe('action lifecycle', () => { - it.todo('rejects a second call while a challenge is open', () => { + it('rejects a second call while a challenge is open', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), 'typed'); + + await user.click(screen.getByRole('button', { name: 'Run action' })); + // The second call rejects with request_already_in_progress + await waitFor(() => expect(outcome()).toBe('rejected: request_already_in_progress')); + // The open challenge is unaffected + expect(screen.getByText(STEP.password)).toBeVisible(); + expect(passwordField()).toHaveValue('typed'); }); // Unsure about test implementation: needs the fake to support signing out mid challenge (clerk.signOut against the fake DELETE handler). @@ -366,9 +869,17 @@ describe('Reverification', () => { // The action is rejected as cancelled }); - it.todo('passes through an error that is unrelated to reverification', () => { + it('passes through an error that is unrelated to reverification', async () => { + const action = vi.fn().mockRejectedValue(new Error('unrelated failure')); + await renderReverification(action); + + await startVerification(); + // The action rejects with the original error + await waitFor(() => expect(outcome()).toBe('rejected: unrelated failure')); + // No challenge card opens + expect(screen.queryByText(TITLE)).toBeNull(); }); }); @@ -382,12 +893,36 @@ describe('Reverification', () => { // The message for form_code_incorrect follows the configured locale }); - it.todo('shows the long message from the server for a Clerk error code without a localized message', () => { - // The long message is shown + it('shows the long message from the server for a Clerk error code without a localized message', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + worker.use( + http.post(fapiUrl(ATTEMPT_FIRST), () => + HttpResponse.json( + { errors: [{ code: 'some_new_error', message: 'Short message', long_message: 'Long message' }] }, + { status: 422 }, + ), + ), + ); + const user = await startVerification(); + + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + + expect(await screen.findByText('Long message')).toBeInTheDocument(); + expect(screen.queryByText('Short message')).toBeNull(); }); - it.todo('shows the short message from the server when there is no long message', () => { - // The short message is shown + it('shows the short message from the server when there is no long message', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + worker.use( + http.post(fapiUrl(ATTEMPT_FIRST), () => + HttpResponse.json({ errors: [{ code: 'some_new_error', message: 'Short message' }] }, { status: 422 }), + ), + ); + const user = await startVerification(); + + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + + expect(await screen.findByText('Short message')).toBeInTheDocument(); }); // Currently wrong: the generic message is hardcoded English in both the model and the controller, and the localizable unstable__errors__generic message is unused. From 991c468b4921089a15b8eb807335e2cbf98d4bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 1 Oct 2026 09:37:13 +0200 Subject: [PATCH 31/40] Add tests for the unsure how to implement cases --- .../__tests__/reverification.feature.test.tsx | 324 +++++++++++++++--- 1 file changed, 284 insertions(+), 40 deletions(-) diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index 73cf63b3900..b270273bcb0 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -1,6 +1,11 @@ import { reverificationError } from '@clerk/shared/authorization-errors'; -import type { ReverificationConfig, SignInFirstFactorJSON, SignInSecondFactorJSON } from '@clerk/shared/types'; -import { screen, waitFor, within } from '@testing-library/react'; +import type { + PublicKeyCredentialWithAuthenticatorAssertionResponse, + ReverificationConfig, + SignInFirstFactorJSON, + SignInSecondFactorJSON, +} from '@clerk/shared/types'; +import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { useState } from 'react'; @@ -161,6 +166,42 @@ function pretendToBeARealBrowser() { }; } +function pretendWebAuthnIsUnsupported() { + const restoreBrowser = pretendToBeARealBrowser(); + const original = Object.getOwnPropertyDescriptor(window, 'PublicKeyCredential'); + Object.defineProperty(window, 'PublicKeyCredential', { value: undefined, configurable: true, writable: true }); + return () => { + restoreBrowser(); + if (original) { + Object.defineProperty(window, 'PublicKeyCredential', original); + } + }; +} + +function fakeAssertion() { + const buffer = () => new Uint8Array([1, 2, 3]).buffer; + return { + type: 'public-key', + id: 'cred_1', + rawId: buffer(), + authenticatorAttachment: 'platform', + response: { clientDataJSON: buffer(), authenticatorData: buffer(), signature: buffer(), userHandle: null }, + } as unknown as PublicKeyCredentialWithAuthenticatorAssertionResponse; +} + +function captureNavigations() { + const navigation = (window as unknown as { navigation: EventTarget }).navigation; + const urls: string[] = []; + const onNavigate = (event: Event) => { + urls.push((event as Event & { destination: { url: string } }).destination.url); + event.preventDefault(); + }; + navigation.addEventListener('navigate', onNavigate); + return { urls, stop: () => navigation.removeEventListener('navigate', onNavigate) }; +} + +const settle = () => new Promise(resolve => setTimeout(resolve, 100)); + async function pasteCode(user: User, code: string) { await user.click(slots()[0]); await user.paste(code); @@ -471,12 +512,46 @@ describe('Reverification', () => { expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(); }); - // Unsure about test implementation: resend is locked for 30s after the first prepare, so every resend case needs fake timers, and mixing fake Date with MSW, Clerk and userEvent in Chromium is untested. - it.todo('resends a code once the cooldown has passed', () => { - // Resend is disabled and shows the countdown while the cooldown runs - // Resend is enabled again after the cooldown - // Resending sends a new code and clears the typed value - // A submit made while a resend is in flight is held and sent once the resend has been sent + it('resends a code once the cooldown has passed', async () => { + const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); + vi.useFakeTimers({ toFake: ['Date'] }); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + await waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('email_code')); + + // Resend is disabled and shows the countdown while the cooldown runs + const resend = await screen.findByRole('button', { name: /Resend \(\d+\)/ }); + expect(resend).toBeDisabled(); + + // Resend is enabled again after the cooldown + vi.setSystemTime(Date.now() + 30_000); + await waitFor(() => expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(), { timeout: 3000 }); + expect(screen.getByRole('button', { name: /Resend/ }).textContent).not.toMatch(/\(\d+\)/); + + // Resending sends a new code and clears the typed value + await user.click(slots()[0]); + await user.keyboard('123'); + expect(slotValues()).toEqual(['1', '2', '3', '', '', '']); + const prepare = holdRequests('post', PREPARE_FIRST); + await user.click(screen.getByRole('button', { name: /Resend/ })); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + expect(slotValues()).toEqual(['', '', '', '', '', '']); + expect(screen.getByRole('button', { name: /Resend \(\d+\)/ })).toBeDisabled(); + + // A submit made while a resend is in flight is held and sent once the resend has been sent + const attempt = holdRequests('post', ATTEMPT_FIRST); + await pasteCode(user, CODE); + await settle(); + expect(attempt.requests).toHaveLength(0); + prepare.release(); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + attempt.release(); + await untilResolved(); + } finally { + vi.useRealTimers(); + } }); }); @@ -561,25 +636,73 @@ describe('Reverification', () => { }); describe('passkey', () => { - // Unsure about test implementation: the fake needs clerk.__internal_getPublicCredentials stubbed to provide a credential. // Currently wrong: the banner shows the browser or clerk-js error message as is, which is not localized. - it.todo('completes the challenge with the passkey', () => { - // The passkey step opens - // A refused credential shows an error banner and stays on the step - // The button is disabled and shows the verifying label while the credential is being verified - // A second attempt with an accepted credential completes the challenge + it('completes the challenge with the passkey', async () => { + const { clerk } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] }), + ); + let acceptCredential: () => void = () => {}; + const getCredential = vi + .fn() + .mockResolvedValueOnce({ publicKeyCredential: null, error: new Error('The operation was refused') }) + .mockImplementationOnce( + () => + new Promise(resolve => { + acceptCredential = () => resolve({ publicKeyCredential: fakeAssertion(), error: null }); + }), + ); + clerk.__internal_getPublicCredentials = getCredential; + const restore = pretendToBeARealBrowser(); + + try { + // The passkey step opens + const user = await startVerification(); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + + // A refused credential shows an error banner and stays on the step + await user.click(screen.getByRole('button', { name: 'Continue' })); + expect(await screen.findByRole('alert')).toHaveTextContent('The operation was refused'); + expect(screen.getByText(STEP.passkey)).toBeVisible(); + + // The button is disabled and shows the verifying label while the credential is being verified + await user.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(verifying()).toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Use another method' })).toBeDisabled(); + + // A second attempt with an accepted credential completes the challenge + await waitFor(() => expect(getCredential).toHaveBeenCalledTimes(2)); + acceptCredential(); + await untilResolved(); + } finally { + restore(); + } }); - // Unsure about test implementation: needs window.PublicKeyCredential stubbed out, and it is not certain isWebAuthnSupported() reacts to that in Chromium. - it.todo('does not offer a passkey when the browser does not support WebAuthn', () => { - // The passkey is not the starting method - // The passkey is not listed in the picker + it('does not offer a passkey when the browser does not support WebAuthn', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, passkeyFactor, emailFactor] }), + ); + const restore = pretendWebAuthnIsUnsupported(); + + try { + // The passkey is not the starting method + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + + // The passkey is not listed in the picker + await useAnotherMethod(user); + expect(await screen.findByRole('button', { name: 'Email code to alice@example.com' })).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Use your passkey' })).toBeNull(); + } finally { + restore(); + } }); }); describe('switching methods', () => { // Unsure about behavior: Back keeps the typed value while picking a method clears it. It is not clear whether keeping it on Back is intended. - // Unsure about test implementation: focus is not moved for the initially active step, only on transitions, so focus must not be asserted on the first step. it('walks through the picker and help steps', async () => { await renderReverification( guardedAction(), @@ -700,14 +823,47 @@ describe('Reverification', () => { expect(await screen.findByText('form_param_invalid')).toBeInTheDocument(); }); - // Unsure about test implementation: the support email is only used in a mailto: link (window.location.assign), which can't be observed in a browser test. The help step doesn't render the address. - it.todo('shows the support email from the environment', () => { - // The help step shows the configured support email + it('emails the support address from the environment', async () => { + await renderReverification( + guardedAction(), + verificationSeed( + { firstFactors: [passwordFactor, emailFactor] }, + { environment: fapiEnvironment({ display_config: { support_email: 'help@acme.test' } }) }, + ), + ); + const navigations = captureNavigations(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Get help' })); + + // Email support opens a mail to the configured address + await user.click(await screen.findByRole('button', { name: 'Email support' })); + await waitFor(() => expect(navigations.urls).toEqual(['mailto:help@acme.test'])); + } finally { + navigations.stop(); + } }); - // Unsure about test implementation: same as above, the default address is only used in the mailto: link. - it.todo('falls back to the default support email when the environment has none', () => { - // The help step shows the default support email + it('emails the default support address when the environment has none', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, emailFactor] })); + const navigations = captureNavigations(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Get help' })); + + // Email support opens a mail to support@ the Frontend API host + await user.click(await screen.findByRole('button', { name: 'Email support' })); + await waitFor(() => expect(navigations.urls).toHaveLength(1)); + expect(navigations.urls[0]).toMatch(/^mailto:support@/); + } finally { + navigations.stop(); + } }); }); @@ -825,25 +981,85 @@ describe('Reverification', () => { prepare.release(); }); - // Unsure if this should be a test: the view has no dismiss button, so this only happens if the consumer keeps its own close control enabled during an attempt. Sign out mid attempt is the more realistic trigger. - // Unsure about test implementation: depends on the timing of the late response, which needs a held request to control. - it.todo('ignores a late failure from an attempt that was in flight when dismissed', () => { + it('ignores a late failure from an attempt that was in flight when dismissed', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + const attempt = holdRequests('post', ATTEMPT_FIRST); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + // The card closes and the action is rejected as cancelled + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + // The late failure does not reopen the card or show an error + attempt.fail(); + await settle(); + expect(screen.queryByText(TITLE)).toBeNull(); + expect(screen.queryByText('form_param_invalid')).toBeNull(); + expect(outcome()).toBe('rejected: reverification_cancelled'); }); - // Unsure if this should be a test: same as the late failure case above. - // Unsure about test implementation: same as the late failure case above. - it.todo('ignores a late success from an attempt that was in flight when dismissed', () => { + it('ignores a late success from an attempt that was in flight when dismissed', async () => { + const { fapi, action } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor] }), + ); + const attempt = holdRequests('post', ATTEMPT_FIRST); + const touch = holdRequests('post', TOUCH); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + // The card closes and the action is rejected as cancelled + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + // The session is not activated + attempt.release(); + await waitFor(() => expect(fapi.verification.status).toBe('complete')); + await settle(); + expect(touch.requests).toHaveLength(0); + touch.release(); + // The action is not retried + expect(action).toHaveBeenCalledTimes(1); + expect(screen.queryByText(TITLE)).toBeNull(); }); - // Unsure about test implementation: depends on the timing of the dismissed attempt's late response, which needs a held request to control. - it.todo('starts a fresh challenge when the action is run again while a dismissed attempt is in flight', () => { + it('starts a fresh challenge when the action is run again while a dismissed attempt is in flight', async () => { + const action = vi + .fn() + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValue({ done: true }); + await renderReverification(action, verificationSeed({ firstFactors: [passwordFactor] })); + const attempt = holdRequests('post', ATTEMPT_FIRST); + const touch = holdRequests('post', TOUCH); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + // The new challenge opens from the first step with empty fields + await user.click(screen.getByRole('button', { name: 'Run action' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(passwordField()).toHaveValue(''); + expect(passwordField()).toBeEnabled(); + // The late response from the dismissed attempt has no effect + attempt.release(); + await settle(); + expect(passwordField()).toHaveValue(''); + expect(passwordField()).toBeEnabled(); + expect(screen.getByText(STEP.password)).toBeVisible(); + expect(touch.requests).toHaveLength(0); + touch.release(); + expect(action).toHaveBeenCalledTimes(2); }); }); @@ -863,10 +1079,21 @@ describe('Reverification', () => { expect(passwordField()).toHaveValue('typed'); }); - // Unsure about test implementation: needs the fake to support signing out mid challenge (clerk.signOut against the fake DELETE handler). - it.todo('cancels the challenge when the user signs out', () => { + it('cancels the challenge when the user signs out', async () => { + const { clerk } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor] }), + ); + await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + + await act(() => clerk.signOut()); + // The card closes + await untilClosed(); + // The action is rejected as cancelled + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); }); it('passes through an error that is unrelated to reverification', async () => { @@ -931,11 +1158,28 @@ describe('Reverification', () => { // The message follows the configured locale }); - // Unsure about behavior: a network failure surfaces as a ClerkRuntimeError (an Error) with its own English message. Needs a decision on what the user should see for network failures. - // Unsure about test implementation: the generic fallback may only be reachable by throwing a non-Error. - it.todo('shows a message for a network failure', () => { - // The step stays open with an error - // The user can try again + // Currently wrong: the message is the raw clerk-js network error including the request URL, and the generic fallback is never used for it. + it('keeps the step open and lets the user try again after a network failure', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + worker.use(http.post(fapiUrl(ATTEMPT_FIRST), () => HttpResponse.error(), { once: true })); + const restore = pretendToBeARealBrowser(); + + try { + const user = await startVerification(); + + // The step stays open with an error + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + expect(await screen.findByText(/Network error/)).toBeInTheDocument(); + expect(screen.getByText(STEP.password)).toBeVisible(); + expect(passwordField()).toBeEnabled(); + expect(passwordField()).toHaveValue(PASSWORD); + + // The user can try again + await user.type(passwordField(), '{Enter}'); + await untilResolved(); + } finally { + restore(); + } }); // Unsure about behavior: a start failure ends in the unavailable card without the reason, and a session activation failure reuses the last step's field error. Neither has a dedicated error surface. From 82e489b54fa7a796fc1995dddf517ad45175aad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 1 Oct 2026 10:16:28 +0200 Subject: [PATCH 32/40] More tests --- .../__tests__/reverification.feature.test.tsx | 157 ++++++++++++++++-- 1 file changed, 141 insertions(+), 16 deletions(-) diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index b270273bcb0..57c40b0ea92 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -16,6 +16,8 @@ import type { FakeVerificationSeed } from '../../../__tests__/feature/fake-fapi/ import { fapiClient, fapiEmailAddress, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { Card } from '../../../components/card'; +import type { MosaicLocalization } from '../../../localization'; +import { MosaicProvider } from '../../../MosaicProvider'; import { Reverification, useReverificationFlow } from '../reverification'; const alice = fapiUser({ @@ -136,9 +138,16 @@ function Host({ action }: { action: () => Promise }) { ); } -function renderReverification(action = guardedAction(), seed: FakeFapiSeed = signedIn()) { +function renderReverification( + action = guardedAction(), + seed: FakeFapiSeed = signedIn(), + localization?: MosaicLocalization, +) { const fapi = serveFapi(seed); - return renderWithClerk().then(view => ({ ...view, fapi, action })); + const host = ; + return renderWithClerk( + localization ? {host} : host, + ).then(view => ({ ...view, fapi, action })); } type User = ReturnType; @@ -291,16 +300,35 @@ describe('Reverification', () => { // The first step opens once Clerk is ready }); - // Currently wrong: entering `unavailable` calls cancel(), which closes the challenge, so the card only flashes and then disappears. Needs a product decision (keep the card until dismissed, or assert only the rejection). - it.todo('shows the unavailable card when verification cannot be started', () => { - // The unavailable card is shown - // The action is rejected as cancelled + // Currently wrong: entering `unavailable` calls cancel(), which closes the challenge, so the unavailable card only flashes and the user is never told why. Needs a product decision (keep the card until dismissed, or assert only the rejection). + it('cancels the action when verification cannot be started', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + worker.use( + http.post(fapiUrl(VERIFY), () => + HttpResponse.json({ errors: [{ code: 'some_new_error', message: 'Nope' }] }, { status: 400 }), + ), + ); + + await startVerification(); + + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); }); - // Currently wrong: same flash as above. Enterprise SSO is dropped by the model mapping, which leaves no usable factor. - it.todo('shows the unavailable card when none of the factors are supported', () => { - // The unavailable card is shown when the only factor is enterprise SSO - // The action is rejected as cancelled + // Needs a product decision: keep the unavailable card until the user dismisses it, or cancel without showing it. + it.todo('keeps the unavailable card visible until it is dismissed', () => { + // The unavailable card stays on screen instead of flashing + // Dismissing it rejects the action as cancelled + }); + + // Currently wrong: same as above. Enterprise SSO is dropped by the model mapping, which leaves no usable factor. + it('cancels the action when none of the factors are supported', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [enterpriseSsoFactor] })); + + await startVerification(); + + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); }); }); @@ -472,6 +500,24 @@ describe('Reverification', () => { await untilResolved(); }); + it('resubmits the typed code with the Continue button', async () => { + const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); + const user = await startVerification(); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + await waitFor(() => expect(fapi.verification.firstFactorVerification?.strategy).toBe('email_code')); + await pasteCode(user, WRONG_CODE); + expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); + await waitFor(() => expect(slots().every(slot => !slot.disabled)).toBe(true)); + + const attempt = holdRequests('post', ATTEMPT_FIRST); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + expect(verifying()).toBeInTheDocument(); + attempt.release(); + expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); + }); + it('holds a submit made while a code is being sent', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); const prepare = holdRequests('post', PREPARE_FIRST); @@ -679,6 +725,26 @@ describe('Reverification', () => { } }); + it('offers the passkey from the picker when another method is showing', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] })); + const restore = pretendToBeARealBrowser(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Continue with your password' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + + // The passkey is listed once the user is on another method + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Use your passkey' })); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + } finally { + restore(); + } + }); + it('does not offer a passkey when the browser does not support WebAuthn', async () => { await renderReverification( guardedAction(), @@ -868,11 +934,40 @@ describe('Reverification', () => { }); describe('finishing', () => { - // Currently wrong: the server verification is already complete at this point, so resubmitting the same code fails with invalid_action_for_session_reverification and the user can't recover. Needs a product decision (retry finish, or restart the verification). - it.todo('returns to the last step with an error when the session cannot be activated', () => { + // Currently wrong: the server verification is already complete at this point, so resubmitting fails with invalid_action_for_session_reverification and the user can't recover. Needs a product decision (retry finish, or restart the verification). + it('returns to the last step with an error when the session cannot be activated', async () => { + const { action } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor] }), + ); + const touch = holdRequests('post', TOUCH); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + // The last step stays visible and pending while the session is being activated + await waitFor(() => expect(touch.requests).toHaveLength(1)); + expect(passwordField()).toBeDisabled(); + expect(verifying()).toBeInTheDocument(); + touch.fail(); + // The error is shown on the last step + expect(await screen.findByText('form_param_invalid')).toBeInTheDocument(); + expect(screen.getByText(STEP.password)).toBeVisible(); + await waitFor(() => expect(passwordField()).toBeEnabled()); + // The action is not retried + expect(action).toHaveBeenCalledTimes(1); + expect(outcome()).toBe(''); + + // Submitting again fails because the server verification is already complete + await user.type(passwordField(), '{Enter}'); + expect(await screen.findByText(/unable to attempt first factor/)).toBeInTheDocument(); + expect(outcome()).toBe(''); + }); + + // Needs a product decision: retry the activation on the same step, or restart the verification. + it.todo('lets the user recover when the session cannot be activated', () => { + // The user can reach a resolved action without leaving the card in a dead end }); it('cannot be interrupted while the action is being retried', async () => { @@ -1112,6 +1207,7 @@ describe('Reverification', () => { describe('errors', () => { // Currently wrong: the model keeps only the server's longMessage or message (toError drops the error code), so nothing downstream can localize it. Known codes such as form_password_incorrect, form_code_incorrect and verification_not_sent should map to localized messages the way the old UI does. + // Blocked: Mosaic has no message keys for error codes yet, so there is nothing a test can override. it.todo('shows a localized message for a wrong password', () => { // The message for form_password_incorrect follows the configured locale }); @@ -1153,12 +1249,41 @@ describe('Reverification', () => { }); // Currently wrong: the generic message is hardcoded English in both the model and the controller, and the localizable unstable__errors__generic message is unused. - it.todo('shows a localized generic message when the failure is not a Clerk error', () => { - // The generic message is shown on the step - // The message follows the configured locale + it.fails('shows a localized generic message when the failure is not a Clerk error', async () => { + const { clerk } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor] }), + { + messages: { 'reverification.unstable__errors__generic': 'Something custom went wrong' }, + }, + ); + const user = await startVerification(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + vi.spyOn(clerk.session!, 'attemptFirstFactorVerification').mockRejectedValue('not an error'); + + await user.type(passwordField(), `${PASSWORD}{Enter}`); + + expect(await screen.findByText('Something custom went wrong')).toBeInTheDocument(); }); // Currently wrong: the message is the raw clerk-js network error including the request URL, and the generic fallback is never used for it. + it.fails('does not show the request URL when there is a network failure', async () => { + await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); + worker.use(http.post(fapiUrl(ATTEMPT_FIRST), () => HttpResponse.error(), { once: true })); + const restore = pretendToBeARealBrowser(); + + try { + const user = await startVerification(); + + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + + await waitFor(() => expect(passwordField()).toBeInvalid()); + expect(document.body).not.toHaveTextContent(/https?:\/\//); + } finally { + restore(); + } + }); + it('keeps the step open and lets the user try again after a network failure', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); worker.use(http.post(fapiUrl(ATTEMPT_FIRST), () => HttpResponse.error(), { once: true })); @@ -1169,7 +1294,7 @@ describe('Reverification', () => { // The step stays open with an error await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); - expect(await screen.findByText(/Network error/)).toBeInTheDocument(); + await waitFor(() => expect(passwordField()).toBeInvalid()); expect(screen.getByText(STEP.password)).toBeVisible(); expect(passwordField()).toBeEnabled(); expect(passwordField()).toHaveValue(PASSWORD); From d88a6a081570adbf77b3fc8b047d038a036509ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 1 Oct 2026 10:16:39 +0200 Subject: [PATCH 33/40] Delete reverification unit tests --- .../reverification.composition.test.tsx | 236 ----- .../reverification.controller.test.tsx | 914 ------------------ .../__tests__/reverification.model.test.tsx | 340 ------- .../__tests__/reverification.test.tsx | 68 -- .../__tests__/reverification.view.test.tsx | 119 --- 5 files changed, 1677 deletions(-) delete mode 100644 packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx delete mode 100644 packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx delete mode 100644 packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx delete mode 100644 packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx delete mode 100644 packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx deleted file mode 100644 index a4746e00b54..00000000000 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { Card } from '../../../components/card'; -import { Dialog } from '../../../components/dialog'; -import { Flow } from '../../../components/flow'; -import { MosaicProvider } from '../../../MosaicProvider'; -import { Reverification } from '../reverification'; -import type { ReverificationController } from '../reverification.controller'; -import type { ReverificationViewProps } from '../reverification.types'; - -let controller: ReverificationController = { status: 'idle' }; - -function surface(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, - }; -} - -function Nested({ step }: { step: 'confirm' | 'verify' | 'finalizing' }) { - return ( - - - - - - {() => ( - <> - - - Delete account? - Confirm the mock delete. - - - - - - - - Finalizing - Completing the mock delete. - - - - )} - - - - - - ); -} - -describe('reverification inside an outer flow', () => { - it('keeps one dialog and one card, and nests a flow only while verifying', () => { - controller = { status: 'idle' }; - const { rerender } = render(); - - expect(screen.getAllByRole('dialog')).toHaveLength(1); - expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); - expect(document.querySelectorAll('.cl-flow-root')).toHaveLength(1); - expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); - expect(screen.getByText('Confirm the mock delete.')).toBeInTheDocument(); - - controller = { status: 'loading' }; - rerender(); - - const flows = document.querySelectorAll('.cl-flow-root'); - expect(screen.getAllByRole('dialog')).toHaveLength(1); - expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); - expect(flows).toHaveLength(1); - expect(flows[0]).toHaveAttribute('data-value', 'verify'); - expect(screen.queryByText('Confirm the mock delete.')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); - expect(document.querySelector('.cl-spinner')).not.toBeNull(); - - const outerCard = document.querySelector('.cl-card-root'); - const outerFlow = flows[0]; - controller = surface(); - rerender(); - - const nextFlows = document.querySelectorAll('.cl-flow-root'); - expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); - expect(document.querySelector('.cl-card-root')).toBe(outerCard); - expect(nextFlows).toHaveLength(2); - expect(nextFlows[0]).toBe(outerFlow); - expect(nextFlows[1]).toHaveAttribute('data-value', 'password'); - expect(screen.getByLabelText('Password')).toBeInTheDocument(); - expect(screen.queryByText('Confirm the mock delete.')).not.toBeInTheDocument(); - - controller = { status: 'idle' }; - rerender(); - - expect(screen.getAllByRole('dialog')).toHaveLength(1); - expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); - expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'finalizing'); - expect(screen.getByRole('heading', { name: 'Finalizing' })).toBeInTheDocument(); - expect(screen.queryByText('Confirm the mock delete.')).not.toBeInTheDocument(); - }); - - it('keeps the current step when the challenge goes inactive', () => { - controller = surface(); - const { rerender } = render(); - - expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); - expect(screen.getByLabelText('Password')).toBeInTheDocument(); - - controller = { status: 'idle' }; - rerender(); - - expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); - expect(screen.queryByText('Confirm the mock delete.')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); - }); - - it('does not mount an inner flow before verification starts', () => { - controller = surface(); - const { rerender } = render(); - - expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); - expect(document.querySelectorAll('.cl-flow-root')).toHaveLength(1); - expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); - - rerender(); - expect(document.querySelectorAll('.cl-flow-root')).toHaveLength(1); - expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); - }); -}); - -describe('reverification card states', () => { - it('keeps one card from the pending state through a factor', () => { - controller = { status: 'loading' }; - const { container, rerender } = render( - - - - - , - ); - const card = container.querySelector('.cl-card-root'); - const spinner = container.querySelector('.cl-spinner'); - - expect(card).not.toBeNull(); - expect(container.querySelector('.cl-flow-root')).toBeNull(); - expect(screen.getByRole('heading', { name: 'Verification required' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); - expect(container.querySelector('[aria-busy="true"]')).toContainElement(spinner); - expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); - - controller = surface(); - rerender( - - - - - , - ); - - expect(container.querySelector('.cl-card-root')).toBe(card); - expect(container.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'password'); - expect(container.querySelector('.cl-spinner')).toBeNull(); - expect(screen.getByLabelText('Password')).toBeInTheDocument(); - }); - - it('shows a dismiss button on the pending card inside a dialog', () => { - controller = { status: 'loading' }; - render( - - - - - - - - - , - ); - - expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); - expect(screen.getByRole('dialog')).toHaveAccessibleName('Verification required'); - expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); - }); - - it('renders unavailable outside the factor flow, then mounts that flow in the same card', () => { - controller = { status: 'unavailable' }; - const { container, rerender } = render( - - - - - , - ); - const card = container.querySelector('.cl-card-root'); - - expect(card).not.toBeNull(); - expect(container.querySelector('.cl-flow-root')).toBeNull(); - expect(screen.getByRole('heading', { name: 'Cannot verify your account' })).toBeInTheDocument(); - expect( - screen.getByText('Cannot proceed with verification. No suitable authentication factor is configured.'), - ).toBeInTheDocument(); - - controller = surface(); - rerender( - - - - - , - ); - - expect(container.querySelector('.cl-card-root')).toBe(card); - expect(container.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'password'); - expect(screen.getByLabelText('Password')).toBeInTheDocument(); - }); -}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx deleted file mode 100644 index 1089355dd26..00000000000 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx +++ /dev/null @@ -1,914 +0,0 @@ -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 { ReverificationActiveModel, ReverificationModel } 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 buildActiveModel(overrides: Partial = {}): ReverificationActiveModel { - return { - status: 'active', - 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('leaves submitting immediately when reset and ignores a late failure', 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('inactive'); - - attempt.reject(new Error('cancelled')); - await tick(); - expect(actor.getSnapshot().value).toBe('inactive'); - expect(actor.getSnapshot().context.errorMessage).toBeUndefined(); - expect(cancel).not.toHaveBeenCalled(); - }); - - it('ignores 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('inactive'); - expect(finish).not.toHaveBeenCalled(); - expect(cancel).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('finishing'); - expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('password'); - finish.resolve(); - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('retrying')); - }); - - 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('finishing'); - }); - - 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({ status: 'inactive' })); - expect(result.current).toEqual({ status: 'idle' }); - }); - - it('is loading while the model is still waiting on Clerk', () => { - const cancel = vi.fn(); - const loading: ReverificationModel = { - status: 'loading', - cancel, - }; - const { result } = renderHook(() => useReverificationController(loading)); - expect(result.current.status).toBe('loading'); - if (result.current.status !== 'loading') { - throw new Error('expected loading'); - } - result.current.onCancel?.(); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it('is unavailable when start fails', async () => { - const cancel = vi.fn(); - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ 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( - buildActiveModel({ - 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( - buildActiveModel({ - 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( - buildActiveModel({ - 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(buildActiveModel({ 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 immediately while an attempt is in flight', async () => { - const attempt = deferred(); - const finish = vi.fn(() => Promise.resolve()); - const cancel = vi.fn(); - const activeModel = buildActiveModel({ 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: { status: 'inactive' } }); - expect(result.current.status).toBe('idle'); - - await act(async () => { - attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - await tick(); - }); - expect(result.current.status).toBe('idle'); - expect(cancel).not.toHaveBeenCalled(); - expect(finish).not.toHaveBeenCalled(); - }); - - it('cancels an in-flight attempt before it can finish', async () => { - const attempt = deferred(); - const finish = vi.fn(() => Promise.resolve()); - const cancel = vi.fn(); - const { result } = renderHook(() => - useReverificationController(buildActiveModel({ attempt: () => attempt.promise, finish, cancel })), - ); - - 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(() => { - if (result.current.status === 'ready') { - result.current.onCancel?.(); - } - }); - expect(cancel).toHaveBeenCalledOnce(); - - await act(async () => { - attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - await tick(); - }); - expect(cancel).toHaveBeenCalledOnce(); - expect(finish).not.toHaveBeenCalled(); - }); - - it('starts a fresh flow when reopened while a cancelled attempt is still in flight', async () => { - const attempt = deferred(); - const start = vi.fn(async () => firstFactorResult()); - const finish = vi.fn(() => Promise.resolve()); - const cancel = vi.fn(); - const model = buildActiveModel({ start, attempt: () => attempt.promise, finish, cancel }); - const { result, rerender } = renderHook( - ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { initialProps: { model } }, - ); - - 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: 'inactive' } }); - expect(result.current.status).toBe('idle'); - - rerender({ model }); - await waitFor(() => expect(start).toHaveBeenCalledTimes(2)); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(false); - expect(result.current.value).toBe(''); - } - }); - - await act(async () => { - attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - await tick(); - }); - - expect(cancel).not.toHaveBeenCalled(); - expect(finish).not.toHaveBeenCalled(); - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(false); - } - }); - - it('stays on the current step pending while finish runs', async () => { - const finish = deferred(); - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - 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('retrying')); - }); - - it('stays on the current step with the error when finish fails', async () => { - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - 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: buildActiveModel({ start }) } }, - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - expect(start).toHaveBeenCalledOnce(); - - rerender({ model: { status: 'loading', cancel: vi.fn() } }); - 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: buildActiveModel({ - 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', cancel: vi.fn() } }); - 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('retrying')); - 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: buildActiveModel({ start }) } }, - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - rerender({ model: { status: 'inactive' } }); - expect(result.current).toEqual({ status: 'idle' }); - - rerender({ model: buildActiveModel({ start }) }); - 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( - buildActiveModel({ - 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( - buildActiveModel({ - 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( - buildActiveModel({ - 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( - buildActiveModel({ - 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); - } - }); - }); - - it('keeps the last factor pending during retrying and resets only when inactive', async () => { - const finish = deferred(); - const start = vi.fn(() => Promise.resolve(firstFactorResult())); - const cancel = vi.fn(); - const active = buildActiveModel({ - start, - cancel, - attempt: vi.fn(() => - Promise.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - ), - finish: () => finish.promise, - }); - const { result, rerender } = renderHook( - ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { initialProps: { model: active } }, - ); - - 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); - } - }); - - rerender({ model: { status: 'retrying' } }); - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('password'); - expect(result.current.isPending).toBe(true); - expect(result.current.onCancel).toBeUndefined(); - } - expect(start).toHaveBeenCalledOnce(); - - act(() => { - finish.resolve(); - }); - await waitFor(() => { - expect(result.current.status).toBe('retrying'); - if (result.current.status === 'retrying') { - expect(result.current.step).toBe('password'); - expect(result.current.isPending).toBe(true); - expect(result.current.onCancel).toBeUndefined(); - } - }); - expect(cancel).not.toHaveBeenCalled(); - - rerender({ model: { status: 'inactive' } }); - expect(result.current).toEqual({ status: 'idle' }); - }); - - it('is retrying without onCancel as soon as finish resolves', async () => { - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - attempt: vi.fn(() => - Promise.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - ), - }), - ), - ); - - 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('retrying')); - expect(result.current.onCancel).toBeUndefined(); - }); - - it('shows the pending card, not unavailable, when start completes without a factor', async () => { - const finish = deferred(); - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - start: vi.fn(() => - Promise.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - ), - finish: () => finish.promise, - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('loading')); - act(() => { - finish.resolve(); - }); - await tick(); - expect(result.current.status).toBe('loading'); - }); -}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx deleted file mode 100644 index 97a327c59ea..00000000000 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx +++ /dev/null @@ -1,340 +0,0 @@ -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 ReverificationActiveModel, - type ReverificationModel, - useReverificationModel, -} from '../reverification.model'; - -function active(model: ReverificationModel): ReverificationActiveModel { - expect(model.status).toBe('active'); - if (model.status !== 'active') { - throw new Error('expected active'); - } - 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 { - phase: 'active' 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 cancel = vi.fn(); - const { result } = renderHook(() => useReverificationModel({ ...activeProps(), cancel })); - expect(result.current.status).toBe('loading'); - if (result.current.status === 'loading') { - result.current.cancel(); - } - expect(cancel).toHaveBeenCalledOnce(); - }); - - it('is loading until supportEmail is resolved', () => { - supportEmail = undefined; - const { result } = renderHook(() => useReverificationModel(activeProps())); - expect(result.current.status).toBe('loading'); - }); - - it('is active when props are active', () => { - const { result } = renderHook(() => useReverificationModel(activeProps())); - expect(active(result.current).supportEmail).toBe('support@example.com'); - }); - - it('is inactive when props are idle, regardless of hydration', () => { - session = null; - environmentHydrated = false; - const { result } = renderHook(() => useReverificationModel({ phase: 'inactive' })); - expect(result.current).toEqual({ status: 'inactive' }); - }); - - it('is retrying when props are retrying, regardless of hydration', () => { - session = null; - environmentHydrated = false; - const { result } = renderHook(() => useReverificationModel({ phase: 'retrying' })); - expect(result.current).toEqual({ status: 'retrying' }); - }); - - it('defaults to second-factor verification when no level is provided', async () => { - session?.startVerification.mockResolvedValue(resource()); - const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: undefined })); - - await active(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 active(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 active(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 active(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 active(result.current).start(); - await active(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 active(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'secret'); - expect(session?.attemptFirstFactorVerification).toHaveBeenCalledWith({ - strategy: 'password', - password: 'secret', - }); - - await active(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 active(result.current).start(); - await active(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 active(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 active(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( - active(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'bad'), - ).rejects.toMatchObject({ message: 'That password is incorrect.' }); - }); - - it('activates the verified session before complete', async () => { - session?.startVerification.mockResolvedValue(resource({ status: 'complete' })); - const order: string[] = []; - setActive.mockImplementation(() => { - order.push('setActive'); - }); - const props = activeProps(); - props.complete.mockImplementation(() => { - order.push('complete'); - }); - const { result } = renderHook(() => useReverificationModel(props)); - const started = await active(result.current).start(); - expect(started).toEqual({ status: 'complete', methods: [], startingMethod: null }); - await active(result.current).finish(); - expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' }); - expect(order).toEqual(['setActive', 'complete']); - }); - - 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(active(result.current).finish()).rejects.toMatchObject({ message: 'Session could not be activated.' }); - expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' }); - expect(props.complete).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx deleted file mode 100644 index 645285c2c08..00000000000 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -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 { ReverificationViewProps } from '../reverification.types'; - -let controller: ReverificationController = { status: 'idle' }; - -vi.mock('../reverification.view', () => ({ - ReverificationPending: () => , - ReverificationUnavailable: () => , - ReverificationView: ({ step }: { step: string }) => {step}, -})); - -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 while reverification is inactive', () => { - controller = { status: 'idle' }; - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders pending, unavailable, or the factor view', () => { - controller = { status: 'loading' }; - const { rerender } = render(); - expect(screen.getByTestId('pending')).toBeInTheDocument(); - expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - - controller = { status: 'unavailable' }; - rerender(); - expect(screen.getByTestId('unavailable')).toBeInTheDocument(); - expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - - controller = ready({ step: 'otp', otpChannel: 'email' }); - rerender(); - expect(screen.getByTestId('view')).toHaveTextContent('otp'); - }); - - it('keeps the factor flow mounted through retrying', () => { - controller = ready({ isPending: true }); - const { rerender } = render(); - expect(screen.getByTestId('view')).toHaveTextContent('password'); - - controller = { status: 'idle' }; - rerender(); - expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - }); -}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx deleted file mode 100644 index cf185f5c28c..00000000000 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.view.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { act, 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 Flow surface while view props and panels change', () => { - const { container, rerender } = renderView(); - - const flow = container.querySelector('.cl-flow-root'); - const passwordStep = screen.getByLabelText('Password').closest('.cl-flow-step'); - - expect(container.querySelector('.cl-card-root')).toBeNull(); - expect(flow).toHaveAttribute('data-value', 'password'); - - rerender( - - - , - ); - - expect(container.querySelector('.cl-flow-root')).toBe(flow); - expect(screen.getByLabelText('Password').closest('.cl-flow-step')).toBe(passwordStep); - - rerender( - - - , - ); - - 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('moves focus to the entering step once it settles', async () => { - const { rerender } = renderView(); - - expect(screen.getByLabelText('Password')).not.toHaveFocus(); - - rerender( - - - , - ); - await act(async () => {}); - - expect(screen.getAllByRole('textbox')[0]).toHaveFocus(); - - rerender( - - - , - ); - await act(async () => {}); - - expect(screen.getByRole('button', { name: 'Continue with your password' })).toHaveFocus(); - }); - - 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('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.'); - }); -}); From b9fc905ea895a6cddc87a901463a4d67b508c821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 1 Oct 2026 10:50:41 +0200 Subject: [PATCH 34/40] Fix rabbit feedback --- .../mosaic/src/__tests__/feature/fake-fapi/verification.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/verification.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/verification.ts index 3f699fbc766..6bc62122ef4 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi/verification.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/verification.ts @@ -61,7 +61,6 @@ type VerificationRequest = { params: Record; request: Request } const REVERIFICATION_LEVELS: readonly string[] = ['first_factor', 'second_factor', 'multi_factor']; const PREPARABLE_STRATEGIES: readonly string[] = ['email_code', 'phone_code', 'passkey']; -const CODE_STRATEGIES: readonly string[] = ['email_code', 'phone_code', 'passkey']; function apiError(status: number, code: string, message: string, longMessage = message, paramName?: string) { return HttpResponse.json( @@ -212,9 +211,12 @@ export function verificationHandlers(state: VerificationHost, url: (path: string } const prepared = stage === 'first' ? state.verification.firstFactorVerification : state.verification.secondFactorVerification; - if (CODE_STRATEGIES.includes(strategy) && prepared?.strategy !== strategy) { + if (PREPARABLE_STRATEGIES.includes(strategy) && prepared?.strategy !== strategy) { return verificationErrors.notSent(); } + if (strategy !== 'passkey' && state.verification.secrets[strategy as VerificationSecretStrategy] === undefined) { + throw new Error(`fake FAPI: no secret seeded for strategy "${strategy}"`); + } if (strategy === 'password') { if (body.get('password') !== state.verification.secrets.password) { return verificationErrors.passwordIncorrect(); From ea344f60106171768008e100016142fa743db8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 1 Oct 2026 10:58:18 +0200 Subject: [PATCH 35/40] Add better test isolation --- packages/mosaic/vitest.setup.browser.mts | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/mosaic/vitest.setup.browser.mts b/packages/mosaic/vitest.setup.browser.mts index 0e23f419066..fb56c2f3de6 100644 --- a/packages/mosaic/vitest.setup.browser.mts +++ b/packages/mosaic/vitest.setup.browser.mts @@ -7,7 +7,33 @@ import { startWorker, takeUnhandledRequests, takeUnsettledHolds, worker } from ' expect.extend(matchers); +const NativeBroadcastChannel = window.BroadcastChannel; +const channelNamespace = crypto.randomUUID(); +const openChannels = new Set(); + +class IsolatedBroadcastChannel extends NativeBroadcastChannel { + private isClosed = false; + + constructor(name: string) { + super(`${channelNamespace}:${name}`); + openChannels.add(this); + } + + override postMessage(message: unknown) { + if (!this.isClosed) { + super.postMessage(message); + } + } + + override close() { + this.isClosed = true; + openChannels.delete(this); + super.close(); + } +} + beforeAll(async () => { + window.BroadcastChannel = IsolatedBroadcastChannel; await startWorker(); }); @@ -18,6 +44,14 @@ beforeEach(() => { afterEach(() => { cleanup(); __resetClerkQueryClientForTest(); + for (const channel of [...openChannels]) { + channel.close(); + } + for (const cookie of document.cookie.split('; ').filter(Boolean)) { + document.cookie = `${cookie.split('=')[0]}=; max-age=0; path=/`; + } + localStorage.clear(); + sessionStorage.clear(); worker.resetHandlers(); const unhandled = takeUnhandledRequests(); const unsettled = takeUnsettledHolds(); @@ -26,5 +60,6 @@ afterEach(() => { }); afterAll(() => { + window.BroadcastChannel = NativeBroadcastChannel; worker.stop(); }); From 1b3f31948a9084b0bcc894d51ac4e26b68a78340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 1 Oct 2026 12:07:16 +0200 Subject: [PATCH 36/40] Remake unavailable into a more flexible error page with messages --- .../destructive.controller.test.ts | 2 +- .../__tests__/reverification.feature.test.tsx | 84 +++++++------- .../reverification.controller.ts | 62 +++++----- .../reverification/reverification.messages.ts | 12 +- .../reverification/reverification.model.tsx | 107 +++++++----------- .../reverification/reverification.tsx | 6 +- .../reverification/reverification.types.ts | 2 + .../reverification/reverification.utils.ts | 12 ++ .../reverification/reverification.view.tsx | 14 ++- 9 files changed, 156 insertions(+), 145 deletions(-) diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts index d4cf5b8ef62..3b3f56e4941 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts @@ -167,7 +167,7 @@ describe('useDestructiveController', () => { it.each([ ['idle', 'confirm'], ['loading', 'confirm'], - ['unavailable', 'verify'], + ['error', 'verify'], ['ready', 'verify'], ['retrying', 'verify'], ] as const)('reverification status %s uses the %s step', (status, step) => { diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index 57c40b0ea92..8b346d8dc2f 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -1,4 +1,5 @@ import { reverificationError } from '@clerk/shared/authorization-errors'; +import { ClerkWebAuthnError } from '@clerk/shared/error'; import type { PublicKeyCredentialWithAuthenticatorAssertionResponse, ReverificationConfig, @@ -73,6 +74,8 @@ const secondPhoneFactor: SignInSecondFactorJSON = { }; const TITLE = 'Verification required'; +const NO_FACTORS_TITLE = 'Cannot verify your account'; +const GENERIC_ERROR_TITLE = 'Something went wrong'; const STEP = { password: 'Enter your current password to continue', emailCode: 'Enter the code sent to your email to continue', @@ -300,8 +303,7 @@ describe('Reverification', () => { // The first step opens once Clerk is ready }); - // Currently wrong: entering `unavailable` calls cancel(), which closes the challenge, so the unavailable card only flashes and the user is never told why. Needs a product decision (keep the card until dismissed, or assert only the rejection). - it('cancels the action when verification cannot be started', async () => { + it('keeps showing a generic error card until it is dismissed when verification cannot be started', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); worker.use( http.post(fapiUrl(VERIFY), () => @@ -309,26 +311,40 @@ describe('Reverification', () => { ), ); - await startVerification(); + const user = await startVerification(); - await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); - await untilClosed(); - }); + // The error card stays on screen without the server's message + expect(await screen.findByText(GENERIC_ERROR_TITLE)).toBeVisible(); + expect(screen.getByText('Please try again.')).toBeVisible(); + expect(screen.queryByText(NO_FACTORS_TITLE)).toBeNull(); + expect(screen.queryByText('Nope')).toBeNull(); + await settle(); + expect(screen.getByText(GENERIC_ERROR_TITLE)).toBeVisible(); + expect(outcome()).toBe(''); - // Needs a product decision: keep the unavailable card until the user dismisses it, or cancel without showing it. - it.todo('keeps the unavailable card visible until it is dismissed', () => { - // The unavailable card stays on screen instead of flashing // Dismissing it rejects the action as cancelled + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + expect(screen.queryByText(GENERIC_ERROR_TITLE)).toBeNull(); }); - // Currently wrong: same as above. Enterprise SSO is dropped by the model mapping, which leaves no usable factor. - it('cancels the action when none of the factors are supported', async () => { + it('keeps showing the no factors card until it is dismissed when none of the factors are supported', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [enterpriseSsoFactor] })); - await startVerification(); + const user = await startVerification(); + expect(await screen.findByText(NO_FACTORS_TITLE)).toBeVisible(); + expect(screen.getByText(/No suitable authentication factor is configured/)).toBeVisible(); + expect(screen.queryByText(GENERIC_ERROR_TITLE)).toBeNull(); + await settle(); + expect(screen.getByText(NO_FACTORS_TITLE)).toBeVisible(); + expect(outcome()).toBe(''); + + await dismiss(user); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); await untilClosed(); + expect(screen.queryByText(NO_FACTORS_TITLE)).toBeNull(); }); }); @@ -691,7 +707,10 @@ describe('Reverification', () => { let acceptCredential: () => void = () => {}; const getCredential = vi .fn() - .mockResolvedValueOnce({ publicKeyCredential: null, error: new Error('The operation was refused') }) + .mockResolvedValueOnce({ + publicKeyCredential: null, + error: new ClerkWebAuthnError('The operation was refused', { code: 'passkey_retrieval_failed' }), + }) .mockImplementationOnce( () => new Promise(resolve => { @@ -934,8 +953,7 @@ describe('Reverification', () => { }); describe('finishing', () => { - // Currently wrong: the server verification is already complete at this point, so resubmitting fails with invalid_action_for_session_reverification and the user can't recover. Needs a product decision (retry finish, or restart the verification). - it('returns to the last step with an error when the session cannot be activated', async () => { + it('shows a generic error card when the session cannot be activated', async () => { const { action } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor] }), @@ -950,24 +968,20 @@ describe('Reverification', () => { expect(verifying()).toBeInTheDocument(); touch.fail(); - // The error is shown on the last step - expect(await screen.findByText('form_param_invalid')).toBeInTheDocument(); - expect(screen.getByText(STEP.password)).toBeVisible(); - await waitFor(() => expect(passwordField()).toBeEnabled()); + // The step is replaced by a generic error card without the server's message + expect(await screen.findByText(GENERIC_ERROR_TITLE)).toBeVisible(); + expect(screen.getByText('Please try again.')).toBeVisible(); + expect(screen.queryByText('form_param_invalid')).toBeNull(); + expect(screen.queryByText(STEP.password)).toBeNull(); // The action is not retried expect(action).toHaveBeenCalledTimes(1); expect(outcome()).toBe(''); - // Submitting again fails because the server verification is already complete - await user.type(passwordField(), '{Enter}'); - expect(await screen.findByText(/unable to attempt first factor/)).toBeInTheDocument(); - expect(outcome()).toBe(''); - }); - - // Needs a product decision: retry the activation on the same step, or restart the verification. - it.todo('lets the user recover when the session cannot be activated', () => { - // The user can reach a resolved action without leaving the card in a dead end + // Dismissing it rejects the action as cancelled + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + expect(screen.queryByText(GENERIC_ERROR_TITLE)).toBeNull(); }); it('cannot be interrupted while the action is being retried', async () => { @@ -1206,7 +1220,7 @@ describe('Reverification', () => { }); describe('errors', () => { - // Currently wrong: the model keeps only the server's longMessage or message (toError drops the error code), so nothing downstream can localize it. Known codes such as form_password_incorrect, form_code_incorrect and verification_not_sent should map to localized messages the way the old UI does. + // Currently wrong: the controller keeps only the server's longMessage or message (errorDetail drops the error code), so nothing downstream can localize it. Known codes such as form_password_incorrect, form_code_incorrect and verification_not_sent should map to localized messages the way the old UI does. // Blocked: Mosaic has no message keys for error codes yet, so there is nothing a test can override. it.todo('shows a localized message for a wrong password', () => { // The message for form_password_incorrect follows the configured locale @@ -1248,8 +1262,7 @@ describe('Reverification', () => { expect(await screen.findByText('Short message')).toBeInTheDocument(); }); - // Currently wrong: the generic message is hardcoded English in both the model and the controller, and the localizable unstable__errors__generic message is unused. - it.fails('shows a localized generic message when the failure is not a Clerk error', async () => { + it('shows a localized generic message when the failure is not a Clerk error', async () => { const { clerk } = await renderReverification( guardedAction(), verificationSeed({ firstFactors: [passwordFactor] }), @@ -1266,8 +1279,7 @@ describe('Reverification', () => { expect(await screen.findByText('Something custom went wrong')).toBeInTheDocument(); }); - // Currently wrong: the message is the raw clerk-js network error including the request URL, and the generic fallback is never used for it. - it.fails('does not show the request URL when there is a network failure', async () => { + it('shows the generic message instead of the request URL when there is a network failure', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] })); worker.use(http.post(fapiUrl(ATTEMPT_FIRST), () => HttpResponse.error(), { once: true })); const restore = pretendToBeARealBrowser(); @@ -1278,6 +1290,7 @@ describe('Reverification', () => { await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); await waitFor(() => expect(passwordField()).toBeInvalid()); + expect(screen.getByText('Something went wrong. Please try again.')).toBeInTheDocument(); expect(document.body).not.toHaveTextContent(/https?:\/\//); } finally { restore(); @@ -1306,11 +1319,6 @@ describe('Reverification', () => { restore(); } }); - - // Unsure about behavior: a start failure ends in the unavailable card without the reason, and a session activation failure reuses the last step's field error. Neither has a dedicated error surface. - it.todo('explains why verification cannot continue when it cannot be started', () => { - // The reason is shown instead of only the unavailable message - }); }); }); diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index 9252d54ae71..b304063bf5c 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -1,32 +1,38 @@ import { useEffect, useState } from 'react'; import type { FlowDirection } from '../../components/flow'; +import { useMessages } from '../../localization'; import { setup } from '../../machine/setup'; import type { DoneInvokeEvent, StateConfig } from '../../machine/types'; import { useMachine } from '../../machine/useMachine'; import type { ReverificationActiveModel, ReverificationModel } from './reverification.model'; import type { + ReverificationErrorReason, ReverificationMethod, ReverificationResult, ReverificationStep, ReverificationViewProps, } from './reverification.types'; -import { needsPrepare, otpChannelFor } from './reverification.utils'; +import { errorDetail, needsPrepare, otpChannelFor } from './reverification.utils'; export type ReverificationController = | { status: 'idle'; onCancel?: undefined } | { status: 'loading'; onCancel?: () => void } - | { status: 'unavailable'; onCancel?: () => void } + | { status: 'error'; reason: ReverificationErrorReason; onCancel?: () => void } | ({ status: 'ready'; onCancel?: () => void } & ReverificationViewProps) | ({ status: 'retrying'; onCancel?: undefined } & ReverificationViewProps); type OverlayFrom = 'factor' | 'method-picker'; -export type ReverificationDeps = Pick; +export type ReverificationDeps = Pick; + +interface FactorError { + detail: string | undefined; +} interface ReverificationContext { inputValue: string; - errorMessage: string | undefined; + error: FactorError | undefined; direction: FlowDirection; activeMethod: ReverificationMethod | null; methods: readonly ReverificationMethod[]; @@ -60,7 +66,6 @@ const unseatedDeps: ReverificationDeps = { prepare: notSeated, attempt: notSeated, finish: notSeated, - cancel: () => {}, }; export const RESEND_COOLDOWN_MS = 30_000; @@ -73,15 +78,15 @@ function unlockResend(): Pick { return { resendAvailableAt: undefined }; } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'Something went wrong. Please try again.'; +function factorError(error: unknown): FactorError { + return { detail: errorDetail(error) }; } function selectMethod(ctx: ReverificationContext, id: string): Partial { return { activeMethod: ctx.methods.find(method => method.id === id) ?? ctx.activeMethod, inputValue: '', - errorMessage: undefined, + error: undefined, direction: 1, submitRequested: false, }; @@ -104,7 +109,7 @@ const applyResult = assign>((_, event) => methods: event.output.methods, activeMethod: event.output.startingMethod, inputValue: '', - errorMessage: undefined, + error: undefined, ...unlockResend(), direction: 1, })); @@ -134,7 +139,7 @@ const afterResult = [ const factorEvents = { TYPE: { - actions: assign((_, event) => ({ inputValue: event.value, errorMessage: undefined })), + actions: assign((_, event) => ({ inputValue: event.value, error: undefined })), }, SHOW_METHODS: { target: 'methodPicker', @@ -162,7 +167,7 @@ export const reverificationMachine = createMachine({ initial: 'inactive', context: { inputValue: '', - errorMessage: undefined, + error: undefined, direction: 1, activeMethod: null, methods: [], @@ -177,7 +182,7 @@ export const reverificationMachine = createMachine({ inactive: { entry: assign(() => ({ inputValue: '', - errorMessage: undefined, + error: undefined, submitRequested: false, ...unlockResend(), })), @@ -188,7 +193,7 @@ export const reverificationMachine = createMachine({ on: { RESET: 'inactive' }, invoke: fromPromise(ctx => ctx.deps.start(), { onDone: afterResult, - onError: 'unavailable', + onError: 'failed', }), }, @@ -217,7 +222,7 @@ export const reverificationMachine = createMachine({ onError: { target: 'verifying', actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), + error: factorError(event.error), submitRequested: false, ...unlockResend(), })), @@ -238,7 +243,7 @@ export const reverificationMachine = createMachine({ onDone: 'verifying', onError: { target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error), ...unlockResend() })), + actions: assign((_, event) => ({ error: factorError(event.error), ...unlockResend() })), }, }), }, @@ -256,7 +261,7 @@ export const reverificationMachine = createMachine({ actions: assign(() => ({ ...lockResend(), inputValue: '', - errorMessage: undefined, + error: undefined, submitRequested: false, })), }, @@ -269,7 +274,7 @@ export const reverificationMachine = createMachine({ onDone: afterResult, onError: { target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), + actions: assign((_, event) => ({ error: factorError(event.error) })), }, }), }, @@ -311,9 +316,10 @@ export const reverificationMachine = createMachine({ }, unavailable: { - entry: (ctx: ReverificationContext) => { - ctx.deps.cancel(); - }, + on: { RESET: 'inactive' }, + }, + + failed: { on: { RESET: 'inactive' }, }, @@ -321,10 +327,7 @@ export const reverificationMachine = createMachine({ on: { RESET: 'inactive' }, invoke: fromPromise(ctx => ctx.deps.finish(), { onDone: 'retrying', - onError: { - target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), - }, + onError: 'failed', }), }, @@ -378,7 +381,7 @@ function viewStep(value: string, method: ReverificationMethod | null): Reverific * Machine - State internal to the controller, not all steps are exposed to the UI * Return 'ReverificationController' - The view state * - status: 'idle' renders nothing - * - status: 'loading' | 'unavailable' carry no factor props + * - status: 'loading' | 'error' carry no factor props * - status: 'ready' | 'retrying' carry the factor view, including step * * There are two pending presentations. @@ -390,6 +393,7 @@ function viewStep(value: string, method: ReverificationMethod | null): Reverific * while the model is active and resets when the model returns to inactive. */ export function useReverificationController(model: ReverificationModel): ReverificationController { + const m = useMessages('reverification'); const active = model.status === 'active' ? model : null; const [snapshot, send] = useMachine( @@ -445,7 +449,11 @@ export function useReverificationController(model: ReverificationModel): Reverif const activeMethod = context.activeMethod; if (snapshot.value === 'unavailable') { - return { status: 'unavailable', onCancel }; + return { status: 'error', reason: 'noFactors', onCancel }; + } + + if (snapshot.value === 'failed') { + return { status: 'error', reason: 'generic', onCancel }; } const step = viewStep(snapshot.value, activeMethod); @@ -465,7 +473,7 @@ export function useReverificationController(model: ReverificationModel): Reverif direction: context.direction, value: context.inputValue, onValueChange: (value: string) => send({ type: 'TYPE', value }), - errorMessage: context.errorMessage, + errorMessage: context.error ? (context.error.detail ?? m.unstable__errors__generic) : undefined, isPending: pendingStates.has(snapshot.value), onSubmit: () => send({ type: 'SUBMIT' }), onShowMethods: () => send({ type: 'SHOW_METHODS' }), diff --git a/packages/mosaic/src/features/reverification/reverification.messages.ts b/packages/mosaic/src/features/reverification/reverification.messages.ts index bb6a0c63397..e08b2deeabe 100644 --- a/packages/mosaic/src/features/reverification/reverification.messages.ts +++ b/packages/mosaic/src/features/reverification/reverification.messages.ts @@ -53,9 +53,15 @@ export const reverificationMessages = { resendButton: 'Didn’t receive a code? Resend', title: 'Verification required', }, - unavailable: { - description: 'Cannot proceed with verification. No suitable authentication factor is configured.', - title: 'Cannot verify your account', + error: { + generic: { + description: 'Please try again.', + title: 'Something went wrong', + }, + noFactors: { + description: 'Cannot proceed with verification. No suitable authentication factor is configured.', + title: 'Cannot verify your account', + }, }, totpMfa: { description: 'Enter the code generated by your authenticator app to continue', diff --git a/packages/mosaic/src/features/reverification/reverification.model.tsx b/packages/mosaic/src/features/reverification/reverification.model.tsx index 0650c3f9cae..87462cc456a 100644 --- a/packages/mosaic/src/features/reverification/reverification.model.tsx +++ b/packages/mosaic/src/features/reverification/reverification.model.tsx @@ -1,4 +1,3 @@ -import { isClerkAPIResponseError } from '@clerk/shared/error'; import { useClerk, useSession } from '@clerk/shared/react'; import type { PreferredSignInStrategy, @@ -35,14 +34,6 @@ export type ReverificationModel = | ReverificationActiveModel | { status: 'retrying' }; -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, @@ -146,80 +137,58 @@ export function useReverificationModel(reverificationState: ReverificationState) return { status: 'active', supportEmail, - start: async () => { - try { - return handleResponse(await session.startVerification({ level: level ?? 'second_factor' })); - } catch (error) { - throw toError(error); - } - }, + start: async () => handleResponse(await session.startVerification({ level: level ?? 'second_factor' })), 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({ + 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; - } - } catch (error) { - throw toError(error); + } + await session.prepareFirstFactorVerification({ + strategy: 'phone_code', + phoneNumberId: method.phoneNumberId, + }); + return; } }, attempt: async (method, value) => { - try { - switch (method.strategy) { - case 'password': + 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.attemptFirstFactorVerification({ strategy: 'password', password: value }), + await session.attemptSecondFactorVerification({ strategy: 'phone_code', code: 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); + } + 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()); } }, finish: async () => { - try { - await clerk.setActive({ session: session.id }); - complete(); - } catch (error) { - throw toError(error); - } + await clerk.setActive({ session: session.id }); + complete(); }, }; } diff --git a/packages/mosaic/src/features/reverification/reverification.tsx b/packages/mosaic/src/features/reverification/reverification.tsx index ab75fff6c80..03c3bf1c23c 100644 --- a/packages/mosaic/src/features/reverification/reverification.tsx +++ b/packages/mosaic/src/features/reverification/reverification.tsx @@ -1,6 +1,6 @@ import { type ReverificationController, useReverificationController } from './reverification.controller'; import { useReverificationModel } from './reverification.model'; -import { ReverificationPending, ReverificationUnavailable, ReverificationView } from './reverification.view'; +import { ReverificationError, ReverificationPending, ReverificationView } from './reverification.view'; import { type ReverificationFetcher, useReverificationWithState, @@ -29,8 +29,8 @@ export function Reverification(controller: ReverificationController) { if (controller.status === 'loading') { return ; - } else if (controller.status === 'unavailable') { - return ; + } else if (controller.status === 'error') { + return ; } return ; diff --git a/packages/mosaic/src/features/reverification/reverification.types.ts b/packages/mosaic/src/features/reverification/reverification.types.ts index 3fcaa6b43f2..ada8be5fc0d 100644 --- a/packages/mosaic/src/features/reverification/reverification.types.ts +++ b/packages/mosaic/src/features/reverification/reverification.types.ts @@ -8,6 +8,8 @@ export type ReverificationStage = 'first' | 'second'; export type ReverificationOtpChannel = 'email' | 'phone' | 'totp'; +export type ReverificationErrorReason = 'noFactors' | 'generic'; + export type ReverificationStep = 'password' | 'passkey' | 'otp' | 'backup-code' | 'method-picker' | 'help'; export type ReverificationMethod = diff --git a/packages/mosaic/src/features/reverification/reverification.utils.ts b/packages/mosaic/src/features/reverification/reverification.utils.ts index 42f07b27c3e..ac089479bec 100644 --- a/packages/mosaic/src/features/reverification/reverification.utils.ts +++ b/packages/mosaic/src/features/reverification/reverification.utils.ts @@ -1,3 +1,4 @@ +import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; import type { PreferredSignInStrategy } from '@clerk/shared/types'; import type { @@ -7,6 +8,17 @@ import type { ReverificationStrategy, } from './reverification.types'; +export function errorDetail(error: unknown): string | undefined { + if (isClerkAPIResponseError(error)) { + const first = error.errors[0]; + return first?.longMessage || first?.message || error.message; + } + if (isClerkRuntimeError(error) && error.code !== 'network_error') { + return error.message; + } + return undefined; +} + export function otpChannelFor(strategy: ReverificationStrategy): ReverificationOtpChannel | undefined { if (strategy === 'email_code') { return 'email'; diff --git a/packages/mosaic/src/features/reverification/reverification.view.tsx b/packages/mosaic/src/features/reverification/reverification.view.tsx index 1e13684542a..515d0188a48 100644 --- a/packages/mosaic/src/features/reverification/reverification.view.tsx +++ b/packages/mosaic/src/features/reverification/reverification.view.tsx @@ -6,7 +6,12 @@ import type { IconName } from '../../icons/registry'; import type { MosaicMessages } from '../../localization'; import { fill, useMessages } from '../../localization'; import { styles } from './reverification.styles'; -import type { ReverificationMethod, ReverificationOtpChannel, ReverificationViewProps } from './reverification.types'; +import type { + ReverificationErrorReason, + ReverificationMethod, + ReverificationOtpChannel, + ReverificationViewProps, +} from './reverification.types'; import { ReverificationBackupCode } from './steps/reverification-backup-code'; import { ReverificationHelp } from './steps/reverification-help'; import { ReverificationMethodPicker } from './steps/reverification-method-picker'; @@ -44,13 +49,14 @@ function methodLabel(method: ReverificationMethod, m: Messages): string { } // Lives here but is rendered by the wrapper parent -export function ReverificationUnavailable() { +export function ReverificationError({ reason }: { reason: ReverificationErrorReason }) { const m = useMessages('reverification'); + const copy = m.error[reason]; return ( - {m.unavailable.title} - {m.unavailable.description} + {copy.title} + {copy.description} ); } From 4108ef54acd4096a0d27c1bbf035da91e8a61433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 1 Oct 2026 12:19:33 +0200 Subject: [PATCH 37/40] Add reverification pending and error stories to swingset --- .../app/(clerk)/live/reverification/page.tsx | 2 +- .../swingset/src/stories/reverification.mdx | 20 ++++++++++++- .../src/stories/reverification.stories.tsx | 30 ++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx index 6b37915c0ce..f9c8888bc8c 100644 --- a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -109,7 +109,7 @@ function DialogHarness() { const nextStep: OuterStep | null = reverification.status === 'retrying' && presentation === 'replace' ? 'finalizing' - : reverification.status === 'ready' || reverification.status === 'unavailable' + : reverification.status === 'ready' || reverification.status === 'error' ? 'verify' : null; diff --git a/packages/swingset/src/stories/reverification.mdx b/packages/swingset/src/stories/reverification.mdx index 370d586c5cc..7a4ee21a61d 100644 --- a/packages/swingset/src/stories/reverification.mdx +++ b/packages/swingset/src/stories/reverification.mdx @@ -17,7 +17,7 @@ The example pauses briefly in each pending state. Enter `error` in password or b ## Usage -The feature wrapper reads Clerk state and drives the reverification flow. It renders nothing while `status` is `idle`. While a challenge is still preparing, the parent card shows a verification header, a centered spinner, and a disabled continue button, then shows the factor without a step transition. Keep the same card around the component for a ready factor, a pending retry, or the unavailable state. Isolated panel stories below are for visual development only. +The feature wrapper reads Clerk state and drives the reverification flow. It renders nothing while `status` is `idle`. While a challenge is still preparing, the parent card shows a verification header, a centered spinner, and a disabled continue button, then shows the factor without a step transition. Keep the same card around the component for a ready factor, a pending retry, or the error state. Isolated panel stories below are for visual development only. ```tsx import { Card } from '@clerk/mosaic/components/card'; @@ -34,6 +34,24 @@ const [runAction, reverification] = useReverificationFlow(action); These examples isolate the presentational views and their visual states. Product code should normally render the `Reverification` block shown above. +### Pending + +This state is used while reverification is still loading/starting and we don't know the factor yet, it can be avoided entirely by not rendering `` while `reverification.status === 'loading'`. + + + +### Error + +Most errors are shown inline on the factor page, but these states are for catastrophic errors outside of that. + +#### No factors available + + + +#### Something went wrong + + + ### Password diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx index 1b39191d9ca..7b9af8d24d0 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -2,7 +2,11 @@ import { Button } from '@clerk/mosaic/components/button'; import { Card } from '@clerk/mosaic/components/card'; import type { ReverificationMethod, ReverificationStep } from '@clerk/mosaic/features/reverification'; import { otpChannelFor } from '@clerk/mosaic/features/reverification/reverification.utils'; -import { ReverificationView } from '@clerk/mosaic/features/reverification/reverification.view'; +import { + ReverificationError, + ReverificationPending, + ReverificationView, +} from '@clerk/mosaic/features/reverification/reverification.view'; import { ReverificationBackupCode } from '@clerk/mosaic/features/reverification/steps/reverification-backup-code'; import { ReverificationHelp } from '@clerk/mosaic/features/reverification/steps/reverification-help'; import { ReverificationMethodPicker } from '@clerk/mosaic/features/reverification/steps/reverification-method-picker'; @@ -263,6 +267,30 @@ export function Default(): JSX.Element { ); } +export function Pending(): JSX.Element { + return ( + + + + ); +} + +export function ErrorNoFactors(): JSX.Element { + return ( + + + + ); +} + +export function ErrorGeneric(): JSX.Element { + return ( + + + + ); +} + function PasswordPanel({ isPending = false, errorMessage, From 7a917e51fd62dcd68622c3c19576bb1ce223ff5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 1 Oct 2026 12:37:10 +0200 Subject: [PATCH 38/40] Improve errors just a tad --- .../__tests__/reverification.feature.test.tsx | 6 ++--- .../reverification/reverification.messages.ts | 1 + .../reverification/reverification.tsx | 7 +++++- .../reverification/reverification.view.tsx | 25 +++++++++++++++---- .../src/stories/reverification.stories.tsx | 10 ++++++-- 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index 8b346d8dc2f..dc967f2d75b 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -322,8 +322,8 @@ describe('Reverification', () => { expect(screen.getByText(GENERIC_ERROR_TITLE)).toBeVisible(); expect(outcome()).toBe(''); - // Dismissing it rejects the action as cancelled - await dismiss(user); + // Closing it rejects the action as cancelled + await user.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); await untilClosed(); expect(screen.queryByText(GENERIC_ERROR_TITLE)).toBeNull(); @@ -341,7 +341,7 @@ describe('Reverification', () => { expect(screen.getByText(NO_FACTORS_TITLE)).toBeVisible(); expect(outcome()).toBe(''); - await dismiss(user); + await user.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); await untilClosed(); expect(screen.queryByText(NO_FACTORS_TITLE)).toBeNull(); diff --git a/packages/mosaic/src/features/reverification/reverification.messages.ts b/packages/mosaic/src/features/reverification/reverification.messages.ts index e08b2deeabe..04e333632f0 100644 --- a/packages/mosaic/src/features/reverification/reverification.messages.ts +++ b/packages/mosaic/src/features/reverification/reverification.messages.ts @@ -54,6 +54,7 @@ export const reverificationMessages = { title: 'Verification required', }, error: { + closeButton: 'Close', generic: { description: 'Please try again.', title: 'Something went wrong', diff --git a/packages/mosaic/src/features/reverification/reverification.tsx b/packages/mosaic/src/features/reverification/reverification.tsx index 03c3bf1c23c..aa1474ed5dd 100644 --- a/packages/mosaic/src/features/reverification/reverification.tsx +++ b/packages/mosaic/src/features/reverification/reverification.tsx @@ -30,7 +30,12 @@ export function Reverification(controller: ReverificationController) { if (controller.status === 'loading') { return ; } else if (controller.status === 'error') { - return ; + return ( + + ); } return ; diff --git a/packages/mosaic/src/features/reverification/reverification.view.tsx b/packages/mosaic/src/features/reverification/reverification.view.tsx index 515d0188a48..08c7f457d7b 100644 --- a/packages/mosaic/src/features/reverification/reverification.view.tsx +++ b/packages/mosaic/src/features/reverification/reverification.view.tsx @@ -49,15 +49,30 @@ function methodLabel(method: ReverificationMethod, m: Messages): string { } // Lives here but is rendered by the wrapper parent -export function ReverificationError({ reason }: { reason: ReverificationErrorReason }) { +export function ReverificationError({ reason, onClose }: { reason: ReverificationErrorReason; onClose?: () => void }) { const m = useMessages('reverification'); const copy = m.error[reason]; return ( - - {copy.title} - {copy.description} - + <> + + {copy.title} + {copy.description} + + {onClose ? ( + + + + ) : null} + ); } diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx index 7b9af8d24d0..0581731cb89 100644 --- a/packages/swingset/src/stories/reverification.stories.tsx +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -278,7 +278,10 @@ export function Pending(): JSX.Element { export function ErrorNoFactors(): JSX.Element { return ( - + undefined} + /> ); } @@ -286,7 +289,10 @@ export function ErrorNoFactors(): JSX.Element { export function ErrorGeneric(): JSX.Element { return ( - + undefined} + /> ); } From e218b212662efc8eed1637cf0d1430bf3fe71d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 1 Oct 2026 16:06:17 +0200 Subject: [PATCH 39/40] Introduce new settled state --- .../destructive.controller.test.ts | 37 ++-- .../destructive/destructive.controller.ts | 9 +- .../destructive/destructive.feature.test.tsx | 179 ++++++++++++++++++ .../blocks/destructive/destructive.test.tsx | 8 +- .../src/blocks/destructive/destructive.tsx | 17 +- .../__tests__/reverification.feature.test.tsx | 125 +++++++++++- .../use-reverification-with-state.test.tsx | 82 +++++++- .../reverification.controller.ts | 88 +++++++-- .../reverification/reverification.model.tsx | 7 +- .../reverification/reverification.tsx | 12 +- .../reverification/reverification.types.ts | 3 +- .../use-reverification-with-state.ts | 27 ++- .../app/(clerk)/live/reverification/page.tsx | 23 +-- .../swingset/src/stories/reverification.mdx | 48 ++++- 14 files changed, 588 insertions(+), 77 deletions(-) create mode 100644 packages/mosaic/src/blocks/destructive/destructive.feature.test.tsx diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts index 3b3f56e4941..d8494c98b58 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts @@ -6,7 +6,7 @@ import type { ReverificationController } from '../../features/reverification'; import { deferred } from '../../machines/__tests__/test-utils'; import { useDestructiveController } from './destructive.controller'; -const idleReverification = { status: 'idle' } as ReverificationController; +const idleReverification = { status: 'idle', visible: false } as ReverificationController; describe('useDestructiveController', () => { it('starts closed and opens from the opener or from onOpenChange', () => { @@ -119,7 +119,7 @@ describe('useDestructiveController', () => { const { result } = renderHook(() => useDestructiveController({ onDelete: () => new Promise(() => {}), - reverification: { status: 'ready', onCancel: vi.fn() } as ReverificationController, + reverification: { status: 'ready', visible: true, onCancel: vi.fn() } as ReverificationController, }), ); act(() => result.current.onOpenChange(true)); @@ -136,7 +136,7 @@ describe('useDestructiveController', () => { const { result } = renderHook(() => useDestructiveController({ onDelete: () => new Promise(() => {}), - reverification: { status: 'retrying' } as ReverificationController, + reverification: { status: 'retrying', visible: true } as ReverificationController, }), ); act(() => result.current.onOpenChange(true)); @@ -165,28 +165,41 @@ describe('useDestructiveController', () => { }); it.each([ - ['idle', 'confirm'], - ['loading', 'confirm'], - ['error', 'verify'], - ['ready', 'verify'], - ['retrying', 'verify'], - ] as const)('reverification status %s uses the %s step', (status, step) => { + [false, 'confirm'], + [true, 'verify'], + ] as const)('while deleting, reverification visible=%s uses the %s step', (visible, step) => { const { result } = renderHook(() => useDestructiveController({ - onDelete: () => Promise.resolve(), - reverification: { status } as ReverificationController, + onDelete: () => new Promise(() => {}), + reverification: { status: 'ready', visible } as ReverificationController, }), ); + act(() => result.current.onOpenChange(true)); + act(() => { + void result.current.onDelete(); + }); expect(result.current.step).toBe(step); }); + it('stays on the confirm step when not deleting, whatever the reverification status', () => { + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => Promise.resolve(), + reverification: { status: 'ready', visible: true, onCancel: vi.fn() } as ReverificationController, + }), + ); + act(() => result.current.onOpenChange(true)); + + expect(result.current.step).toBe('confirm'); + }); + it('cancels reverification when the dialog closes', () => { const onCancel = vi.fn(); const { result } = renderHook(() => useDestructiveController({ onDelete: () => new Promise(() => {}), - reverification: { status: 'ready', onCancel } as ReverificationController, + reverification: { status: 'ready', visible: true, onCancel } as ReverificationController, }), ); act(() => result.current.onOpenChange(true)); diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.ts index fec4a2b6124..1cfaa4981a6 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.ts @@ -36,8 +36,7 @@ export function useDestructiveController({ }; const isDeleting = status === 'open-pending'; - const step = - reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm'; + const step = isDeleting && reverification?.visible ? 'verify' : 'confirm'; return { open: status !== 'closed', @@ -51,12 +50,6 @@ export function useDestructiveController({ setDestructiveState({ status: 'open-pending' }); try { await onDelete(); - // TODO: It's possible this might give a flash of the confirm page after - // reverification. - // While we do stay on the current page throughout the retry, the - // useReverificationWithState `.finally` runs first and sets phase - // to inactive. That resets the reverification state and this - // dialog could flash to the first screen. Unconfirmed. setDestructiveState({ status: 'closed' }); } catch (error: unknown) { if (isReverificationCancelledError(error)) { diff --git a/packages/mosaic/src/blocks/destructive/destructive.feature.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.feature.test.tsx new file mode 100644 index 00000000000..f11ac9a836e --- /dev/null +++ b/packages/mosaic/src/blocks/destructive/destructive.feature.test.tsx @@ -0,0 +1,179 @@ +import { reverificationError } from '@clerk/shared/authorization-errors'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it, vi } from 'vitest'; + +import { fapiUrl, serveFapi, worker } from '../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiSession, fapiUser } from '../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../__tests__/feature/render'; +import { useReverificationFlow } from '../../features/reverification'; +import { Destructive } from './destructive'; +import { useDestructiveController } from './destructive.controller'; + +const session = fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }); + +const CONFIRM_TITLE = 'Delete account?'; +const VERIFY = '/v1/client/sessions/sess_1/verify'; + +function Host({ + action, + cleanupMs, + settleMs, +}: { + action: () => Promise; + cleanupMs?: number; + settleMs?: number; +}) { + const [run, reverification] = useReverificationFlow(action); + const controller = useDestructiveController({ + onDelete: async () => { + try { + const result = await run(); + if (settleMs !== undefined) { + await new Promise(resolve => setTimeout(resolve, settleMs)); + } + return result; + } catch (error) { + if (cleanupMs !== undefined) { + await new Promise(resolve => setTimeout(resolve, cleanupMs)); + } + throw error; + } + }, + reverification, + }); + + return ( + <> + + {controller.open ? 'open' : 'closed'} + + + ); +} + +async function openVerification() { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.type(await screen.findByRole('textbox'), 'Delete'); + await user.click(screen.getByRole('button', { name: 'Delete account' })); + return { user }; +} + +async function untilConfirmStepIsGone() { + await waitFor(() => expect(screen.queryByText(CONFIRM_TITLE)).toBeNull()); +} + +function watchForConfirmStep() { + const log: string[] = []; + const inspect = (records: MutationRecord[]) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (node.textContent?.includes(CONFIRM_TITLE)) { + log.push(`added: ${(node as Element).outerHTML?.slice(0, 120) ?? node.textContent}`); + } + } + } + }; + const observer = new MutationObserver(inspect); + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + inspect(observer.takeRecords()); + observer.disconnect(); + return log; + }; +} + +const passwordVerification = { + client: fapiClient([session]), + verification: { secrets: { password: 'hunter2' }, firstFactors: [{ strategy: 'password' as const }] }, +}; + +describe('Destructive with reverification', () => { + it('closes from the error card without going back to the confirm step', async () => { + const action = vi.fn().mockResolvedValueOnce(reverificationError()).mockResolvedValue({ done: true }); + serveFapi({ client: fapiClient([session]) }); + await renderWithClerk( + , + ); + worker.use( + http.post(fapiUrl(VERIFY), () => + HttpResponse.json({ errors: [{ code: 'some_new_error', message: 'Nope' }] }, { status: 400 }), + ), + ); + const { user } = await openVerification(); + + expect(await screen.findByText('Something went wrong')).toBeVisible(); + const buttons = screen.getAllByRole('button', { name: 'Close' }); + await untilConfirmStepIsGone(); + const stopWatching = watchForConfirmStep(); + await user.click(buttons[buttons.length - 1]); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(stopWatching()).toEqual([]); + + expect(screen.getByLabelText('Outcome')).toHaveTextContent('closed'); + }); + + for (const cleanupMs of [undefined, 400]) { + it(`closes from the factor card without going back to the confirm step (host cleans up after ${cleanupMs ?? 0}ms)`, async () => { + const action = vi.fn().mockResolvedValueOnce(reverificationError()).mockResolvedValue({ done: true }); + serveFapi(passwordVerification); + await renderWithClerk( + , + ); + const { user } = await openVerification(); + + expect(await screen.findByLabelText('Password')).toBeVisible(); + await untilConfirmStepIsGone(); + const stopWatching = watchForConfirmStep(); + await user.click(screen.getAllByRole('button', { name: 'Close' })[0]); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(stopWatching()).toEqual([]); + + expect(screen.getByLabelText('Outcome')).toHaveTextContent('closed'); + }); + } + + for (const settleMs of [undefined, 400]) { + it(`does not go back to the confirm step after a successful verification (host settles after ${settleMs ?? 0}ms)`, async () => { + const action = vi.fn().mockResolvedValueOnce(reverificationError()).mockResolvedValue({ done: true }); + serveFapi(passwordVerification); + await renderWithClerk( + , + ); + const { user } = await openVerification(); + + const field = await screen.findByLabelText('Password'); + await untilConfirmStepIsGone(); + const stopWatching = watchForConfirmStep(); + await user.type(field, 'hunter2{Enter}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull(), { timeout: 3000 }); + expect(stopWatching()).toEqual([]); + + expect(screen.getByLabelText('Outcome')).toHaveTextContent('closed'); + }); + } +}); diff --git a/packages/mosaic/src/blocks/destructive/destructive.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.test.tsx index dcf2d17d95d..04688252a8a 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.test.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.test.tsx @@ -36,6 +36,8 @@ const confirmButton = () => screen.getByRole('button', { name: 'Delete account' const startingReverification = { status: 'loading' as const, onCancel: vi.fn(), + visible: false, + reset: vi.fn(), }; function readyReverification(status: 'ready' | 'retrying'): ReverificationController { @@ -54,7 +56,9 @@ function readyReverification(status: 'ready' | 'retrying'): ReverificationContro methods: [], onSelectMethod: () => {}, }; - return status === 'retrying' ? { status, ...view } : { status, ...view }; + return status === 'retrying' + ? { status, visible: true, reset: () => {}, ...view } + : { status, visible: true, reset: () => {}, ...view }; } describe('Destructive', () => { @@ -221,7 +225,7 @@ describe('Destructive', () => { {...props} errorMessage='Delete failed.' step='confirm' - reverification={{ status: 'idle' }} + reverification={{ status: 'idle', visible: false, reset: () => {} }} />
, ); diff --git a/packages/mosaic/src/blocks/destructive/destructive.tsx b/packages/mosaic/src/blocks/destructive/destructive.tsx index cec3702b8ea..4fd87db9da4 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.tsx @@ -42,7 +42,9 @@ export interface DestructiveControlledProps { step?: 'confirm' | 'verify'; } -type DestructiveCardProps = Omit; +type DestructiveCardProps = Omit & { + onClose?: () => void; +}; function DestructiveCard({ open, @@ -58,6 +60,7 @@ function DestructiveCard({ errorMessage, reverification, step = 'confirm', + onClose, }: DestructiveCardProps) { const formId = useId(); const [typedValue, setTypedValue] = useState(''); @@ -145,7 +148,16 @@ function DestructiveCard({ {() => ( <> {confirmation} - {reverification ? : null} + + {reverification ? ( + + ) : null} + )} @@ -177,6 +189,7 @@ function ControlledDestructive({ open, onOpenChange, trigger, ...props }: Destru {trigger ? : null} onOpenChange(false, { trigger: null, triggerId: null, event: undefined })} {...props} /> diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index dc967f2d75b..bb0f200a164 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -9,7 +9,7 @@ import type { import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; @@ -111,6 +111,55 @@ function errorCode(error: unknown) { function Host({ action }: { action: () => Promise }) { const [run, reverification] = useReverificationFlow(action); const [outcome, setOutcome] = useState(''); + const [running, setRunning] = useState(false); + const inFlight = useRef(false); + + return ( + <> + + {outcome} + {running ? ( + + + {reverification.onCancel ? ( + + ) : null} + + ) : null} + + ); +} + +function HoldingHost({ action }: { action: () => Promise }) { + const [run, reverification] = useReverificationFlow(action); + const [outcome, setOutcome] = useState(''); return ( <> @@ -125,7 +174,14 @@ function Host({ action }: { action: () => Promise }) { > Run action + {outcome} + {String(reverification.visible)} {reverification.onCancel ? ( @@ -1025,6 +1081,73 @@ describe('Reverification', () => { }); }); + describe('after the action has ended', () => { + const renderHolding = (action: () => Promise) => { + serveFapi(verificationSeed({ firstFactors: [passwordFactor] })); + return renderWithClerk(); + }; + + it('keeps the last card on screen, inert, after a dismissal until it is reset', async () => { + await renderHolding(guardedAction()); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), 'half-typed'); + + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + + expect(screen.getByText(STEP.password)).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull(); + expect(passwordField()).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Reset' })); + expect(screen.queryByText(STEP.password)).toBeNull(); + }); + + it('keeps the last card on screen after the action completes until the next run', async () => { + await renderHolding(guardedAction()); + const user = await startVerification(); + await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`); + await untilResolved(); + + expect(screen.getByText(STEP.password)).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Run action' })); + await waitFor(() => expect(screen.queryByText(STEP.password)).toBeNull()); + }); + + it('is visible once there is a card of its own to show, until it is reset', async () => { + await renderHolding(guardedAction()); + const start = holdRequests('post', VERIFY); + const visible = () => screen.getByLabelText('Visible').textContent; + expect(visible()).toBe('false'); + + const user = await startVerification(); + await waitFor(() => expect(start.requests).toHaveLength(1)); + expect(screen.getByText(TITLE)).toBeVisible(); + expect(visible()).toBe('false'); + + start.release(); + expect(await screen.findByText(STEP.password)).toBeVisible(); + expect(visible()).toBe('true'); + + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + expect(visible()).toBe('true'); + + await user.click(screen.getByRole('button', { name: 'Reset' })); + expect(visible()).toBe('false'); + }); + + it('does not keep anything on screen when no reverification was needed', async () => { + await renderHolding(vi.fn().mockResolvedValue({ done: true })); + await startVerification(); + + await untilResolved(); + expect(screen.queryByText(STEP.password)).toBeNull(); + }); + }); + describe('dismissing', () => { it('dismisses the challenge and starts fresh the next time', async () => { const action = vi diff --git a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx index 1a714e11ddc..82aea7c7b46 100644 --- a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -90,7 +90,7 @@ describe('useReverificationWithState', () => { expect(result.current[1]).toEqual({ phase: 'inactive' }); }); - it('moves inactive → active → retrying → inactive when the retry succeeds', async () => { + it('moves inactive → active → retrying → settled when the retry succeeds', async () => { const retry = deferred<{ ok: true }>(); const fetcher = vi .fn() @@ -118,11 +118,11 @@ describe('useReverificationWithState', () => { await act(async () => { await expect(pending).resolves.toEqual({ ok: true }); }); - await waitFor(() => expect(result.current[1]).toEqual({ phase: 'inactive' })); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'settled' })); expect(challengeCancel).not.toHaveBeenCalled(); }); - it('returns to inactive when the delayed retry fails', async () => { + it('settles when the delayed retry fails', async () => { const retry = deferred(); const fetcher = vi .fn() @@ -146,11 +146,11 @@ describe('useReverificationWithState', () => { await act(async () => { await expect(pending).rejects.toMatchObject({ message: 'Mock delete failed.' }); }); - await waitFor(() => expect(result.current[1]).toEqual({ phase: 'inactive' })); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'settled' })); expect(challengeCancel).not.toHaveBeenCalled(); }); - it('returns directly to inactive when the active challenge is cancelled', async () => { + it('settles immediately when the active challenge is cancelled', async () => { const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: undefined } satisfies Hint); const { result } = renderHook(() => useReverificationWithState(fetcher)); @@ -164,16 +164,78 @@ describe('useReverificationWithState', () => { assertActive(result.current[1]); result.current[1].cancel(); }); + expect(result.current[1]).toEqual({ phase: 'settled' }); await act(async () => { await expect(pending).rejects.toMatchObject({ code: 'reverification_cancelled' }); }); expect(isReverificationCancelledError(await pending.catch(error => error))).toBe(true); - expect(result.current[1]).toEqual({ phase: 'inactive' }); + expect(result.current[1]).toEqual({ phase: 'settled' }); expect(fetcher).toHaveBeenCalledOnce(); expect(challengeCancel).toHaveBeenCalledOnce(); }); + it('stays settled until the handler is called again', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockResolvedValue('ok'); + const { result } = renderHook(() => useReverificationWithState(fetcher)); + + let first!: Promise; + act(() => { + first = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].cancel(); + }); + await act(async () => { + await first.catch(() => undefined); + }); + expect(result.current[1]).toEqual({ phase: 'settled' }); + + let second!: Promise; + act(() => { + second = result.current[0](); + }); + expect(result.current[1]).toEqual({ phase: 'inactive' }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].cancel(); + }); + await act(async () => { + await second.catch(() => undefined); + }); + }); + + it('releases a settled run with reset', async () => { + const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); + const { result } = renderHook(() => useReverificationWithState(fetcher)); + + let first!: Promise; + act(() => { + first = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].cancel(); + }); + + act(() => result.current[2]()); + expect(result.current[1]).toEqual({ phase: 'settled' }); + + await act(async () => { + await first.catch(() => undefined); + }); + act(() => result.current[2]()); + expect(result.current[1]).toEqual({ phase: 'inactive' }); + }); + it('rejects a second call during the initial request without calling the fetcher again', async () => { const gate = deferred(); const fetcher = vi.fn(() => gate.promise); @@ -234,7 +296,7 @@ describe('useReverificationWithState', () => { await act(async () => { await expect(first).resolves.toBe('ok'); }); - await waitFor(() => expect(result.current[1]).toEqual({ phase: 'inactive' })); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'settled' })); }); it('starts another challenge after the first invocation settles', async () => { @@ -261,7 +323,7 @@ describe('useReverificationWithState', () => { await act(async () => { await expect(first).resolves.toBe('ok'); }); - await waitFor(() => expect(result.current[1]).toEqual({ phase: 'inactive' })); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'settled' })); act(() => { first = result.current[0](); @@ -291,7 +353,7 @@ describe('useReverificationWithState', () => { session = { id: 'sess_2' }; rerender(); await settled; - expect(result.current[1]).toEqual({ phase: 'inactive' }); + expect(result.current[1]).toEqual({ phase: 'settled' }); expect(challengeCancel).toHaveBeenCalledOnce(); }); @@ -336,7 +398,7 @@ describe('useReverificationWithState', () => { session = null; rerender(); await settled; - expect(result.current[1]).toEqual({ phase: 'inactive' }); + expect(result.current[1]).toEqual({ phase: 'settled' }); }); it('does not cancel a retry when the session changes', async () => { diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index b304063bf5c..030e7388561 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -15,13 +15,23 @@ import type { } from './reverification.types'; import { errorDetail, needsPrepare, otpChannelFor } from './reverification.utils'; -export type ReverificationController = +type ReverificationControllerState = | { status: 'idle'; onCancel?: undefined } | { status: 'loading'; onCancel?: () => void } | { status: 'error'; reason: ReverificationErrorReason; onCancel?: () => void } | ({ status: 'ready'; onCancel?: () => void } & ReverificationViewProps) | ({ status: 'retrying'; onCancel?: undefined } & ReverificationViewProps); +export type ReverificationController = ReverificationControllerState & { + /** + * Whether reverification has a card of its own to show: a factor or an error. The pending card + * shown before a factor exists is not counted, so a host can keep its own pending state on + * screen until this turns true. Stays true after the run ends until the next run or `reset`. + */ + visible: boolean; + reset: () => void; +}; + type OverlayFrom = 'factor' | 'method-picker'; export type ReverificationDeps = Pick; @@ -41,11 +51,13 @@ interface ReverificationContext { frozenActiveMethodId: string | undefined; overlayFrom: OverlayFrom; supportEmail: string; + heldFrom: string; deps: ReverificationDeps; } type ReverificationEvent = | { type: 'START' } + | { type: 'SETTLE' } | { type: 'RESET' } | { type: 'TYPE'; value: string } | { type: 'SUBMIT' } @@ -78,6 +90,13 @@ function unlockResend(): Pick { return { resendAvailableAt: undefined }; } +function settleFrom(state: string) { + return { + target: 'settled' as const, + actions: assign(() => ({ heldFrom: state })), + }; +} + function factorError(error: unknown): FactorError { return { detail: errorDetail(error) }; } @@ -176,6 +195,7 @@ export const reverificationMachine = createMachine({ frozenActiveMethodId: undefined, overlayFrom: 'factor', supportEmail: '', + heldFrom: '', deps: unseatedDeps, }, states: { @@ -190,7 +210,7 @@ export const reverificationMachine = createMachine({ }, starting: { - on: { RESET: 'inactive' }, + on: { RESET: 'inactive', SETTLE: settleFrom('starting') }, invoke: fromPromise(ctx => ctx.deps.start(), { onDone: afterResult, onError: 'failed', @@ -206,6 +226,7 @@ export const reverificationMachine = createMachine({ preparing: { on: { ...factorEvents, + SETTLE: settleFrom('preparing'), SUBMIT: { actions: assign(() => ({ submitRequested: true })), }, @@ -238,7 +259,7 @@ export const reverificationMachine = createMachine({ */ methodPickerPreparing: { entry: assign(() => lockResend()), - on: { RESET: 'inactive' }, + on: { RESET: 'inactive', SETTLE: settleFrom('methodPickerPreparing') }, invoke: fromPromise(prepareActive, { onDone: 'verifying', onError: { @@ -252,6 +273,7 @@ export const reverificationMachine = createMachine({ always: [{ guard: ctx => ctx.activeMethod === null, target: 'unavailable' }], on: { ...factorEvents, + SETTLE: settleFrom('verifying'), SUBMIT: 'submitting', RESEND: { target: 'preparing', @@ -269,7 +291,7 @@ export const reverificationMachine = createMachine({ }, submitting: { - on: { RESET: 'inactive' }, + on: { RESET: 'inactive', SETTLE: settleFrom('submitting') }, invoke: fromPromise(submit, { onDone: afterResult, onError: { @@ -281,6 +303,7 @@ export const reverificationMachine = createMachine({ methodPicker: { on: { + SETTLE: settleFrom('methodPicker'), SELECT_METHOD: [ { target: 'methodPickerPreparing', @@ -303,6 +326,7 @@ export const reverificationMachine = createMachine({ help: { on: { + SETTLE: settleFrom('help'), BACK: [ { target: 'methodPicker', @@ -316,15 +340,15 @@ export const reverificationMachine = createMachine({ }, unavailable: { - on: { RESET: 'inactive' }, + on: { RESET: 'inactive', SETTLE: settleFrom('unavailable') }, }, failed: { - on: { RESET: 'inactive' }, + on: { RESET: 'inactive', SETTLE: settleFrom('failed') }, }, finishing: { - on: { RESET: 'inactive' }, + on: { RESET: 'inactive', SETTLE: settleFrom('finishing') }, invoke: fromPromise(ctx => ctx.deps.finish(), { onDone: 'retrying', onError: 'failed', @@ -332,9 +356,17 @@ export const reverificationMachine = createMachine({ }, // This means we are retrying the action after successful reverification - // This state is usually left by the model state going to 'inactive', - // which it does when the retry has completed + // This state is usually left by the model state going to 'settled' once the retry has completed retrying: { + on: { RESET: 'inactive', SETTLE: settleFrom('retrying') }, + }, + + /* + The action has ended and the caller has not moved on yet. The machine keeps the last + frame (heldFrom) so the controller can keep rendering it, inert, until the model + leaves 'settled' and the machine is reset. + */ + settled: { on: { RESET: 'inactive' }, }, }, @@ -391,8 +423,18 @@ function viewStep(value: string, method: ReverificationMethod | null): Reverific * 'retrying' is the point of no return: verification succeeded and the original action is being * retried, so it keeps the last factor pending and has no `onCancel`. The factor machine runs * while the model is active and resets when the model returns to inactive. + * + * When the run ends after reverification was shown (completed, failed or cancelled), the model is + * 'settled' and the controller keeps returning the last frame, pending and without `onCancel`, + * until the next run starts or `reset` is called. Hosts decide when to stop rendering it. */ -export function useReverificationController(model: ReverificationModel): ReverificationController { +export function useReverificationController(model: ReverificationModel, reset: () => void): ReverificationController { + const state = useReverificationState(model); + const visible = state.status === 'ready' || state.status === 'retrying' || state.status === 'error'; + return { ...state, visible, reset }; +} + +function useReverificationState(model: ReverificationModel): ReverificationControllerState { const m = useMessages('reverification'); const active = model.status === 'active' ? model : null; @@ -427,19 +469,24 @@ export function useReverificationController(model: ReverificationModel): Reverif : undefined; const onCancel = cancelModel ? () => { - send({ type: 'RESET' }); + send({ type: 'SETTLE' }); cancelModel(); } : undefined; + const settled = model.status === 'settled'; const needsStart = active !== null && snapshot.value === 'inactive'; - const needsReset = model.status === 'inactive' && snapshot.value !== 'inactive'; + const needsSettle = settled && snapshot.value !== 'inactive' && snapshot.value !== 'settled'; + const needsReset = + (model.status === 'inactive' && snapshot.value !== 'inactive') || (!settled && snapshot.value === 'settled'); useEffect(() => { if (needsStart) { send({ type: 'START' }); + } else if (needsSettle) { + send({ type: 'SETTLE' }); } else if (needsReset) { send({ type: 'RESET' }); } - }, [needsStart, needsReset, send]); + }, [needsStart, needsSettle, needsReset, send]); if (model.status === 'inactive') { return { status: 'idle' }; @@ -447,16 +494,17 @@ export function useReverificationController(model: ReverificationModel): Reverif const { context } = snapshot; const activeMethod = context.activeMethod; + const state = settled && snapshot.value === 'settled' ? context.heldFrom : snapshot.value; - if (snapshot.value === 'unavailable') { + if (state === 'unavailable') { return { status: 'error', reason: 'noFactors', onCancel }; } - if (snapshot.value === 'failed') { + if (state === 'failed') { return { status: 'error', reason: 'generic', onCancel }; } - const step = viewStep(snapshot.value, activeMethod); + const step = viewStep(state, activeMethod); if (!step) { return { status: 'loading', onCancel }; @@ -464,9 +512,9 @@ export function useReverificationController(model: ReverificationModel): Reverif // 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 excludeId = state === 'methodPickerPreparing' ? context.frozenActiveMethodId : activeMethod?.id; const methods = context.methods.filter(method => method.id !== excludeId); - const pendingMethodId = snapshot.value === 'methodPickerPreparing' ? activeMethod?.id : undefined; + const pendingMethodId = state === 'methodPickerPreparing' ? activeMethod?.id : undefined; const view: ReverificationViewProps = { step, @@ -474,7 +522,7 @@ export function useReverificationController(model: ReverificationModel): Reverif value: context.inputValue, onValueChange: (value: string) => send({ type: 'TYPE', value }), errorMessage: context.error ? (context.error.detail ?? m.unstable__errors__generic) : undefined, - isPending: pendingStates.has(snapshot.value), + isPending: settled || pendingStates.has(state), onSubmit: () => send({ type: 'SUBMIT' }), onShowMethods: () => send({ type: 'SHOW_METHODS' }), onShowHelp: () => send({ type: 'SHOW_HELP' }), @@ -502,5 +550,5 @@ export function useReverificationController(model: ReverificationModel): Reverif : undefined, }; - return snapshot.value === 'retrying' ? { status: 'retrying', ...view } : { status: 'ready', onCancel, ...view }; + return state === 'retrying' ? { status: 'retrying', ...view } : { status: 'ready', onCancel, ...view }; } diff --git a/packages/mosaic/src/features/reverification/reverification.model.tsx b/packages/mosaic/src/features/reverification/reverification.model.tsx index 87462cc456a..30d929aa297 100644 --- a/packages/mosaic/src/features/reverification/reverification.model.tsx +++ b/packages/mosaic/src/features/reverification/reverification.model.tsx @@ -32,7 +32,8 @@ export type ReverificationModel = | { status: 'inactive' } | { status: 'loading'; cancel: () => void } | ReverificationActiveModel - | { status: 'retrying' }; + | { status: 'retrying' } + | { status: 'settled' }; function toMethod( factor: SessionVerificationFirstFactor | SessionVerificationSecondFactor, @@ -122,6 +123,10 @@ export function useReverificationModel(reverificationState: ReverificationState) return { status: 'retrying' }; } + if (reverificationState.phase === 'settled') { + return { status: 'settled' }; + } + const { level, cancel, complete } = reverificationState; if (!session || !environment || supportEmail === undefined) { diff --git a/packages/mosaic/src/features/reverification/reverification.tsx b/packages/mosaic/src/features/reverification/reverification.tsx index aa1474ed5dd..5ca3edbee7f 100644 --- a/packages/mosaic/src/features/reverification/reverification.tsx +++ b/packages/mosaic/src/features/reverification/reverification.tsx @@ -15,14 +15,18 @@ export type UseReverificationFlowResult( fetcher: F, ): UseReverificationFlowResult { - const [wrappedFetcher, reverificationState] = useReverificationWithState(fetcher); + const [wrappedFetcher, reverificationState, reset] = useReverificationWithState(fetcher); const model = useReverificationModel(reverificationState); - const controller = useReverificationController(model); + const controller = useReverificationController(model, reset); return [wrappedFetcher, controller]; } -export function Reverification(controller: ReverificationController) { +export type ReverificationProps = ReverificationController & { + onClose?: () => void; +}; + +export function Reverification({ onClose, ...controller }: ReverificationProps) { if (controller.status === 'idle') { return null; } @@ -33,7 +37,7 @@ export function Reverification(controller: ReverificationController) { return ( ); } diff --git a/packages/mosaic/src/features/reverification/reverification.types.ts b/packages/mosaic/src/features/reverification/reverification.types.ts index ada8be5fc0d..e55cda4b1af 100644 --- a/packages/mosaic/src/features/reverification/reverification.types.ts +++ b/packages/mosaic/src/features/reverification/reverification.types.ts @@ -47,7 +47,8 @@ export type ReverificationViewProps = ReverificationViewFields & { export type ReverificationState = | { phase: 'inactive' } | { phase: 'active'; complete: () => void; cancel: () => void; level: SessionVerificationLevel | undefined } - | { phase: 'retrying' }; + | { phase: 'retrying' } + | { phase: 'settled' }; export type ReverificationResult = { status: 'needs_first_factor' | 'needs_second_factor' | 'complete'; diff --git a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts index 41daad8e368..ced1f18e220 100644 --- a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts +++ b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts @@ -9,6 +9,7 @@ export type ReverificationFetcher = (...args: any[]) => Promise | undefined export type UseReverificationWithStateResult = readonly [ ReturnType>, ReverificationState, + () => void, ]; type RuntimeOperation = @@ -29,6 +30,10 @@ type Runtime = { sessionId: string | null; }; +function releaseSettled(state: ReverificationState): ReverificationState { + return state.phase === 'settled' ? { phase: 'inactive' } : state; +} + const REQUEST_ALREADY_IN_PROGRESS_CODE = 'request_already_in_progress'; function requestAlreadyInProgressError(): ClerkRuntimeError { @@ -40,7 +45,12 @@ function requestAlreadyInProgressError(): ClerkRuntimeError { /** * Wraps useReverification without the default UI. Returns [handler, state]: call handler * to run the action, and render your own UI from state.phase ('inactive' | 'active' | - * 'retrying'). While 'active', state.complete and state.cancel end the challenge. + * 'retrying' | 'settled'). While 'active', state.complete and state.cancel end the challenge. + * + * A run that was interrupted by a reverification ends in 'settled', not 'inactive', so the + * UI that was showing can be held until the caller moves on. 'settled' ends when the handler + * is called again, or when the third return value, reset, is called. A run that was never + * interrupted stays 'inactive' throughout. * * The handler runs one call at a time. A call made while another is pending rejects with a * ClerkRuntimeError with code 'request_already_in_progress'. @@ -83,7 +93,7 @@ export function useReverificationWithState( return; } runtimeRef.current.operation = { status: 'cancelling', promise: operation.promise }; - setReverificationState({ phase: 'inactive' }); + setReverificationState({ phase: 'settled' }); operation.cancel(); }, []); @@ -120,6 +130,8 @@ export function useReverificationWithState( return Promise.reject(requestAlreadyInProgressError()); } + setReverificationState(releaseSettled); + const invocation = Promise.resolve().then(() => wrapped(...args)); runtimeRef.current.operation = { status: 'requesting', promise: invocation }; void invocation @@ -129,7 +141,7 @@ export function useReverificationWithState( return; } runtimeRef.current.operation = { status: 'idle' }; - setReverificationState({ phase: 'inactive' }); + setReverificationState({ phase: operation.status === 'requesting' ? 'inactive' : 'settled' }); }) // The original error is meant to be handled outside, but .finally() creates // a new promise that errors the same way, so we swallow that duplicate error silently @@ -140,6 +152,13 @@ export function useReverificationWithState( [wrapped], ) as ReturnType>; + const reset = useCallback(() => { + if (runtimeRef.current.operation.status !== 'idle') { + return; + } + setReverificationState(releaseSettled); + }, []); + const phase = reverificationState.phase; useEffect(() => { if (phase !== 'active') { @@ -179,5 +198,5 @@ export function useReverificationWithState( }; }, []); - return [singleFlight, reverificationState]; + return [singleFlight, reverificationState, reset]; } diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx index f9c8888bc8c..e2118ff35e6 100644 --- a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -42,7 +42,7 @@ function CardHarness() { const [message, setMessage] = useState(null); const [requestPending, setRequestPending] = useState(false); const [deleteAccount, reverification] = useReverificationFlow(() => mockDelete(false)); - const busy = requestPending || reverification.status !== 'idle'; + const busy = requestPending; return (
@@ -84,9 +84,11 @@ function CardHarness() { Delete account
- - - + {requestPending ? ( + + + + ) : null} {message ?

{message}

: null} ); @@ -106,8 +108,9 @@ function DialogHarness() { const [step, setStep] = useState('confirm'); const runRef = useRef(false); - const nextStep: OuterStep | null = - reverification.status === 'retrying' && presentation === 'replace' + const nextStep: OuterStep | null = !requestPending + ? null + : reverification.status === 'retrying' && presentation === 'replace' ? 'finalizing' : reverification.status === 'ready' || reverification.status === 'error' ? 'verify' @@ -119,7 +122,7 @@ function DialogHarness() { } const continueDelete = () => { - if (runRef.current || reverification.status !== 'idle') { + if (runRef.current) { return; } runRef.current = true; @@ -177,12 +180,10 @@ function DialogHarness() { if (next) { return; } - if (requestPending && reverification.status === 'idle') { + if (requestPending && step === 'confirm') { return; } - if (reverification.status !== 'idle') { - reverification.onCancel?.(); - } + reverification.onCancel?.(); setOpen(false); if (reverification.status !== 'retrying') { setErrorMessage(null); diff --git a/packages/swingset/src/stories/reverification.mdx b/packages/swingset/src/stories/reverification.mdx index 7a4ee21a61d..b1a9c4bbac1 100644 --- a/packages/swingset/src/stories/reverification.mdx +++ b/packages/swingset/src/stories/reverification.mdx @@ -30,13 +30,59 @@ const [runAction, reverification] = useReverificationFlow(action);
; ``` +## Composing with a parent flow + +Reverification is usually one step inside a larger flow, like a confirmation dialog that deletes something. The parent owns the action and decides what is on screen. Reverification only reports whether it has something to show. + +### Reading `visible` + +`reverification.visible` is true when reverification has a card of its own: a factor, a pending retry, or an error. It is false when idle and while the pending card is showing before a factor is known. A parent that wants to handle pending itself, for example by keeping its own spinner up, can switch to the verification step on `visible` and never render the pending card. + +A parent should decide its layout from its own state and `visible`, not from `status`. `status` describes the card, and it changes while the action is still running. + +```tsx +const step = isRunning && reverification.visible ? 'verify' : 'confirm'; +``` + +### When the run ends + +The reverification card does not disappear when the action settles. If the user was shown a card during the run, the run ends in a settled state, whether it succeeded, failed, or was cancelled. The controller keeps returning the last card, inert: it is pending and has no `onCancel`. `visible` stays true unless the last thing shown was the pending card. + +This lets the parent finish its own work after the action returns, such as refetching or closing a dialog, without the screen falling back to an earlier step in between. The parent leaves the card by unmounting `` or changing its own step when it is done. + +A run that never asked for reverification never settles in this way. `status` stays `idle`, and `visible` stays false for the whole run. + +The settled card is released by whichever happens first. + +- The action is run again. +- `reverification.reset()` is called. + +Call `reset()` when a parent leaves the flow without running the action again and wants a clean state, so the next render does not show a stale card. + +### Cancelling + +`reverification.onCancel` is a request. It is undefined when the user cannot cancel, which is the case while the action is being retried and once the run has settled. Calling it rejects the action with a `reverification_cancelled` error and settles the card. + +The parent decides what cancelling means for its own flow. Pass `onClose` to `` to run that instead of `onCancel` when the user presses the Close button on the error card. The parent is then responsible for calling `onCancel` itself, as `Destructive` does when its dialog closes. This lets the parent close or leave the step at the moment of the click, instead of waiting for its own action to unwind. + +```tsx + closeDialog()} +/> +``` + +### Outcomes + +The promise returned by the wrapped action is the outcome. It resolves when the action succeeds, rejects with `reverification_cancelled` when the user cancels, and rejects with the action's own error otherwise. Calling the action while another call is pending rejects with `request_already_in_progress`. + ## Panels These examples isolate the presentational views and their visual states. Product code should normally render the `Reverification` block shown above. ### Pending -This state is used while reverification is still loading/starting and we don't know the factor yet, it can be avoided entirely by not rendering `` while `reverification.status === 'loading'`. +This state is used while reverification is still loading/starting and we don't know the factor yet, it can be avoided entirely by not rendering `` until `reverification.visible` is true. From d63fe38e700e1dde61724d09e11816044159a5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 1 Oct 2026 16:27:44 +0200 Subject: [PATCH 40/40] Reset reverification before new delete --- .../destructive.controller.test.ts | 40 ++++++++++++++++--- .../destructive/destructive.controller.ts | 1 + .../destructive/destructive.feature.test.tsx | 33 +++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts index d8494c98b58..ab512135048 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts @@ -6,7 +6,7 @@ import type { ReverificationController } from '../../features/reverification'; import { deferred } from '../../machines/__tests__/test-utils'; import { useDestructiveController } from './destructive.controller'; -const idleReverification = { status: 'idle', visible: false } as ReverificationController; +const idleReverification = { status: 'idle', visible: false, reset: vi.fn() } as ReverificationController; describe('useDestructiveController', () => { it('starts closed and opens from the opener or from onOpenChange', () => { @@ -119,7 +119,12 @@ describe('useDestructiveController', () => { const { result } = renderHook(() => useDestructiveController({ onDelete: () => new Promise(() => {}), - reverification: { status: 'ready', visible: true, onCancel: vi.fn() } as ReverificationController, + reverification: { + status: 'ready', + visible: true, + reset: vi.fn(), + onCancel: vi.fn(), + } as ReverificationController, }), ); act(() => result.current.onOpenChange(true)); @@ -136,7 +141,7 @@ describe('useDestructiveController', () => { const { result } = renderHook(() => useDestructiveController({ onDelete: () => new Promise(() => {}), - reverification: { status: 'retrying', visible: true } as ReverificationController, + reverification: { status: 'retrying', visible: true, reset: vi.fn() } as ReverificationController, }), ); act(() => result.current.onOpenChange(true)); @@ -171,7 +176,7 @@ describe('useDestructiveController', () => { const { result } = renderHook(() => useDestructiveController({ onDelete: () => new Promise(() => {}), - reverification: { status: 'ready', visible } as ReverificationController, + reverification: { status: 'ready', visible, reset: vi.fn() } as ReverificationController, }), ); act(() => result.current.onOpenChange(true)); @@ -186,7 +191,12 @@ describe('useDestructiveController', () => { const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve(), - reverification: { status: 'ready', visible: true, onCancel: vi.fn() } as ReverificationController, + reverification: { + status: 'ready', + visible: true, + reset: vi.fn(), + onCancel: vi.fn(), + } as ReverificationController, }), ); act(() => result.current.onOpenChange(true)); @@ -194,12 +204,30 @@ describe('useDestructiveController', () => { expect(result.current.step).toBe('confirm'); }); + it('releases the previous reverification before a new delete starts', () => { + const reset = vi.fn(); + const { result } = renderHook(() => + useDestructiveController({ + onDelete: () => new Promise(() => {}), + reverification: { status: 'idle', visible: false, reset } as ReverificationController, + }), + ); + act(() => result.current.onOpenChange(true)); + expect(reset).not.toHaveBeenCalled(); + + act(() => { + void result.current.onDelete(); + }); + + expect(reset).toHaveBeenCalledOnce(); + }); + it('cancels reverification when the dialog closes', () => { const onCancel = vi.fn(); const { result } = renderHook(() => useDestructiveController({ onDelete: () => new Promise(() => {}), - reverification: { status: 'ready', visible: true, onCancel } as ReverificationController, + reverification: { status: 'ready', visible: true, reset: vi.fn(), onCancel } as ReverificationController, }), ); act(() => result.current.onOpenChange(true)); diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.ts index 1cfaa4981a6..53501e69bd8 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.ts @@ -47,6 +47,7 @@ export function useDestructiveController({ openDestructiveDialog, onDelete: async () => { if (status === 'open-needs-confirmation' || status === 'open-error') { + reverification?.reset(); setDestructiveState({ status: 'open-pending' }); try { await onDelete(); diff --git a/packages/mosaic/src/blocks/destructive/destructive.feature.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.feature.test.tsx index f11ac9a836e..8e29986f811 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.feature.test.tsx +++ b/packages/mosaic/src/blocks/destructive/destructive.feature.test.tsx @@ -20,14 +20,19 @@ function Host({ action, cleanupMs, settleMs, + beforeRunMs, }: { action: () => Promise; cleanupMs?: number; settleMs?: number; + beforeRunMs?: number; }) { const [run, reverification] = useReverificationFlow(action); const controller = useDestructiveController({ onDelete: async () => { + if (beforeRunMs !== undefined) { + await new Promise(resolve => setTimeout(resolve, beforeRunMs)); + } try { const result = await run(); if (settleMs !== undefined) { @@ -176,4 +181,32 @@ describe('Destructive with reverification', () => { expect(screen.getByLabelText('Outcome')).toHaveTextContent('closed'); }); } + + it('does not show the previous attempt again when the host starts the next run after a delay', async () => { + const action = vi + .fn() + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValue({ done: true }); + serveFapi(passwordVerification); + await renderWithClerk( + , + ); + const { user } = await openVerification(); + expect(await screen.findByLabelText('Password')).toBeVisible(); + await user.click(screen.getAllByRole('button', { name: 'Close' })[0]); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.type(await screen.findByRole('textbox'), 'Delete'); + const stopWatching = watchForConfirmStep(); + await user.click(screen.getByRole('button', { name: 'Delete account' })); + await new Promise(resolve => setTimeout(resolve, 150)); + expect(screen.queryByLabelText('Password')).toBeNull(); + expect(await screen.findByLabelText('Password', undefined, { timeout: 3000 })).toBeVisible(); + expect(stopWatching()).toEqual([]); + }); });