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/.claude/skills/mosaic/references/views.md b/.claude/skills/mosaic/references/views.md index 3a6a9cca927..3e4b26263ed 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/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/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.test.tsx b/packages/mosaic/src/blocks/destructive/destructive.test.tsx index 797ca23ed6e..4d41d45d2ad 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: ({ phase }: { phase: string }) => {phase}, +})); + function renderBlock(overrides: Partial = {}) { return render( @@ -28,6 +33,31 @@ 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', + 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', () => { @@ -143,6 +173,78 @@ describe('Destructive', () => { await user.click(confirmButton()); 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: readyReverification('active') }); + + 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: { ...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', () => { + 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( + + + , + ); + expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); + expect(screen.getByTestId('reverification')).toHaveTextContent('retrying'); + + 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..40a4867e676 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 ReverificationController } 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 */ @@ -31,11 +33,12 @@ 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 */ errorMessage?: string; + reverification?: ReverificationController; } 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,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 @@ -73,10 +79,81 @@ 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(() => { }); } }; + 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 && reverification.status !== 'idle') { + reverification.onCancel?.(); + } + onOpenChange(...args); + }; + return ( {trigger ? : null} 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..9f83caa897f --- /dev/null +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx @@ -0,0 +1,237 @@ +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', phase: 'inactive' }; + +function surface(overrides: Partial = {}): ReverificationController { + return { + status: 'ready', + phase: 'active', + 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', phase: 'inactive' }; + 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', phase: 'active' }; + 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', phase: 'inactive' }; + 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', phase: 'inactive' }; + 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', phase: 'active' }; + 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', phase: 'active' }; + 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', phase: 'active' }; + 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..5f4ef4e3ef9 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,17 +290,24 @@ 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', phase: 'inactive' }); }); it('is loading while the model is still waiting on Clerk', () => { + const cancel = vi.fn(); const loading: ReverificationModel = { status: 'loading', - isActive: true, + 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 () => { @@ -413,7 +420,7 @@ describe('useReverificationController', () => { } }); - rerender({ model: { ...activeModel, isActive: false } }); + rerender({ model: { ...activeModel, phase: 'inactive' } }); expect(result.current.status).toBe('idle'); act(() => { @@ -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', isActive: true } }); + 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', isActive: true } }); + 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'); @@ -554,10 +597,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', phase: 'inactive' }); - 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 +755,61 @@ 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(result.current.onCancel).toBeUndefined(); + } + 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', 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 7403bc26b16..06d2110f9cc 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, @@ -101,27 +101,32 @@ 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.isActive).toBe(true); + expect(result.current.phase).toBe('active'); + 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'); - 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 +304,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..22342fb0356 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx @@ -3,44 +3,20 @@ import { describe, expect, it, vi } from 'vitest'; import { Reverification } from '../reverification'; import type { ReverificationController } from '../reverification.controller'; -import type { ReverificationModel } from '../reverification.model'; import type { ReverificationViewProps } from '../reverification.types'; -const model: ReverificationModel = { - status: 'ready', - isActive: true, - supportEmail: '', - start: vi.fn(), - prepare: vi.fn(), - attempt: vi.fn(), - finish: vi.fn(), - cancel: vi.fn(), -}; - -let controller: ReverificationController = { status: 'idle' }; - -vi.mock('../reverification.model', () => ({ - useReverificationModel: () => model, -})); - -vi.mock('../reverification.controller', () => ({ - useReverificationController: () => controller, -})); +let controller: ReverificationController = { status: 'idle', phase: 'inactive' }; vi.mock('../reverification.view', () => ({ + ReverificationPending: () => , + ReverificationUnavailable: () => , ReverificationView: ({ step }: { step: string }) => {step}, })); -const active = { - isActive: true as const, - complete: vi.fn(), - cancel: vi.fn(), - level: 'first_factor' as const, -}; - function ready(overrides: Partial = {}): ReverificationController { return { status: 'ready', + phase: 'active', step: 'password', value: '', onValueChange: vi.fn(), @@ -59,25 +35,35 @@ function ready(overrides: Partial = {}): Reverification } describe('Reverification', () => { - it('renders nothing when reverification is not active', () => { - controller = { status: 'idle' }; - const { container } = render(); + it('renders nothing while reverification is inactive', () => { + controller = { status: 'idle', phase: 'inactive' }; + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); - it('renders nothing while the controller is loading or unavailable', () => { - controller = { status: 'loading' }; - const loading = render(); - expect(loading.container).toBeEmptyDOMElement(); + it('renders pending, unavailable, or the factor view', () => { + controller = { status: 'loading', phase: 'active' }; + const { rerender } = render(); + expect(screen.getByTestId('pending')).toBeInTheDocument(); + expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - controller = { status: 'unavailable' }; - const unavailable = render(); - expect(unavailable.container).toBeEmptyDOMElement(); - }); + controller = { status: 'unavailable', phase: 'active' }; + rerender(); + expect(screen.getByTestId('unavailable')).toBeInTheDocument(); + expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - it('renders the view once the controller is ready', () => { controller = ready({ step: 'otp', otpChannel: 'email' }); - render(); + 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', phase: 'inactive' }; + 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..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,12 +105,6 @@ describe('ReverificationView', () => { expect(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (23)' })).toBeDisabled(); }); - it('does not render Card branding', () => { - renderView(); - - expect(screen.queryByText('Secured by')).not.toBeInTheDocument(); - }); - it('renders a passkey attempt error in a negative Banner', () => { renderView({ step: 'passkey', 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..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 @@ -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 { ReverificationState } 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: ReverificationState): 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/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 98beda9cf65..f3f6e5b10b1 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -5,14 +5,24 @@ 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, + 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'; @@ -352,7 +362,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 +386,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 +394,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 + * + * 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 * - * Note that there are two loading states. - * - status: 'loading' - Full card spinner - * - status: 'ready' && isPending: true - Current action is pending, inline loading state + * `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; @@ -416,8 +432,16 @@ 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 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(() => { if (needsStart) { send({ type: 'START' }); @@ -426,25 +450,31 @@ export function useReverificationController(model: ReverificationModel): Reverif } }, [needsStart, needsReset, send]); - if (!model.isActive) { - return { status: 'idle' }; + if (reverificationPhase === 'inactive') { + return { status: 'idle', phase: reverificationPhase }; } - 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' }; + return { status: 'unavailable', phase: reverificationPhase, onCancel }; } - 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) { - return { status: 'unavailable' }; + if (snapshot.value === 'inactive' || snapshot.value === 'starting' || snapshot.value === 'done') { + return { status: 'loading', phase: reverificationPhase, onCancel }; + } + return { status: 'unavailable', phase: reverificationPhase, onCancel }; } - 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; @@ -453,12 +483,13 @@ export function useReverificationController(model: ReverificationModel): Reverif return { status: 'ready', + phase: reverificationPhase, 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,9 +501,10 @@ 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' }), + 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.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..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'; - isActive: boolean; + 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'; isActive: boolean } | ReverificationReadyModel; +export type ReverificationModel = + | { status: 'loading'; phase: ReverificationState['phase']; cancel: () => void } + | ReverificationReadyModel; function toError(error: unknown): Error { if (isClerkAPIResponseError(error)) { @@ -114,15 +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 { isActive, cancel, complete, level } = props; + 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', isActive }; + return { status: 'loading', phase, cancel: cancelVerification }; } const webAuthnSupported = isWebAuthnSupported(); @@ -133,7 +141,7 @@ export function useReverificationModel(props: ReverificationProps): Reverificati return { status: 'ready', - isActive, + phase, supportEmail, start: async () => { try { @@ -142,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.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..04cc862bafc 100644 --- a/packages/mosaic/src/features/reverification/reverification.tsx +++ b/packages/mosaic/src/features/reverification/reverification.tsx @@ -1,21 +1,39 @@ -import { useReverificationController } from './reverification.controller'; +import { type ReverificationController, 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'; +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); - if (!props.isActive) { + return [wrappedFetcher, controller]; +} + +export function Reverification(controller: ReverificationController) { + if (controller.status === 'idle') { return null; } - if (controller.status !== 'ready') { - // TODO: Implement unavailable and loading states, could also live in the .view. - return null; + if (controller.status === 'loading') { + return ; + } else if (controller.status === 'unavailable') { + 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 c31890a4fbe..3fcaa6b43f2 100644 --- a/packages/mosaic/src/features/reverification/reverification.types.ts +++ b/packages/mosaic/src/features/reverification/reverification.types.ts @@ -18,8 +18,7 @@ export type ReverificationMethod = export type ReverificationPreparableMethod = Extract; -export type ReverificationViewProps = { - step: ReverificationStep; +type ReverificationViewFields = { direction?: FlowDirection; value: string; onValueChange: (value: string) => void; @@ -39,9 +38,14 @@ export type ReverificationViewProps = { resendRemainingSeconds?: number; }; -export type ReverificationProps = - | { isActive: false; complete?: undefined; cancel?: undefined; level?: undefined } - | { isActive: true; complete: () => void; cancel: () => void; level: SessionVerificationLevel | undefined }; +export type ReverificationViewProps = ReverificationViewFields & { + step: ReverificationStep; +}; + +export type ReverificationState = + | { 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..1e13684542a 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,27 @@ 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, +}: 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 = { @@ -91,121 +133,119 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element : 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} - /> - - - - - - - )} - - + + {() => ( + <> + + + + + + + + + + { + 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} + /> + + + + + + + )} + ); } 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..7a7e8d60501 100644 --- a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts +++ b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts @@ -1,69 +1,159 @@ -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'; +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 = + | { 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. + * + * 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 + * request_already_in_progress. If you always guard against double-invocation, + * you wont see this error. */ -export function useReverificationWithState( +// 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]; } 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.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.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx deleted file mode 100644 index 9f70ce5dea6..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.view.test.tsx +++ /dev/null @@ -1,74 +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 { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view'; - -function renderView(onDelete: () => Promise = vi.fn(() => Promise.resolve())) { - return render( - - - , - ); -} - -const openDialog = async (user: ReturnType) => { - await user.click(screen.getByRole('button', { name: 'Delete account' })); - return screen.getByRole('dialog'); -}; - -describe('UserProfileDeleteSectionView', () => { - it('renders the danger zone with the dialog closed', () => { - 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(); - }); - - it('deletes only after the phrase is typed back, then closes', async () => { - const onDelete = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); - renderView(onDelete); - - const dialog = await openDialog(user); - 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(); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - - 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' })); - - expect(await screen.findByText('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 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()); - await openDialog(user); - 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 9a55f04dae8..c489b79785d 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', @@ -279,16 +301,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: 4, name: 'Connected accounts' })).toBeInTheDocument(); @@ -298,12 +316,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 831c094f29a..6e39326f09b 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: [ @@ -102,7 +124,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({ @@ -111,7 +132,6 @@ describe('UserProfileSecurityPanelView', () => { onRemovePasskey, onSignOutDevice, onSignOutAllOtherDevices, - onDeleteAccount, }); await user.click(screen.getByRole('button', { name: 'Add passkey' })); @@ -137,18 +157,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-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 1b96da8ca90..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 view 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 new file mode 100644 index 00000000000..d481dc74f2e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx @@ -0,0 +1,55 @@ +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 = { + fallback?: React.ReactNode; +} + +export function UserProfileDeleteSection(props: UserProfileDeleteSectionProps) { + // -- 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 ; +} 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 d9c00997b64..4278dc80dad 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,22 +1,11 @@ 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'; -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; -} - -export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) { +export function UserProfileDeleteSectionView(destructiveProps: DestructiveController) { const m = useMessages('userProfileDeleteSection'); - const { isOpen, onOpenChange, onConfirm, isDeleting, errorMessage } = useUserProfileDeleteSectionController({ - onDelete, - }); return ( @@ -30,8 +19,7 @@ export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSect 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 cb33e5ab840..6bcac3a7e0e 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'; @@ -131,7 +131,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 b92721f077a..956f4631b6b 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'; @@ -125,7 +125,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/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index 2e206b399de..1ce15a7dd11 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -18,7 +18,14 @@ 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}/`); +} export function LiveSidebar(props: ComponentProps) { const pathname = usePathname(); @@ -64,7 +71,7 @@ export function LiveSidebar(props: ComponentProps) { } > {flow.title} 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} +
+
+ ); +} diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx index 47f4be03068..4dd6a25508d 100644 --- a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -1,14 +1,20 @@ 'use client'; +import { Destructive } from '@clerk/mosaic/blocks/destructive'; import { Button } from '@clerk/mosaic/components/button'; -import { Reverification, useReverificationWithState } from '@clerk/mosaic/features/reverification'; +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, useReverificationFlow } 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 +24,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 +36,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] = useReverificationFlow(() => 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] = useReverificationFlow(() => 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.status === 'ready' || reverification.status === 'unavailable' + ? '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.status !== 'idle') { + reverification.onCancel?.(); + } + 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. + + + + )} +
+
+
+
+
+ ); +} + +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.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); 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. 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/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/destructive.mdx b/packages/swingset/src/stories/destructive.mdx index e2590a7464b..809af4724b3 100644 --- a/packages/swingset/src/stories/destructive.mdx +++ b/packages/swingset/src/stories/destructive.mdx @@ -19,45 +19,48 @@ 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. +## Reverification + +Wrap the action with `useReverificationFlow` and pass the result to the controller. Confirmation and verification stay in the same dialog and card. + +```tsx +import { useReverificationFlow } from '@clerk/mosaic/features/reverification'; + +const [runDelete, reverification] = useReverificationFlow(deleteAccount); +const destructive = useDestructiveController({ onDelete: runDelete, 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. @@ -89,25 +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. | +| 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: @@ -122,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} -/> -``` 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/reverification.mdx b/packages/swingset/src/stories/reverification.mdx index 0fa8f8eec3c..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 `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 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} ); 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..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,3 +1,4 @@ +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'; @@ -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 = useDestructiveController({ 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 f399ea47e80..68c513d492a 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… } ```