diff --git a/.changeset/mosaic-destructive-reverification-statecharts.md b/.changeset/mosaic-destructive-reverification-statecharts.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-destructive-reverification-statecharts.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts index 04dac160383..4da65ccec8a 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.test.ts @@ -1,12 +1,26 @@ -import { ClerkRuntimeError } from '@clerk/shared/error'; +import { ClerkAPIResponseError } 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 { reverificationMachine } from '../../features/reverification/reverification.machine'; import { deferred } from '../../machines/__tests__/test-utils'; -import { useDestructiveController } from './destructive.controller'; +import { type DestructiveReverification, useDestructiveController } from './destructive.controller'; -const idleReverification = { status: 'idle' } as ReverificationController; +const needsReverification = () => + new ClerkAPIResponseError('reverify', { + data: [{ code: 'session_reverification_required', message: 'Reverify', long_message: 'Reverify' }], + status: 403, + }); + +function reverification(sessionId: string | null | undefined = 'sess_1'): DestructiveReverification { + return { + sessionId, + actors: { + reverification: reverificationMachine, + startVerification: () => new Promise(() => {}), + }, + }; +} describe('useDestructiveController', () => { it('starts closed and opens from the opener or from onOpenChange', () => { @@ -28,9 +42,7 @@ describe('useDestructiveController', () => { 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.onDelete()); act(() => result.current.openDestructiveDialog()); @@ -43,9 +55,7 @@ describe('useDestructiveController', () => { const { result } = renderHook(() => useDestructiveController({ onDelete })); act(() => result.current.onOpenChange(true)); - act(() => { - void result.current.onDelete(); - }); + act(() => result.current.onDelete()); expect(onDelete).toHaveBeenCalledOnce(); expect(result.current.open).toBe(true); expect(result.current.isDeleting).toBe(true); @@ -66,146 +76,111 @@ describe('useDestructiveController', () => { const { result } = renderHook(() => useDestructiveController({ onDelete })); act(() => result.current.onOpenChange(true)); - await act(async () => { - await result.current.onDelete(); - }); + act(() => result.current.onDelete()); + await waitFor(() => expect(result.current.errorMessage).toBe('Something went wrong')); 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(); - }); + act(() => 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 })); + it('ignores a close while the action is in flight', () => { + const { result } = renderHook(() => useDestructiveController({ onDelete: () => new Promise(() => {}) })); 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.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', () => { + it('moves to the verify step and exposes the reverification actor when reverification is required', async () => { const { result } = renderHook(() => useDestructiveController({ - onDelete: () => new Promise(() => {}), - reverification: { status: 'ready', onCancel: vi.fn() } as ReverificationController, + onDelete: () => Promise.reject(needsReverification()), + reverification: reverification(), }), ); act(() => result.current.onOpenChange(true)); - act(() => { - void result.current.onDelete(); - }); + expect(result.current.step).toBe('confirm'); - act(() => result.current.onOpenChange(false)); + act(() => result.current.onDelete()); - expect(result.current.open).toBe(false); + await waitFor(() => expect(result.current.step).toBe('verify')); + expect(result.current.verification).toBeDefined(); + expect(result.current.isDeleting).toBe(false); }); - it('ignores a close while reverification is retrying the action', () => { + it('shows the error when reverification is required but not wired', async () => { const { result } = renderHook(() => - useDestructiveController({ - onDelete: () => new Promise(() => {}), - reverification: { status: 'retrying' } as ReverificationController, - }), + useDestructiveController({ onDelete: () => Promise.reject(needsReverification()) }), ); act(() => result.current.onOpenChange(true)); - act(() => { - void result.current.onDelete(); - }); - act(() => result.current.onOpenChange(false)); - - expect(result.current.open).toBe(true); - expect(result.current.isDeleting).toBe(true); - }); - - it('is on the confirm step without reverification', () => { - const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve() })); + act(() => result.current.onDelete()); + await waitFor(() => expect(result.current.errorMessage).toBe('Something went wrong')); expect(result.current.step).toBe('confirm'); }); - it.each([ - ['idle', 'confirm'], - ['loading', 'confirm'], - ['unavailable', 'verify'], - ['ready', 'verify'], - ['retrying', 'verify'], - ] as const)('reverification status %s uses the %s step', (status, step) => { + it('stops reverification when the dialog closes', async () => { const { result } = renderHook(() => useDestructiveController({ - onDelete: () => Promise.resolve(), - reverification: { status } as ReverificationController, + onDelete: () => Promise.reject(needsReverification()), + reverification: reverification(), }), ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onDelete()); + await waitFor(() => expect(result.current.verification).toBeDefined()); + const child = result.current.verification; - expect(result.current.step).toBe(step); + act(() => result.current.onOpenChange(false)); + + expect(result.current.open).toBe(false); + expect(result.current.verification).toBeUndefined(); + expect(child?.getSnapshot().status).toBe('stopped'); }); - it('cancels reverification when the dialog closes', () => { - const onCancel = vi.fn(); - const { result } = renderHook(() => - useDestructiveController({ - onDelete: () => new Promise(() => {}), - reverification: { status: 'ready', onCancel } as ReverificationController, - }), + it('closes when the session changes during reverification', async () => { + const { result, rerender } = renderHook( + ({ sessionId }) => + useDestructiveController({ + onDelete: () => Promise.reject(needsReverification()), + reverification: reverification(sessionId), + }), + { initialProps: { sessionId: 'sess_1' } }, ); act(() => result.current.onOpenChange(true)); - act(() => { - void result.current.onDelete(); - }); + act(() => result.current.onDelete()); + await waitFor(() => expect(result.current.step).toBe('verify')); - act(() => result.current.onOpenChange(false)); + rerender({ sessionId: 'sess_2' }); - expect(onCancel).toHaveBeenCalledOnce(); expect(result.current.open).toBe(false); }); - it('does not cancel reverification when a close is ignored', () => { - const { result } = renderHook(() => - useDestructiveController({ - onDelete: () => new Promise(() => {}), - reverification: { status: 'retrying' } as ReverificationController, - }), + it('closes when the session is signed out during reverification, but not when it briefly unloads', async () => { + const { result, rerender } = renderHook( + ({ sessionId }: { sessionId: string | null | undefined }) => + useDestructiveController({ + onDelete: () => Promise.reject(needsReverification()), + reverification: reverification(sessionId), + }), + { initialProps: { sessionId: 'sess_1' } }, ); act(() => result.current.onOpenChange(true)); - act(() => { - void result.current.onDelete(); - }); + act(() => result.current.onDelete()); + await waitFor(() => expect(result.current.step).toBe('verify')); - act(() => result.current.onOpenChange(false)); + rerender({ sessionId: undefined }); + rerender({ sessionId: 'sess_1' }); + expect(result.current.step).toBe('verify'); - expect(result.current.open).toBe(true); + rerender({ sessionId: null }); + expect(result.current.open).toBe(false); }); }); diff --git a/packages/mosaic/src/blocks/destructive/destructive.controller.ts b/packages/mosaic/src/blocks/destructive/destructive.controller.ts index 43d9729b9c8..de644cccb3f 100644 --- a/packages/mosaic/src/blocks/destructive/destructive.controller.ts +++ b/packages/mosaic/src/blocks/destructive/destructive.controller.ts @@ -1,84 +1,57 @@ -import { isReverificationCancelledError } from '@clerk/shared/error'; -import { useState } from 'react'; +import { useEffect, useRef } from 'react'; -import type { ReverificationController } from '../../features/reverification'; +import type { AnyActor, ProvidedActors } from '../../machine/types'; +import { useMachine } from '../../machine/useMachine'; import type { DestructiveControlledProps } from './destructive'; +import { destructiveMachine } from './destructive.machine'; export type DestructiveController = Pick< DestructiveControlledProps, 'open' | 'isDeleting' | 'errorMessage' | 'onOpenChange' | 'step' > & { - onDelete: () => Promise; + onDelete: () => void; openDestructiveDialog: () => void; + verification: AnyActor | undefined; }; -type DestructiveState = - | { status: 'closed' } - | { status: 'open-needs-confirmation' } - | { status: 'open-pending' } - | { status: 'open-error'; errorMessage: string }; +export interface DestructiveReverification { + actors: ProvidedActors; + sessionId: string | null | undefined; +} -/** Optional controller for use with Destructive block */ export function useDestructiveController({ onDelete, reverification, }: { onDelete: () => Promise; - reverification?: ReverificationController; + reverification?: DestructiveReverification; }): DestructiveController { - const [destructiveState, setDestructiveState] = useState({ status: 'closed' }); - const { status } = destructiveState; - - const openDestructiveDialog = () => { - if (status === 'closed') { - setDestructiveState({ status: 'open-needs-confirmation' }); + const [snapshot, send] = useMachine(destructiveMachine, { + context: { reverifiable: reverification !== undefined }, + actors: { ...reverification?.actors, action: onDelete }, + }); + + const sessionId = reverification?.sessionId; + const knownSessionId = useRef(sessionId); + useEffect(() => { + if (sessionId === undefined) { + return; } - }; - - const isDeleting = status === 'open-pending'; - const step = - reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm'; + const previous = knownSessionId.current; + knownSessionId.current = sessionId; + if (previous !== undefined && previous !== sessionId) { + send({ type: 'SESSION_CHANGED' }); + } + }, [sessionId, send]); return { - open: status !== 'closed', - isDeleting, - errorMessage: status === 'open-error' ? destructiveState.errorMessage : undefined, - step, - 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 cancelReverification = reverification?.onCancel; - if (!nextIsOpen && isDeleting && !cancelReverification) { - return; - } - - if (nextIsOpen) { - setDestructiveState({ status: 'open-needs-confirmation' }); - } else { - cancelReverification?.(); - setDestructiveState({ status: 'closed' }); - } - }, + open: snapshot.matches('open'), + isDeleting: snapshot.matches('open.running') || snapshot.matches('open.verifying.retrying'), + errorMessage: snapshot.matches('open.failed') ? snapshot.context.errorMessage : undefined, + step: snapshot.matches('open.verifying') ? 'verify' : 'confirm', + verification: snapshot.children.reverification, + openDestructiveDialog: () => send({ type: 'OPEN' }), + onDelete: () => send({ type: 'CONFIRM' }), + onOpenChange: nextIsOpen => send(nextIsOpen ? { type: 'OPEN' } : { type: 'CLOSE' }), }; } diff --git a/packages/mosaic/src/blocks/destructive/destructive.machine.test.ts b/packages/mosaic/src/blocks/destructive/destructive.machine.test.ts new file mode 100644 index 00000000000..3a376a07ddb --- /dev/null +++ b/packages/mosaic/src/blocks/destructive/destructive.machine.test.ts @@ -0,0 +1,174 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { describe, expect, it, vi } from 'vitest'; + +import { reverificationMachine } from '../../features/reverification/reverification.machine'; +import type { ReverificationMethod, ReverificationResult } from '../../features/reverification/reverification.types'; +import { childActor, createActor } from '../../machine/createActor'; +import { destructiveMachine } from './destructive.machine'; + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +const password: ReverificationMethod = { id: 'password', stage: 'first', strategy: 'password' }; + +const needsReverification = () => + new ClerkAPIResponseError('reverify', { + data: [{ code: 'session_reverification_required', message: 'Reverify', long_message: 'Reverify' }], + status: 403, + }); + +const needsFactor: ReverificationResult = { + status: 'needs_first_factor', + methods: [password], + startingMethod: password, +}; +const complete: ReverificationResult = { status: 'complete', methods: [], startingMethod: null }; + +function setupFlow(action: () => Promise) { + const actors = { + action: vi.fn(action), + reverification: reverificationMachine, + startVerification: vi.fn(() => Promise.resolve(needsFactor)), + prepareFactor: vi.fn(() => Promise.resolve()), + attemptFactor: vi.fn(() => Promise.resolve(complete)), + finishVerification: vi.fn(() => Promise.resolve()), + }; + const actor = createActor(destructiveMachine, { actors, context: { reverifiable: true } }).start(); + const reverification = () => childActor(actor.getSnapshot().children.reverification, reverificationMachine); + return { actor, actors, reverification }; +} + +describe('destructive flow', () => { + it('closes after the action succeeds', async () => { + const { actor, actors } = setupFlow(() => Promise.resolve()); + + actor.send({ type: 'OPEN' }); + actor.send({ type: 'CONFIRM' }); + expect(actor.getSnapshot().value).toBe('open.running'); + await tick(); + + expect(actors.action).toHaveBeenCalledTimes(1); + expect(actor.getSnapshot().value).toBe('closed'); + }); + + it('cannot be closed while the action runs', () => { + const { actor } = setupFlow(() => new Promise(() => {})); + + actor.send({ type: 'OPEN' }); + actor.send({ type: 'CONFIRM' }); + actor.send({ type: 'CLOSE' }); + + expect(actor.getSnapshot().value).toBe('open.running'); + }); + + it('shows an error when the action fails and lets the user try again', async () => { + const { actor, actors } = setupFlow(() => Promise.reject(new Error('boom'))); + + actor.send({ type: 'OPEN' }); + actor.send({ type: 'CONFIRM' }); + await tick(); + expect(actor.getSnapshot().value).toBe('open.failed'); + expect(actor.getSnapshot().context.errorMessage).toBe('Something went wrong'); + + actor.send({ type: 'CONFIRM' }); + expect(actor.getSnapshot().value).toBe('open.running'); + expect(actors.action).toHaveBeenCalledTimes(2); + }); + + it('reverifies, retries the action and closes', async () => { + let calls = 0; + const { actor, actors, reverification } = setupFlow(() => + ++calls === 1 ? Promise.reject(needsReverification()) : Promise.resolve(), + ); + + actor.send({ type: 'OPEN' }); + actor.send({ type: 'CONFIRM' }); + await tick(); + expect(actor.getSnapshot().value).toBe('open.verifying.challenge'); + expect(reverification()?.getSnapshot().value).toBe('factor.editing.ready'); + + reverification()?.send({ type: 'TYPE', value: 'hunter2' }); + reverification()?.send({ type: 'SUBMIT' }); + await tick(); + await tick(); + + expect(actors.attemptFactor).toHaveBeenCalledWith({ method: password, value: 'hunter2' }); + expect(actors.finishVerification).toHaveBeenCalledTimes(1); + expect(actors.action).toHaveBeenCalledTimes(2); + expect(actor.getSnapshot().value).toBe('closed'); + expect(actor.getSnapshot().children).toEqual({}); + }); + + it('keeps the dialog open and pending while retrying', async () => { + let calls = 0; + const { actor, reverification } = setupFlow(() => + ++calls === 1 ? Promise.reject(needsReverification()) : new Promise(() => {}), + ); + + actor.send({ type: 'OPEN' }); + actor.send({ type: 'CONFIRM' }); + await tick(); + reverification()?.send({ type: 'SUBMIT' }); + await tick(); + await tick(); + + expect(actor.getSnapshot().value).toBe('open.verifying.retrying'); + expect(reverification()?.getSnapshot().value).toBe('verified'); + actor.send({ type: 'CLOSE' }); + expect(actor.getSnapshot().value).toBe('open.verifying.retrying'); + }); + + it('stops reverification and skips the retry when closed during the challenge', async () => { + const { actor, actors, reverification } = setupFlow(() => Promise.reject(needsReverification())); + + actor.send({ type: 'OPEN' }); + actor.send({ type: 'CONFIRM' }); + await tick(); + const child = reverification(); + + actor.send({ type: 'CLOSE' }); + + expect(actor.getSnapshot().value).toBe('closed'); + expect(child?.getSnapshot().status).toBe('stopped'); + expect(actors.action).toHaveBeenCalledTimes(1); + }); + + it('closes when the session changes during the challenge', async () => { + const { actor } = setupFlow(() => Promise.reject(needsReverification())); + + actor.send({ type: 'SESSION_CHANGED' }); + actor.send({ type: 'OPEN' }); + actor.send({ type: 'CONFIRM' }); + await tick(); + actor.send({ type: 'SESSION_CHANGED' }); + + expect(actor.getSnapshot().value).toBe('closed'); + }); + + it('treats a reverification error as a failure when it cannot reverify', async () => { + const actor = createActor(destructiveMachine, { + actors: { action: () => Promise.reject(needsReverification()) }, + }).start(); + + actor.send({ type: 'OPEN' }); + actor.send({ type: 'CONFIRM' }); + await tick(); + + expect(actor.getSnapshot().value).toBe('open.failed'); + }); + + it('shows the error when the retry fails', async () => { + let calls = 0; + const { actor, reverification } = setupFlow(() => + ++calls === 1 ? Promise.reject(needsReverification()) : Promise.reject(new Error('boom')), + ); + + actor.send({ type: 'OPEN' }); + actor.send({ type: 'CONFIRM' }); + await tick(); + reverification()?.send({ type: 'SUBMIT' }); + await tick(); + await tick(); + + expect(actor.getSnapshot().value).toBe('open.failed'); + }); +}); diff --git a/packages/mosaic/src/blocks/destructive/destructive.machine.ts b/packages/mosaic/src/blocks/destructive/destructive.machine.ts new file mode 100644 index 00000000000..f963d221aa9 --- /dev/null +++ b/packages/mosaic/src/blocks/destructive/destructive.machine.ts @@ -0,0 +1,69 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; + +import { setup } from '../../machine/setup'; +import type { ErrorInvokeEvent } from '../../machine/types'; + +export interface DestructiveContext { + errorMessage: string | undefined; + reverifiable: boolean; +} + +export type DestructiveEvent = { type: 'OPEN' } | { type: 'CLOSE' } | { type: 'CONFIRM' } | { type: 'SESSION_CHANGED' }; + +const { createMachine, assign } = setup(); + +export function isReverificationRequired(error: unknown): boolean { + return isClerkAPIResponseError(error) && error.errors.some(({ code }) => code === 'session_reverification_required'); +} + +const fail = { + target: '#destructive.open.failed', + actions: assign(() => ({ errorMessage: 'Something went wrong' })), +}; + +export const destructiveMachine = createMachine({ + id: 'destructive', + initial: 'closed', + context: { errorMessage: undefined, reverifiable: false }, + states: { + closed: { + on: { OPEN: 'open' }, + }, + open: { + initial: 'confirming', + on: { CLOSE: 'closed' }, + states: { + confirming: { + on: { CONFIRM: 'running' }, + }, + running: { + on: { CLOSE: {} }, + invoke: { + src: 'action', + onDone: '#destructive.closed', + onError: [ + { target: 'verifying', guard: (ctx, event) => ctx.reverifiable && isReverificationRequired(event.error) }, + fail, + ], + }, + }, + verifying: { + initial: 'challenge', + invoke: { id: 'reverification', src: 'reverification', onDone: '.retrying' }, + states: { + challenge: { + on: { SESSION_CHANGED: '#destructive.closed' }, + }, + retrying: { + on: { CLOSE: {} }, + invoke: { src: 'action', onDone: '#destructive.closed', onError: fail }, + }, + }, + }, + failed: { + on: { CONFIRM: 'running' }, + }, + }, + }, + }, +}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.actors.test.tsx similarity index 62% rename from packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx rename to packages/mosaic/src/features/reverification/__tests__/reverification.actors.test.tsx index 97a327c59ea..e2ba3851f06 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.model.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.actors.test.tsx @@ -4,19 +4,7 @@ import type { PreferredSignInStrategy, SessionVerificationResource } from '@cler import { renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - type ReverificationActiveModel, - type ReverificationModel, - useReverificationModel, -} from '../reverification.model'; - -function active(model: ReverificationModel): ReverificationActiveModel { - expect(model.status).toBe('active'); - if (model.status !== 'active') { - throw new Error('expected active'); - } - return model; -} +import { useReverificationActors } from '../reverification.actors'; let session: | { @@ -32,7 +20,6 @@ let session: | undefined; let environmentHydrated: boolean; let preferredSignInStrategy: PreferredSignInStrategy; -let supportEmail: string | undefined; let webAuthnSupported: boolean; let setActive: ReturnType; @@ -53,10 +40,6 @@ vi.mock('../../../hooks/useMosaicEnvironment', () => ({ useMosaicEnvironment: () => environment(), })); -vi.mock('../../../hooks/useMosaicSupportEmail', () => ({ - useMosaicSupportEmail: () => supportEmail, -})); - vi.mock('@clerk/shared/webauthn', () => ({ isWebAuthnSupported: () => webAuthnSupported, })); @@ -72,16 +55,7 @@ function resource(overrides: Partial = {}): Session } as SessionVerificationResource; } -function activeProps() { - return { - phase: 'active' as const, - complete: vi.fn(), - cancel: vi.fn(), - level: 'first_factor' as const, - }; -} - -describe('useReverificationModel', () => { +describe('useReverificationActors', () => { beforeEach(() => { session = { id: 'sess_1', @@ -94,52 +68,15 @@ describe('useReverificationModel', () => { }; environmentHydrated = true; preferredSignInStrategy = 'password'; - supportEmail = 'support@example.com'; webAuthnSupported = true; setActive = vi.fn().mockResolvedValue(undefined); }); - it('is loading until the session and environment are both present', () => { - session = null; - const cancel = vi.fn(); - const { result } = renderHook(() => useReverificationModel({ ...activeProps(), cancel })); - expect(result.current.status).toBe('loading'); - if (result.current.status === 'loading') { - result.current.cancel(); - } - expect(cancel).toHaveBeenCalledOnce(); - }); - - it('is loading until supportEmail is resolved', () => { - supportEmail = undefined; - const { result } = renderHook(() => useReverificationModel(activeProps())); - expect(result.current.status).toBe('loading'); - }); - - it('is active when props are active', () => { - const { result } = renderHook(() => useReverificationModel(activeProps())); - expect(active(result.current).supportEmail).toBe('support@example.com'); - }); - - it('is inactive when props are idle, regardless of hydration', () => { - session = null; - environmentHydrated = false; - const { result } = renderHook(() => useReverificationModel({ phase: 'inactive' })); - expect(result.current).toEqual({ status: 'inactive' }); - }); - - it('is retrying when props are retrying, regardless of hydration', () => { - session = null; - environmentHydrated = false; - const { result } = renderHook(() => useReverificationModel({ phase: 'retrying' })); - expect(result.current).toEqual({ status: 'retrying' }); - }); - it('defaults to second-factor verification when no level is provided', async () => { session?.startVerification.mockResolvedValue(resource()); - const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: undefined })); + const { result } = renderHook(() => useReverificationActors()); - await active(result.current).start(); + await result.current.actors.startVerification(undefined); expect(session?.startVerification).toHaveBeenCalledWith({ level: 'second_factor' }); }); @@ -162,8 +99,8 @@ describe('useReverificationModel', () => { }), ); - const { result } = renderHook(() => useReverificationModel(activeProps())); - const started = await active(result.current).start(); + const { result } = renderHook(() => useReverificationActors()); + const started = await result.current.actors.startVerification('first_factor'); expect(session?.startVerification).toHaveBeenCalledWith({ level: 'first_factor' }); expect(started.methods.map(method => method.strategy)).toEqual(['password', 'email_code']); @@ -184,8 +121,8 @@ describe('useReverificationModel', () => { }), ); - const { result } = renderHook(() => useReverificationModel(activeProps())); - const started = await active(result.current).start(); + const { result } = renderHook(() => useReverificationActors()); + const started = await result.current.actors.startVerification('first_factor'); expect(started.startingMethod?.strategy).toBe('passkey'); }); @@ -202,8 +139,8 @@ describe('useReverificationModel', () => { }), ); - const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: 'second_factor' })); - const started = await active(result.current).start(); + const { result } = renderHook(() => useReverificationActors()); + const started = await result.current.actors.startVerification('second_factor'); expect(started.status).toBe('needs_second_factor'); expect(started.startingMethod).toEqual({ id: 'totp', stage: 'second', strategy: 'totp' }); expect(started.methods.find(method => method.strategy === 'phone_code')).toEqual({ @@ -220,9 +157,9 @@ describe('useReverificationModel', () => { session?.prepareFirstFactorVerification.mockResolvedValue(resource()); session?.attemptFirstFactorVerification.mockResolvedValue(resource({ status: 'complete' })); - const { result } = renderHook(() => useReverificationModel(activeProps())); - await active(result.current).start(); - await active(result.current).prepare({ + const { result } = renderHook(() => useReverificationActors()); + await result.current.actors.startVerification('first_factor'); + await result.current.actors.prepareFactor({ id: 'email_code:idn_1', stage: 'first', strategy: 'email_code', @@ -234,13 +171,16 @@ describe('useReverificationModel', () => { emailAddressId: 'idn_1', }); - await active(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'secret'); + await result.current.actors.attemptFactor({ + method: { id: 'password', stage: 'first', strategy: 'password' }, + value: 'secret', + }); expect(session?.attemptFirstFactorVerification).toHaveBeenCalledWith({ strategy: 'password', password: 'secret', }); - await active(result.current).prepare({ + await result.current.actors.prepareFactor({ id: 'phone_code:pn_1', stage: 'first', strategy: 'phone_code', @@ -264,9 +204,9 @@ describe('useReverificationModel', () => { session?.prepareSecondFactorVerification.mockResolvedValue(resource({ status: 'needs_second_factor' })); session?.attemptSecondFactorVerification.mockResolvedValue(resource({ status: 'complete' })); - const { result } = renderHook(() => useReverificationModel({ ...activeProps(), level: 'second_factor' })); - await active(result.current).start(); - await active(result.current).prepare({ + const { result } = renderHook(() => useReverificationActors()); + await result.current.actors.startVerification('second_factor'); + await result.current.actors.prepareFactor({ id: 'phone_code:pn_1', stage: 'second', strategy: 'phone_code', @@ -278,15 +218,21 @@ describe('useReverificationModel', () => { phoneNumberId: 'pn_1', }); - await active(result.current).attempt({ id: 'totp', stage: 'second', strategy: 'totp' }, '123456'); + await result.current.actors.attemptFactor({ + method: { id: 'totp', stage: 'second', strategy: 'totp' }, + value: '123456', + }); expect(session?.attemptSecondFactorVerification).toHaveBeenCalledWith({ strategy: 'totp', code: '123456' }); }); it('verifies a passkey', async () => { session?.verifyWithPasskey.mockResolvedValue(resource({ status: 'complete' })); - const { result } = renderHook(() => useReverificationModel(activeProps())); + const { result } = renderHook(() => useReverificationActors()); - await active(result.current).attempt({ id: 'passkey', stage: 'first', strategy: 'passkey' }, ''); + await result.current.actors.attemptFactor({ + method: { id: 'passkey', stage: 'first', strategy: 'passkey' }, + value: '', + }); expect(session?.verifyWithPasskey).toHaveBeenCalledOnce(); }); @@ -304,37 +250,44 @@ describe('useReverificationModel', () => { }), ); - const { result } = renderHook(() => useReverificationModel(activeProps())); + const { result } = renderHook(() => useReverificationActors()); await expect( - active(result.current).attempt({ id: 'password', stage: 'first', strategy: 'password' }, 'bad'), + result.current.actors.attemptFactor({ + method: { id: 'password', stage: 'first', strategy: 'password' }, + value: 'bad', + }), ).rejects.toMatchObject({ message: 'That password is incorrect.' }); }); - it('activates the verified session before complete', async () => { - session?.startVerification.mockResolvedValue(resource({ status: 'complete' })); - const order: string[] = []; - setActive.mockImplementation(() => { - order.push('setActive'); - }); - const props = activeProps(); - props.complete.mockImplementation(() => { - order.push('complete'); - }); - const { result } = renderHook(() => useReverificationModel(props)); - const started = await active(result.current).start(); - expect(started).toEqual({ status: 'complete', methods: [], startingMethod: null }); - await active(result.current).finish(); + it('activates the verified session to finish', async () => { + const { result } = renderHook(() => useReverificationActors()); + + await result.current.actors.finishVerification(); + expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' }); - expect(order).toEqual(['setActive', 'complete']); }); - it('does not call complete when setActive fails', async () => { + it('rewrites a failed setActive to a plain Error', async () => { setActive.mockRejectedValue(new Error('Session could not be activated.')); - const props = activeProps(); - const { result } = renderHook(() => useReverificationModel(props)); + const { result } = renderHook(() => useReverificationActors()); - await expect(active(result.current).finish()).rejects.toMatchObject({ message: 'Session could not be activated.' }); - expect(setActive).toHaveBeenCalledWith({ session: 'sess_1' }); - expect(props.complete).not.toHaveBeenCalled(); + await expect(result.current.actors.finishVerification()).rejects.toMatchObject({ + message: 'Session could not be activated.', + }); + }); + + it('fails verification when there is no session', async () => { + session = null; + const { result } = renderHook(() => useReverificationActors()); + + await expect(result.current.actors.startVerification(undefined)).rejects.toBeInstanceOf(Error); + expect(result.current.sessionId).toBeNull(); + }); + + it('reports an unloaded session as undefined', () => { + session = undefined; + const { result } = renderHook(() => useReverificationActors()); + + expect(result.current.sessionId).toBeUndefined(); }); }); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx index a4746e00b54..312d79c475f 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.composition.test.tsx @@ -4,31 +4,27 @@ import { describe, expect, it, vi } from 'vitest'; import { Card } from '../../../components/card'; import { Dialog } from '../../../components/dialog'; import { Flow } from '../../../components/flow'; +import { mockActor } from '../../../machine/createActor'; +import type { AnyActor } from '../../../machine/types'; import { MosaicProvider } from '../../../MosaicProvider'; import { Reverification } from '../reverification'; -import type { ReverificationController } from '../reverification.controller'; -import type { ReverificationViewProps } from '../reverification.types'; - -let controller: ReverificationController = { status: 'idle' }; - -function surface(overrides: Partial = {}): ReverificationController { - return { - status: 'ready', - step: 'password', - value: '', - onValueChange: vi.fn(), - isPending: false, - onSubmit: vi.fn(), - onShowMethods: vi.fn(), - onShowHelp: vi.fn(), - onBack: vi.fn(), - onEmailSupport: vi.fn(), - onResend: vi.fn(), - canResend: true, - methods: [], - onSelectMethod: vi.fn(), - ...overrides, - }; +import { reverificationMachine } from '../reverification.machine'; +import type { ReverificationMethod } from '../reverification.types'; + +vi.mock('../../../hooks/useMosaicSupportEmail', () => ({ + useMosaicSupportEmail: () => 'support@example.com', +})); + +const password: ReverificationMethod = { id: 'password', stage: 'first', strategy: 'password' }; + +let actor: AnyActor | undefined; + +function at(value: string): AnyActor { + return mockActor(reverificationMachine, { value, context: { activeMethod: password, methods: [password] } }); +} + +function surface(): AnyActor { + return at('factor.editing.ready'); } function Nested({ step }: { step: 'confirm' | 'verify' | 'finalizing' }) { @@ -54,7 +50,7 @@ function Nested({ step }: { step: 'confirm' | 'verify' | 'finalizing' }) { - + @@ -74,7 +70,7 @@ function Nested({ step }: { step: 'confirm' | 'verify' | 'finalizing' }) { describe('reverification inside an outer flow', () => { it('keeps one dialog and one card, and nests a flow only while verifying', () => { - controller = { status: 'idle' }; + actor = undefined; const { rerender } = render(); expect(screen.getAllByRole('dialog')).toHaveLength(1); @@ -83,7 +79,7 @@ describe('reverification inside an outer flow', () => { expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm'); expect(screen.getByText('Confirm the mock delete.')).toBeInTheDocument(); - controller = { status: 'loading' }; + actor = mockActor(reverificationMachine, { value: 'starting' }); rerender(); const flows = document.querySelectorAll('.cl-flow-root'); @@ -97,7 +93,7 @@ describe('reverification inside an outer flow', () => { const outerCard = document.querySelector('.cl-card-root'); const outerFlow = flows[0]; - controller = surface(); + actor = surface(); rerender(); const nextFlows = document.querySelectorAll('.cl-flow-root'); @@ -109,7 +105,7 @@ describe('reverification inside an outer flow', () => { expect(screen.getByLabelText('Password')).toBeInTheDocument(); expect(screen.queryByText('Confirm the mock delete.')).not.toBeInTheDocument(); - controller = { status: 'idle' }; + actor = undefined; rerender(); expect(screen.getAllByRole('dialog')).toHaveLength(1); @@ -120,13 +116,13 @@ describe('reverification inside an outer flow', () => { }); it('keeps the current step when the challenge goes inactive', () => { - controller = surface(); + actor = surface(); const { rerender } = render(); expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); expect(screen.getByLabelText('Password')).toBeInTheDocument(); - controller = { status: 'idle' }; + actor = undefined; rerender(); expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify'); @@ -135,7 +131,7 @@ describe('reverification inside an outer flow', () => { }); it('does not mount an inner flow before verification starts', () => { - controller = surface(); + actor = surface(); const { rerender } = render(); expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1); @@ -150,11 +146,11 @@ describe('reverification inside an outer flow', () => { describe('reverification card states', () => { it('keeps one card from the pending state through a factor', () => { - controller = { status: 'loading' }; + actor = mockActor(reverificationMachine, { value: 'starting' }); const { container, rerender } = render( - + , ); @@ -168,11 +164,11 @@ describe('reverification card states', () => { expect(container.querySelector('[aria-busy="true"]')).toContainElement(spinner); expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); - controller = surface(); + actor = surface(); rerender( - + , ); @@ -184,13 +180,13 @@ describe('reverification card states', () => { }); it('shows a dismiss button on the pending card inside a dialog', () => { - controller = { status: 'loading' }; + actor = mockActor(reverificationMachine, { value: 'starting' }); render( - + @@ -203,11 +199,11 @@ describe('reverification card states', () => { }); it('renders unavailable outside the factor flow, then mounts that flow in the same card', () => { - controller = { status: 'unavailable' }; + actor = at('unavailable'); const { container, rerender } = render( - + , ); @@ -220,11 +216,11 @@ describe('reverification card states', () => { screen.getByText('Cannot proceed with verification. No suitable authentication factor is configured.'), ).toBeInTheDocument(); - controller = surface(); + actor = surface(); rerender( - + , ); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx deleted file mode 100644 index 1089355dd26..00000000000 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.tsx +++ /dev/null @@ -1,914 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { createActor } from '../../../machine/createActor'; -import { deferred, tick } from '../../../machines/__tests__/test-utils'; -import { - type ReverificationDeps, - reverificationMachine, - useReverificationController, -} from '../reverification.controller'; -import type { ReverificationActiveModel, ReverificationModel } from '../reverification.model'; -import type { ReverificationMethod, ReverificationResult } from '../reverification.types'; - -const password: ReverificationMethod = { id: 'password', stage: 'first', strategy: 'password' }; -const email: ReverificationMethod = { - id: 'email_code:idn_1', - stage: 'first', - strategy: 'email_code', - identifier: 'a***@ex.com', - emailAddressId: 'idn_1', -}; -const totp: ReverificationMethod = { id: 'totp', stage: 'second', strategy: 'totp' }; - -function firstFactorResult(overrides: Partial = {}): ReverificationResult { - return { - status: 'needs_first_factor', - methods: [password, email], - startingMethod: password, - ...overrides, - }; -} - -function seatedDeps(overrides: Partial = {}): ReverificationDeps { - return { - start: vi.fn(async () => firstFactorResult()), - prepare: vi.fn(async () => {}), - attempt: vi.fn(async () => firstFactorResult({ status: 'complete' })), - finish: vi.fn(async () => {}), - cancel: vi.fn(), - ...overrides, - }; -} - -function startActor(deps: ReverificationDeps = seatedDeps()) { - const actor = createActor(reverificationMachine, { context: { deps } }).start(); - actor.send({ type: 'START' }); - return actor; -} - -function buildActiveModel(overrides: Partial = {}): ReverificationActiveModel { - return { - status: 'active', - supportEmail: 'support@example.com', - start: vi.fn(async () => firstFactorResult()), - prepare: vi.fn(async () => {}), - attempt: vi.fn(async () => firstFactorResult({ status: 'complete' })), - finish: vi.fn(async () => {}), - cancel: vi.fn(), - ...overrides, - }; -} - -describe('reverificationMachine', () => { - it('starts verification and lands on the starting method', async () => { - const actor = startActor(); - expect(actor.getSnapshot().value).toBe('starting'); - await tick(); - expect(actor.getSnapshot().value).toBe('verifying'); - expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('password'); - }); - - it('returns to verifying with the error when an attempt fails', async () => { - const actor = startActor( - seatedDeps({ attempt: vi.fn(async () => Promise.reject(new Error('That password is incorrect.'))) }), - ); - await tick(); - actor.send({ type: 'TYPE', value: 'bad' }); - actor.send({ type: 'SUBMIT' }); - await tick(); - expect(actor.getSnapshot().value).toBe('verifying'); - expect(actor.getSnapshot().context.errorMessage).toBe('That password is incorrect.'); - }); - - it('prepares an email code once when that method is selected', async () => { - const prepare = vi.fn(async () => {}); - const actor = startActor(seatedDeps({ prepare })); - await tick(); - actor.send({ type: 'SHOW_METHODS' }); - expect(actor.getSnapshot().value).toBe('methodPicker'); - actor.send({ type: 'SELECT_METHOD', id: email.id }); - expect(actor.getSnapshot().value).toBe('methodPickerPreparing'); - await tick(); - expect(prepare).toHaveBeenCalledOnce(); - expect(actor.getSnapshot().value).toBe('verifying'); - expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('email_code'); - expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); - - actor.send({ type: 'SHOW_METHODS' }); - actor.send({ type: 'BACK' }); - expect(actor.getSnapshot().value).toBe('verifying'); - expect(prepare).toHaveBeenCalledOnce(); - }); - - it('routes to second factor after a successful first-factor attempt', async () => { - const actor = startActor( - seatedDeps({ - attempt: vi.fn(async () => - firstFactorResult({ - status: 'needs_second_factor', - methods: [totp], - startingMethod: totp, - }), - ), - }), - ); - await tick(); - actor.send({ type: 'TYPE', value: 'secret' }); - actor.send({ type: 'SUBMIT' }); - await tick(); - expect(actor.getSnapshot().value).toBe('verifying'); - expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('totp'); - }); - - it('opens help from the method picker and returns to it', async () => { - const actor = startActor(); - await tick(); - actor.send({ type: 'SHOW_METHODS' }); - actor.send({ type: 'SHOW_HELP' }); - expect(actor.getSnapshot().value).toBe('help'); - actor.send({ type: 'BACK' }); - expect(actor.getSnapshot().value).toBe('methodPicker'); - }); - - it('leaves submitting immediately when reset and ignores a late failure', async () => { - const attempt = deferred(); - const cancel = vi.fn(); - const actor = startActor(seatedDeps({ attempt: () => attempt.promise, cancel })); - await tick(); - actor.send({ type: 'TYPE', value: 'secret' }); - actor.send({ type: 'SUBMIT' }); - expect(actor.getSnapshot().value).toBe('submitting'); - - actor.send({ type: 'RESET' }); - expect(actor.getSnapshot().value).toBe('inactive'); - - attempt.reject(new Error('cancelled')); - await tick(); - expect(actor.getSnapshot().value).toBe('inactive'); - expect(actor.getSnapshot().context.errorMessage).toBeUndefined(); - expect(cancel).not.toHaveBeenCalled(); - }); - - it('ignores a successful attempt that settled after reset', async () => { - const attempt = deferred(); - const finish = vi.fn(async () => {}); - const cancel = vi.fn(); - const actor = startActor(seatedDeps({ attempt: () => attempt.promise, finish, cancel })); - await tick(); - actor.send({ type: 'TYPE', value: 'secret' }); - actor.send({ type: 'SUBMIT' }); - actor.send({ type: 'RESET' }); - - attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - await tick(); - expect(actor.getSnapshot().value).toBe('inactive'); - expect(finish).not.toHaveBeenCalled(); - expect(cancel).not.toHaveBeenCalled(); - }); - - it('finishes on success without replacing the active method', async () => { - const finish = deferred(); - const actor = startActor( - seatedDeps({ - attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - finish: () => finish.promise, - }), - ); - await tick(); - actor.send({ type: 'TYPE', value: 'secret' }); - actor.send({ type: 'SUBMIT' }); - await tick(); - expect(actor.getSnapshot().value).toBe('finishing'); - expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('password'); - finish.resolve(); - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('retrying')); - }); - - it('returns to the current method with the error when finish fails', async () => { - const actor = startActor( - seatedDeps({ - attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - finish: vi.fn(async () => Promise.reject(new Error('Session could not be activated.'))), - }), - ); - await tick(); - actor.send({ type: 'TYPE', value: 'secret' }); - actor.send({ type: 'SUBMIT' }); - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifying')); - expect(actor.getSnapshot().context.activeMethod?.strategy).toBe('password'); - expect(actor.getSnapshot().context.errorMessage).toBe('Session could not be activated.'); - expect(actor.getSnapshot().context.deps.cancel).not.toHaveBeenCalled(); - }); - - it('prepares the starting email method without leaving the factor', async () => { - const prepare = deferred(); - const actor = startActor( - seatedDeps({ - start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), - prepare: () => prepare.promise, - }), - ); - await tick(); - expect(actor.getSnapshot().value).toBe('preparing'); - expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); - - actor.send({ type: 'TYPE', value: '123456' }); - expect(actor.getSnapshot().context.inputValue).toBe('123456'); - - prepare.resolve(); - await tick(); - expect(actor.getSnapshot().value).toBe('verifying'); - expect(actor.getSnapshot().context.resendAvailableAt).toEqual(expect.any(Number)); - }); - - it('queues an attempt submitted while the starting prepare is in flight', async () => { - const prepare = deferred(); - const finish = deferred(); - const attempt = vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - const actor = startActor( - seatedDeps({ - start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), - prepare: () => prepare.promise, - attempt, - finish: () => finish.promise, - }), - ); - await tick(); - actor.send({ type: 'TYPE', value: '123456' }); - actor.send({ type: 'SUBMIT' }); - expect(actor.getSnapshot().value).toBe('preparing'); - expect(attempt).not.toHaveBeenCalled(); - - prepare.resolve(); - await tick(); - expect(attempt).toHaveBeenCalledOnce(); - expect(actor.getSnapshot().value).toBe('finishing'); - }); - - it('keeps the factor interactive and queues submit while resend prepares', async () => { - const now = vi.spyOn(Date, 'now').mockReturnValue(1_000); - const resend = deferred(); - let prepareCount = 0; - const prepare = vi.fn(() => { - prepareCount += 1; - return prepareCount === 1 ? Promise.resolve() : resend.promise; - }); - const attempt = vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - - try { - const actor = startActor( - seatedDeps({ - start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), - prepare, - attempt, - }), - ); - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('verifying')); - - actor.send({ type: 'SHOW_HELP' }); - actor.send({ type: 'BACK' }); - now.mockReturnValue(31_000); - actor.send({ type: 'RESEND' }); - - expect(actor.getSnapshot().value).toBe('preparing'); - expect(actor.getSnapshot().context.resendAvailableAt).toBe(61_000); - - actor.send({ type: 'TYPE', value: '123456' }); - actor.send({ type: 'SUBMIT' }); - expect(actor.getSnapshot().context.inputValue).toBe('123456'); - expect(attempt).not.toHaveBeenCalled(); - - resend.resolve(); - await vi.waitFor(() => expect(attempt).toHaveBeenCalledWith(email, '123456')); - } finally { - now.mockRestore(); - } - }); -}); - -describe('useReverificationController', () => { - it('is idle when reverification is not active', () => { - const { result } = renderHook(() => useReverificationController({ status: 'inactive' })); - expect(result.current).toEqual({ status: 'idle' }); - }); - - it('is loading while the model is still waiting on Clerk', () => { - const cancel = vi.fn(); - const loading: ReverificationModel = { - status: 'loading', - cancel, - }; - const { result } = renderHook(() => useReverificationController(loading)); - expect(result.current.status).toBe('loading'); - if (result.current.status !== 'loading') { - throw new Error('expected loading'); - } - result.current.onCancel?.(); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it('is unavailable when start fails', async () => { - const cancel = vi.fn(); - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ start: vi.fn(async () => Promise.reject(new Error('no session'))), cancel }), - ), - ); - await waitFor(() => expect(result.current.status).toBe('unavailable')); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it('is unavailable when start returns no methods', async () => { - const cancel = vi.fn(); - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - start: vi.fn(async () => firstFactorResult({ methods: [], startingMethod: null })), - cancel, - }), - ), - ); - await waitFor(() => expect(result.current.status).toBe('unavailable')); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it('still passes onShowMethods when only one method is available', async () => { - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - start: vi.fn(async () => firstFactorResult({ methods: [password], startingMethod: password })), - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - if (result.current.status !== 'ready') { - throw new Error('expected ready'); - } - expect(result.current.onShowMethods).toEqual(expect.any(Function)); - expect(result.current.methods).toEqual([]); - expect(result.current.step).toBe('password'); - }); - - it('keeps onResend during the resend cooldown', async () => { - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - if (result.current.status !== 'ready') { - throw new Error('expected ready'); - } - expect(result.current.canResend).toBe(false); - expect(result.current.onResend).toEqual(expect.any(Function)); - expect(result.current.resendRemainingSeconds).toBeGreaterThan(0); - expect(result.current.resendRemainingSeconds).toBeLessThanOrEqual(30); - }); - - it('marks the current step pending while an attempt is in flight', async () => { - const attempt = deferred(); - const { result } = renderHook(() => - useReverificationController(buildActiveModel({ attempt: () => attempt.promise })), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(true); - } - }); - - act(() => { - attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - }); - }); - - it('resets immediately while an attempt is in flight', async () => { - const attempt = deferred(); - const finish = vi.fn(() => Promise.resolve()); - const cancel = vi.fn(); - const activeModel = buildActiveModel({ attempt: () => attempt.promise, finish, cancel }); - const { result, rerender } = renderHook( - ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { initialProps: { model: activeModel } }, - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(true); - } - }); - - rerender({ model: { status: 'inactive' } }); - expect(result.current.status).toBe('idle'); - - await act(async () => { - attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - await tick(); - }); - expect(result.current.status).toBe('idle'); - expect(cancel).not.toHaveBeenCalled(); - expect(finish).not.toHaveBeenCalled(); - }); - - it('cancels an in-flight attempt before it can finish', async () => { - const attempt = deferred(); - const finish = vi.fn(() => Promise.resolve()); - const cancel = vi.fn(); - const { result } = renderHook(() => - useReverificationController(buildActiveModel({ attempt: () => attempt.promise, finish, cancel })), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(true); - } - }); - - act(() => { - if (result.current.status === 'ready') { - result.current.onCancel?.(); - } - }); - expect(cancel).toHaveBeenCalledOnce(); - - await act(async () => { - attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - await tick(); - }); - expect(cancel).toHaveBeenCalledOnce(); - expect(finish).not.toHaveBeenCalled(); - }); - - it('starts a fresh flow when reopened while a cancelled attempt is still in flight', async () => { - const attempt = deferred(); - const start = vi.fn(async () => firstFactorResult()); - const finish = vi.fn(() => Promise.resolve()); - const cancel = vi.fn(); - const model = buildActiveModel({ start, attempt: () => attempt.promise, finish, cancel }); - const { result, rerender } = renderHook( - ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { initialProps: { model } }, - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(true); - } - }); - - rerender({ model: { status: 'inactive' } }); - expect(result.current.status).toBe('idle'); - - rerender({ model }); - await waitFor(() => expect(start).toHaveBeenCalledTimes(2)); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(false); - expect(result.current.value).toBe(''); - } - }); - - await act(async () => { - attempt.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - await tick(); - }); - - expect(cancel).not.toHaveBeenCalled(); - expect(finish).not.toHaveBeenCalled(); - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(false); - } - }); - - it('stays on the current step pending while finish runs', async () => { - const finish = deferred(); - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - finish: () => finish.promise, - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('password'); - expect(result.current.isPending).toBe(true); - } - }); - - act(() => { - finish.resolve(); - }); - await waitFor(() => expect(result.current.status).toBe('retrying')); - }); - - it('stays on the current step with the error when finish fails', async () => { - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - finish: vi.fn(async () => Promise.reject(new Error('Session could not be activated.'))), - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('password'); - expect(result.current.isPending).toBe(false); - expect(result.current.errorMessage).toBe('Session could not be activated.'); - } - }); - }); - - it('keeps the current step when the model flickers to loading', async () => { - const start = vi.fn(async () => firstFactorResult()); - const { result, rerender } = renderHook( - ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { initialProps: { model: buildActiveModel({ start }) } }, - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - expect(start).toHaveBeenCalledOnce(); - - rerender({ model: { status: 'loading', cancel: vi.fn() } }); - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('password'); - } - expect(start).toHaveBeenCalledOnce(); - }); - - it('keeps finish seated when the model flickers to loading', async () => { - const finish = deferred(); - const finishFn = vi.fn(() => finish.promise); - const { result, rerender } = renderHook( - ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { - initialProps: { - model: buildActiveModel({ - attempt: vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - finish: finishFn, - }), - }, - }, - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(true); - } - }); - - rerender({ model: { status: 'loading', cancel: vi.fn() } }); - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('password'); - expect(result.current.isPending).toBe(true); - } - - act(() => { - finish.resolve(); - }); - await waitFor(() => expect(result.current.status).toBe('retrying')); - expect(finishFn).toHaveBeenCalledOnce(); - }); - - it('starts again only when the handshake ends and reopens', async () => { - const start = vi.fn(async () => firstFactorResult()); - const { result, rerender } = renderHook( - ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { initialProps: { model: buildActiveModel({ start }) } }, - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - rerender({ model: { status: 'inactive' } }); - expect(result.current).toEqual({ status: 'idle' }); - - rerender({ model: buildActiveModel({ start }) }); - await waitFor(() => expect(result.current.status).toBe('ready')); - expect(start).toHaveBeenCalledTimes(2); - }); - - it('shows the factor idle while the starting prepare is in flight', async () => { - const prepare = deferred(); - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - start: vi.fn(async () => firstFactorResult({ methods: [email, password], startingMethod: email })), - prepare: () => prepare.promise, - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - if (result.current.status !== 'ready') { - throw new Error('expected ready'); - } - expect(result.current.step).toBe('otp'); - expect(result.current.isPending).toBe(false); - expect(result.current.canResend).toBe(false); - expect(result.current.pendingMethodId).toBeUndefined(); - - const onValueChange = result.current.onValueChange; - act(() => { - onValueChange('123456'); - }); - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.value).toBe('123456'); - } - - act(() => { - prepare.resolve(); - }); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.canResend).toBe(false); - expect(result.current.isPending).toBe(false); - } - }); - }); - - it('does not attempt until the starting prepare settles after submit', async () => { - const prepare = deferred(); - const attempt = vi.fn(async () => firstFactorResult({ status: 'complete', methods: [], startingMethod: null })); - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - start: vi.fn(async () => firstFactorResult({ methods: [email], startingMethod: email })), - prepare: () => prepare.promise, - attempt, - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('123456'); - result.current.onSubmit(); - } - }); - expect(attempt).not.toHaveBeenCalled(); - if (result.current.status === 'ready') { - expect(result.current.isPending).toBe(false); - } - - act(() => { - prepare.resolve(); - }); - await waitFor(() => expect(attempt).toHaveBeenCalledOnce()); - }); - - it('keeps the picker on the sending row until prepare settles', async () => { - const prepare = deferred(); - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - prepare: () => prepare.promise, - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onShowMethods(); - } - }); - if (result.current.status !== 'ready') { - throw new Error('expected ready'); - } - expect(result.current.step).toBe('method-picker'); - - const onSelectMethod = result.current.onSelectMethod; - act(() => { - onSelectMethod(email.id); - }); - if (result.current.status !== 'ready') { - throw new Error('expected ready'); - } - expect(result.current.step).toBe('method-picker'); - expect(result.current.pendingMethodId).toBe(email.id); - expect(result.current.isPending).toBe(false); - expect(result.current.methods.some(method => method.id === email.id)).toBe(true); - expect(result.current.onBack).toEqual(expect.any(Function)); - - act(() => { - prepare.resolve(); - }); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('otp'); - expect(result.current.pendingMethodId).toBeUndefined(); - expect(result.current.canResend).toBe(false); - } - }); - }); - - it('shows the factor with the error when a picker prepare fails', async () => { - const prepare = deferred(); - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - prepare: () => prepare.promise, - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onShowMethods(); - result.current.onSelectMethod(email.id); - } - }); - - act(() => { - prepare.reject(new Error('Could not send the code.')); - }); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('otp'); - expect(result.current.errorMessage).toBe('Could not send the code.'); - expect(result.current.pendingMethodId).toBeUndefined(); - expect(result.current.canResend).toBe(true); - } - }); - }); - - it('keeps the last factor pending during retrying and resets only when inactive', async () => { - const finish = deferred(); - const start = vi.fn(() => Promise.resolve(firstFactorResult())); - const cancel = vi.fn(); - const active = buildActiveModel({ - start, - cancel, - attempt: vi.fn(() => - Promise.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - ), - finish: () => finish.promise, - }); - const { result, rerender } = renderHook( - ({ model }: { model: ReverificationModel }) => useReverificationController(model), - { initialProps: { model: active } }, - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - await waitFor(() => { - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('password'); - expect(result.current.isPending).toBe(true); - } - }); - - rerender({ model: { status: 'retrying' } }); - expect(result.current.status).toBe('ready'); - if (result.current.status === 'ready') { - expect(result.current.step).toBe('password'); - expect(result.current.isPending).toBe(true); - expect(result.current.onCancel).toBeUndefined(); - } - expect(start).toHaveBeenCalledOnce(); - - act(() => { - finish.resolve(); - }); - await waitFor(() => { - expect(result.current.status).toBe('retrying'); - if (result.current.status === 'retrying') { - expect(result.current.step).toBe('password'); - expect(result.current.isPending).toBe(true); - expect(result.current.onCancel).toBeUndefined(); - } - }); - expect(cancel).not.toHaveBeenCalled(); - - rerender({ model: { status: 'inactive' } }); - expect(result.current).toEqual({ status: 'idle' }); - }); - - it('is retrying without onCancel as soon as finish resolves', async () => { - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - attempt: vi.fn(() => - Promise.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - ), - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('ready')); - act(() => { - if (result.current.status === 'ready') { - result.current.onValueChange('secret'); - result.current.onSubmit(); - } - }); - - await waitFor(() => expect(result.current.status).toBe('retrying')); - expect(result.current.onCancel).toBeUndefined(); - }); - - it('shows the pending card, not unavailable, when start completes without a factor', async () => { - const finish = deferred(); - const { result } = renderHook(() => - useReverificationController( - buildActiveModel({ - start: vi.fn(() => - Promise.resolve(firstFactorResult({ status: 'complete', methods: [], startingMethod: null })), - ), - finish: () => finish.promise, - }), - ), - ); - - await waitFor(() => expect(result.current.status).toBe('loading')); - act(() => { - finish.resolve(); - }); - await tick(); - expect(result.current.status).toBe('loading'); - }); -}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.machine.test.ts b/packages/mosaic/src/features/reverification/__tests__/reverification.machine.test.ts new file mode 100644 index 00000000000..6d2405bd6ce --- /dev/null +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.machine.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../../machine/createActor'; +import { reverificationMachine } from '../reverification.machine'; +import type { ReverificationMethod, ReverificationResult } from '../reverification.types'; + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +function deferred() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise(res => { + resolve = res; + }); + return { promise, resolve }; +} + +const password: ReverificationMethod = { id: 'password', stage: 'first', strategy: 'password' }; +const email: ReverificationMethod = { + id: 'email_code:ema_1', + stage: 'first', + strategy: 'email_code', + emailAddressId: 'ema_1', + identifier: 'a***@example.com', +}; + +const result = (startingMethod: ReverificationMethod | null): ReverificationResult => ({ + status: 'needs_first_factor', + methods: [password, email], + startingMethod, +}); + +function start( + startingMethod: ReverificationMethod | null, + overrides: Partial<{ prepareFactor: () => Promise; attemptFactor: () => Promise }> = {}, +) { + const actors = { + startVerification: vi.fn(() => Promise.resolve(result(startingMethod))), + prepareFactor: vi.fn(overrides.prepareFactor ?? (() => Promise.resolve())), + attemptFactor: vi.fn( + overrides.attemptFactor ?? (() => Promise.resolve(result(startingMethod))), + ), + finishVerification: vi.fn(() => Promise.resolve()), + }; + const actor = createActor(reverificationMachine, { actors }).start(); + return { actor, actors }; +} + +describe('reverification machine', () => { + it('lands on the starting factor', async () => { + const { actor } = start(password); + expect(actor.getSnapshot().value).toBe('starting'); + + await tick(); + + expect(actor.getSnapshot().value).toBe('factor.editing.ready'); + expect(actor.getSnapshot().context.activeMethod).toEqual(password); + }); + + it('is unavailable when there is no usable method', async () => { + const { actor } = start(null); + await tick(); + + expect(actor.getSnapshot().value).toBe('unavailable'); + }); + + it('prepares a factor that needs it, then waits for input', async () => { + const prepared = deferred(); + const { actor, actors } = start(email, { prepareFactor: () => prepared.promise }); + await tick(); + expect(actor.getSnapshot().value).toBe('factor.editing.preparing.idle'); + + prepared.resolve(); + await tick(); + + expect(actors.prepareFactor).toHaveBeenCalledWith(email); + expect(actor.getSnapshot().value).toBe('factor.editing.ready'); + }); + + it('queues a submit made while preparing and sends it once prepared', async () => { + const prepared = deferred(); + const { actor, actors } = start(email, { prepareFactor: () => prepared.promise }); + await tick(); + + actor.send({ type: 'TYPE', value: '123456' }); + actor.send({ type: 'SUBMIT' }); + expect(actor.getSnapshot().value).toBe('factor.editing.preparing.queued'); + expect(actors.attemptFactor).not.toHaveBeenCalled(); + + prepared.resolve(); + await tick(); + + expect(actors.attemptFactor).toHaveBeenCalledWith({ method: email, value: '123456' }); + }); + + it('shows the error and returns to the factor when an attempt fails', async () => { + const { actor } = start(password, { attemptFactor: () => Promise.reject(new Error('Incorrect password')) }); + await tick(); + + actor.send({ type: 'SUBMIT' }); + expect(actor.getSnapshot().value).toBe('factor.submitting'); + actor.send({ type: 'SHOW_HELP' }); + expect(actor.getSnapshot().value).toBe('factor.submitting'); + await tick(); + + expect(actor.getSnapshot().value).toBe('factor.editing.ready'); + expect(actor.getSnapshot().context.errorMessage).toBe('Incorrect password'); + }); + + it('returns from help to wherever it was opened', async () => { + const { actor } = start(password); + await tick(); + + actor.send({ type: 'SHOW_HELP' }); + expect(actor.getSnapshot().value).toBe('factor.help'); + actor.send({ type: 'BACK' }); + expect(actor.getSnapshot().value).toBe('factor.editing.ready'); + + actor.send({ type: 'SHOW_METHODS' }); + actor.send({ type: 'SHOW_HELP' }); + expect(actor.getSnapshot().value).toBe('methods.help'); + actor.send({ type: 'BACK' }); + expect(actor.getSnapshot().value).toBe('methods.list'); + }); + + it('keeps the current method active until a picked method is prepared', async () => { + const prepared = deferred(); + const { actor } = start(password, { prepareFactor: () => prepared.promise }); + await tick(); + + actor.send({ type: 'SHOW_METHODS' }); + actor.send({ type: 'SELECT_METHOD', id: email.id }); + expect(actor.getSnapshot().value).toBe('methods.preparing'); + expect(actor.getSnapshot().context.activeMethod).toEqual(password); + expect(actor.getSnapshot().context.pendingMethod).toEqual(email); + + prepared.resolve(); + await tick(); + + expect(actor.getSnapshot().value).toBe('factor.editing.ready'); + expect(actor.getSnapshot().context.activeMethod).toEqual(email); + expect(actor.getSnapshot().context.pendingMethod).toBeNull(); + }); + + it('switches straight to a method that needs no preparation', async () => { + const { actor } = start(email); + await tick(); + await tick(); + + actor.send({ type: 'SHOW_METHODS' }); + actor.send({ type: 'SELECT_METHOD', id: password.id }); + + expect(actor.getSnapshot().value).toBe('factor.editing.ready'); + expect(actor.getSnapshot().context.activeMethod).toEqual(password); + }); + + it('finishes once verification is complete', async () => { + const { actor, actors } = start(password, { + attemptFactor: () => Promise.resolve({ status: 'complete', methods: [], startingMethod: null }), + }); + await tick(); + + actor.send({ type: 'SUBMIT' }); + await tick(); + await tick(); + + expect(actors.finishVerification).toHaveBeenCalledTimes(1); + expect(actor.getSnapshot().value).toBe('verified'); + expect(actor.getSnapshot().status).toBe('done'); + }); +}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx index 645285c2c08..9e326b8bbb5 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.test.tsx @@ -1,68 +1,136 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; +import { createActor, mockActor } from '../../../machine/createActor'; import { Reverification } from '../reverification'; -import type { ReverificationController } from '../reverification.controller'; -import type { ReverificationViewProps } from '../reverification.types'; +import { RESEND_COOLDOWN_MS, type ReverificationContext, reverificationMachine } from '../reverification.machine'; +import type { ReverificationMethod, ReverificationViewProps } from '../reverification.types'; -let controller: ReverificationController = { status: 'idle' }; +let viewProps: ReverificationViewProps | undefined; vi.mock('../reverification.view', () => ({ ReverificationPending: () => , ReverificationUnavailable: () => , - ReverificationView: ({ step }: { step: string }) => {step}, + ReverificationView: (props: ReverificationViewProps) => { + viewProps = props; + return {props.step}; + }, })); -function ready(overrides: Partial = {}): ReverificationController { - return { - status: 'ready', - step: 'password', - value: '', - onValueChange: vi.fn(), - isPending: false, - onSubmit: vi.fn(), - onShowMethods: vi.fn(), - onShowHelp: vi.fn(), - onBack: vi.fn(), - onEmailSupport: vi.fn(), - onResend: vi.fn(), - canResend: true, - methods: [], - onSelectMethod: vi.fn(), - ...overrides, - }; +vi.mock('../../../hooks/useMosaicSupportEmail', () => ({ + useMosaicSupportEmail: () => 'support@example.com', +})); + +const password: ReverificationMethod = { id: 'password', stage: 'first', strategy: 'password' }; +const email: ReverificationMethod = { + id: 'email_code:ema_1', + stage: 'first', + strategy: 'email_code', + emailAddressId: 'ema_1', + identifier: 'a***@example.com', +}; + +function at(value: string, context: Partial = {}) { + return mockActor(reverificationMachine, { + value, + context: { activeMethod: password, methods: [password, email], ...context }, + }); +} + +function view(): ReverificationViewProps { + if (!viewProps) { + throw new Error('view was not rendered'); + } + return viewProps; } describe('Reverification', () => { - it('renders nothing while reverification is inactive', () => { - controller = { status: 'idle' }; - const { container } = render(); + it('renders nothing without a reverification actor', () => { + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); - it('renders pending, unavailable, or the factor view', () => { - controller = { status: 'loading' }; - const { rerender } = render(); + it('renders nothing for an actor of another machine', () => { + const other = createActor(reverificationMachine); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders pending while starting and unavailable when there is no method', () => { + const { rerender } = render(); expect(screen.getByTestId('pending')).toBeInTheDocument(); - expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - controller = { status: 'unavailable' }; - rerender(); + rerender(); expect(screen.getByTestId('unavailable')).toBeInTheDocument(); - expect(screen.queryByTestId('view')).not.toBeInTheDocument(); + }); + + it('shows the pending card when verification completes without a factor', () => { + render(); + expect(screen.getByTestId('pending')).toBeInTheDocument(); + }); + + it.each([ + ['factor.editing.ready', 'password'], + ['factor.help', 'help'], + ['methods.list', 'method-picker'], + ['methods.preparing', 'method-picker'], + ['methods.help', 'help'], + ])('maps %s to the %s step', (value, step) => { + render(); + expect(screen.getByTestId('view')).toHaveTextContent(step); + }); + + it('maps the active method to its factor step and otp channel', () => { + render(); - controller = ready({ step: 'otp', otpChannel: 'email' }); - rerender(); expect(screen.getByTestId('view')).toHaveTextContent('otp'); + expect(view().otpChannel).toBe('email'); + expect(view().methods).toEqual([password]); }); - it('keeps the factor flow mounted through retrying', () => { - controller = ready({ isPending: true }); - const { rerender } = render(); - expect(screen.getByTestId('view')).toHaveTextContent('password'); + it.each([ + ['factor.editing.ready', false], + ['factor.editing.preparing.queued', false], + ['factor.submitting', true], + ['finishing', true], + ['verified', true], + ])('in %s the factor pending flag is %s', (value, isPending) => { + render(); + expect(view().isPending).toBe(isPending); + }); + + it('keeps the picked method as the sending row while it prepares', () => { + render(); + + expect(view().pendingMethodId).toBe(email.id); + expect(view().methods).toEqual([email]); + }); + + it('counts down the resend cooldown', () => { + vi.useFakeTimers(); + try { + const resendAvailableAt = Date.now() + RESEND_COOLDOWN_MS; + render(); + expect(view().canResend).toBe(false); + expect(view().resendRemainingSeconds).toBe(30); + + act(() => { + vi.advanceTimersByTime(RESEND_COOLDOWN_MS); + }); + + expect(view().canResend).toBe(true); + expect(view().resendRemainingSeconds).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it('sends view callbacks to the actor', () => { + const actor = at('factor.editing.ready'); + render(); + + act(() => view().onValueChange('hunter2')); - controller = { status: 'idle' }; - rerender(); - expect(screen.queryByTestId('view')).not.toBeInTheDocument(); + expect(actor.getSnapshot().context.inputValue).toBe('hunter2'); }); }); 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 deleted file mode 100644 index 1a714e11ddc..00000000000 --- a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx +++ /dev/null @@ -1,460 +0,0 @@ -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 = { - complete: () => void; - cancel: () => void; - level: SessionVerificationLevel | undefined; -}; - -type Hint = { reverificationLevel: SessionVerificationLevel | undefined }; - -const IN_PROGRESS = 'request_already_in_progress'; - -let session: { id: string } | null | undefined = { id: 'sess_1' }; -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(); - return { - ...actual, - useSession: () => ({ session }), - useReverification: ( - fetcher: (...args: unknown[]) => Promise | undefined, - options?: { onNeedsReverification?: (params: NeedsReverificationParameters) => void }, - ) => { - 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); - }; - }, - }; -}); - -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(() => { - session = { id: 'sess_1' }; - challengeCancel = vi.fn(); - }); - - 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)); - - 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('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)); - - let pending!: Promise; - act(() => { - pending = result.current[0](); - }); - 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(() => { - assertActive(result.current[1]); - result.current[1].complete(); - }); - await waitFor(() => expect(result.current[1]).toEqual({ phase: 'retrying' })); - expect(fetcher).toHaveBeenCalledTimes(2); - - 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 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)); - - let pending!: Promise; - act(() => { - pending = 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.reject(new Error('Mock delete failed.')); - await act(async () => { - await expect(pending).rejects.toMatchObject({ message: 'Mock delete failed.' }); - }); - await waitFor(() => expect(result.current[1]).toEqual({ phase: 'inactive' })); - expect(challengeCancel).not.toHaveBeenCalled(); - }); - - 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)); - - let pending!: Promise; - act(() => { - pending = 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 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(() => { - first = result.current[0]('first'); - }); - await waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); - - 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('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)); - - let first!: Promise; - act(() => { - 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(() => { - 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(() => { - 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' })); - - 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 while the challenge is active', async () => { - const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); - const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); - - let first!: Promise; - act(() => { - first = result.current[0](); - }); - await waitFor(() => expect(result.current[1].phase).toBe('active')); - - const settled = expect(first).rejects.toMatchObject({ code: 'reverification_cancelled' }); - session = { id: 'sess_2' }; - rerender(); - await settled; - expect(result.current[1]).toEqual({ phase: 'inactive' }); - expect(challengeCancel).toHaveBeenCalledOnce(); - }); - - it('does not cancel when the session is briefly unloaded', async () => { - const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); - const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); - - let first!: Promise; - act(() => { - first = result.current[0](); - }); - await waitFor(() => expect(result.current[1].phase).toBe('active')); - - const previous = session; - session = undefined; - rerender(); - session = previous; - rerender(); - 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 during the challenge', async () => { - const fetcher = vi.fn().mockResolvedValue({ reverificationLevel: 'first_factor' } satisfies Hint); - const { result, rerender } = renderHook(() => useReverificationWithState(fetcher)); - - let first!: Promise; - act(() => { - 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 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 during the challenge', 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')); - - const settled = expect(first).rejects.toMatchObject({ code: 'reverification_cancelled' }); - unmount(); - await settled; - expect(challengeCancel).toHaveBeenCalledOnce(); - }); - - it('cancels the challenge when the owner unmounts before reverification is needed', async () => { - const response = deferred(); - const fetcher = vi.fn().mockImplementationOnce(() => response.promise); - const { result, unmount } = renderHook(() => useReverificationWithState(fetcher)); - - let pending!: Promise; - act(() => { - pending = result.current[0](); - }); - unmount(); - - const settled = expect(pending).rejects.toMatchObject({ code: 'reverification_cancelled' }); - response.resolve({ reverificationLevel: 'first_factor' }); - await settled; - expect(challengeCancel).toHaveBeenCalledOnce(); - }); - - it('does not cancel when the owner unmounts during retry', async () => { - const retry = deferred(); - const fetcher = vi - .fn() - .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) - .mockImplementationOnce(() => retry.promise); - 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].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(challengeCancel).toHaveBeenCalledOnce(); - - unmount(); - expect(challengeCancel).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/mosaic/src/features/reverification/index.ts b/packages/mosaic/src/features/reverification/index.ts index 1c56d70fda6..e81e200f317 100644 --- a/packages/mosaic/src/features/reverification/index.ts +++ b/packages/mosaic/src/features/reverification/index.ts @@ -1,14 +1,10 @@ -export { Reverification, useReverificationFlow } from './reverification'; -export type { ReverificationController } from './reverification.controller'; -export type { UseReverificationFlowResult } from './reverification'; +export { Reverification } from './reverification'; +export { useReverificationActors } from './reverification.actors'; export type { ReverificationMethod, ReverificationOtpChannel, - ReverificationState, ReverificationStage, ReverificationStep, ReverificationStrategy, ReverificationViewProps, } from './reverification.types'; -export type { UseReverificationWithStateResult } from './use-reverification-with-state'; -export { useReverificationWithState } from './use-reverification-with-state'; diff --git a/packages/mosaic/src/features/reverification/reverification.model.tsx b/packages/mosaic/src/features/reverification/reverification.actors.ts similarity index 54% rename from packages/mosaic/src/features/reverification/reverification.model.tsx rename to packages/mosaic/src/features/reverification/reverification.actors.ts index 0650c3f9cae..d7a89e4a287 100644 --- a/packages/mosaic/src/features/reverification/reverification.model.tsx +++ b/packages/mosaic/src/features/reverification/reverification.actors.ts @@ -9,32 +9,10 @@ import type { import { isWebAuthnSupported } from '@clerk/shared/webauthn'; import { useMosaicEnvironment } from '../../hooks/useMosaicEnvironment'; -import { useMosaicSupportEmail } from '../../hooks/useMosaicSupportEmail'; -import type { - ReverificationMethod, - ReverificationPreparableMethod, - ReverificationResult, - ReverificationStage, - ReverificationState, -} from './reverification.types'; +import { type ReverificationActors, reverificationMachine } from './reverification.machine'; +import type { ReverificationMethod, ReverificationResult, ReverificationStage } from './reverification.types'; import { pickStartingMethod } from './reverification.utils'; -export type ReverificationActiveModel = { - status: 'active'; - supportEmail: string; - start: () => Promise; - prepare: (method: ReverificationPreparableMethod) => Promise; - attempt: (method: ReverificationMethod, value: string) => Promise; - finish: () => Promise; - cancel: () => void; -}; - -export type ReverificationModel = - | { status: 'inactive' } - | { status: 'loading'; cancel: () => void } - | ReverificationActiveModel - | { status: 'retrying' }; - function toError(error: unknown): Error { if (isClerkAPIResponseError(error)) { const first = error.errors[0]; @@ -117,109 +95,91 @@ function toResult( }; } -export function useReverificationModel(reverificationState: ReverificationState): ReverificationModel { +async function rethrow(work: () => Promise): Promise { + try { + return await work(); + } catch (error) { + throw toError(error); + } +} + +export function useReverificationActors(): { + sessionId: string | null | undefined; + actors: ReverificationActors & { reverification: typeof reverificationMachine }; +} { const { session } = useSession(); const clerk = useClerk(); const environment = useMosaicEnvironment(); - const supportEmail = useMosaicSupportEmail(); - - if (reverificationState.phase === 'inactive') { - return { status: 'inactive' }; - } - - if (reverificationState.phase === 'retrying') { - return { status: 'retrying' }; - } - - const { level, cancel, complete } = reverificationState; - - if (!session || !environment || supportEmail === undefined) { - return { status: 'loading', cancel }; - } - - const webAuthnSupported = isWebAuthnSupported(); - const preferredSignInStrategy = environment.displayConfig.preferredSignInStrategy; + const verifiedSession = () => { + if (!session) { + throw new Error('Something went wrong. Please try again.'); + } + return session; + }; const handleResponse = (resource: SessionVerificationResource) => - toResult(resource, preferredSignInStrategy, webAuthnSupported); - - return { - status: 'active', - supportEmail, - start: async () => { - try { - return handleResponse(await session.startVerification({ level: level ?? 'second_factor' })); - } catch (error) { - throw toError(error); - } - }, - cancel, - prepare: async method => { - try { - switch (method.strategy) { - case 'email_code': - await session.prepareFirstFactorVerification({ - strategy: 'email_code', - emailAddressId: method.emailAddressId, - }); - return; - case 'phone_code': - if (method.stage === 'second') { - await session.prepareSecondFactorVerification({ - strategy: 'phone_code', - phoneNumberId: method.phoneNumberId, - }); - return; - } - await session.prepareFirstFactorVerification({ - strategy: 'phone_code', - phoneNumberId: method.phoneNumberId, - }); - return; + toResult(resource, environment?.displayConfig.preferredSignInStrategy, isWebAuthnSupported()); + + const actors: ReverificationActors = { + startVerification: level => + rethrow(async () => + handleResponse(await verifiedSession().startVerification({ level: level ?? 'second_factor' })), + ), + prepareFactor: method => + rethrow(async () => { + const current = verifiedSession(); + if (method.strategy === 'email_code') { + await current.prepareFirstFactorVerification({ + strategy: 'email_code', + emailAddressId: method.emailAddressId, + }); + } else if (method.stage === 'second') { + await current.prepareSecondFactorVerification({ + strategy: 'phone_code', + phoneNumberId: method.phoneNumberId, + }); + } else { + await current.prepareFirstFactorVerification({ strategy: 'phone_code', phoneNumberId: method.phoneNumberId }); } - } catch (error) { - throw toError(error); - } - }, - attempt: async (method, value) => { - try { + }), + attemptFactor: ({ method, value }) => + rethrow(async () => { + const current = verifiedSession(); switch (method.strategy) { case 'password': return handleResponse( - await session.attemptFirstFactorVerification({ strategy: 'password', password: value }), + await current.attemptFirstFactorVerification({ strategy: 'password', password: value }), ); case 'email_code': return handleResponse( - await session.attemptFirstFactorVerification({ strategy: 'email_code', code: value }), + await current.attemptFirstFactorVerification({ strategy: 'email_code', code: value }), ); case 'phone_code': if (method.stage === 'second') { return handleResponse( - await session.attemptSecondFactorVerification({ strategy: 'phone_code', code: value }), + await current.attemptSecondFactorVerification({ strategy: 'phone_code', code: value }), ); } return handleResponse( - await session.attemptFirstFactorVerification({ strategy: 'phone_code', code: value }), + await current.attemptFirstFactorVerification({ strategy: 'phone_code', code: value }), ); case 'totp': case 'backup_code': return handleResponse( - await session.attemptSecondFactorVerification({ strategy: method.strategy, code: value }), + await current.attemptSecondFactorVerification({ strategy: method.strategy, code: value }), ); case 'passkey': - return handleResponse(await session.verifyWithPasskey()); + return handleResponse(await current.verifyWithPasskey()); } - } catch (error) { - throw toError(error); - } - }, - finish: async () => { - try { - await clerk.setActive({ session: session.id }); - complete(); - } catch (error) { - throw toError(error); - } - }, + }), + finishVerification: () => + rethrow(async () => { + await clerk.setActive({ session: verifiedSession().id }); + }), + }; + + return { + sessionId: session === undefined ? undefined : (session?.id ?? null), + actors: { reverification: reverificationMachine, ...actors }, }; } diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts deleted file mode 100644 index 9252d54ae71..00000000000 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { useEffect, useState } from 'react'; - -import type { FlowDirection } from '../../components/flow'; -import { setup } from '../../machine/setup'; -import type { DoneInvokeEvent, StateConfig } from '../../machine/types'; -import { useMachine } from '../../machine/useMachine'; -import type { ReverificationActiveModel, ReverificationModel } from './reverification.model'; -import type { - ReverificationMethod, - ReverificationResult, - ReverificationStep, - ReverificationViewProps, -} from './reverification.types'; -import { needsPrepare, otpChannelFor } from './reverification.utils'; - -export type ReverificationController = - | { status: 'idle'; onCancel?: undefined } - | { status: 'loading'; onCancel?: () => void } - | { status: 'unavailable'; onCancel?: () => void } - | ({ status: 'ready'; onCancel?: () => void } & ReverificationViewProps) - | ({ status: 'retrying'; onCancel?: undefined } & ReverificationViewProps); - -type OverlayFrom = 'factor' | 'method-picker'; - -export type ReverificationDeps = Pick; - -interface ReverificationContext { - inputValue: string; - errorMessage: string | undefined; - direction: FlowDirection; - activeMethod: ReverificationMethod | null; - methods: readonly ReverificationMethod[]; - resendAvailableAt: number | undefined; - submitRequested: boolean; - frozenActiveMethodId: string | undefined; - overlayFrom: OverlayFrom; - supportEmail: string; - deps: ReverificationDeps; -} - -type ReverificationEvent = - | { type: 'START' } - | { type: 'RESET' } - | { type: 'TYPE'; value: string } - | { type: 'SUBMIT' } - | { type: 'SHOW_METHODS' } - | { type: 'SHOW_HELP' } - | { type: 'SELECT_METHOD'; id: string } - | { type: 'BACK' } - | { type: 'RESEND' }; - -const { createMachine, assign, fromPromise } = setup(); - -function notSeated(): Promise { - return Promise.reject(new Error('reverification deps are not seated')); -} - -const unseatedDeps: ReverificationDeps = { - start: notSeated, - prepare: notSeated, - attempt: notSeated, - finish: notSeated, - cancel: () => {}, -}; - -export const RESEND_COOLDOWN_MS = 30_000; - -function lockResend(): Pick { - return { resendAvailableAt: Date.now() + RESEND_COOLDOWN_MS }; -} - -function unlockResend(): Pick { - return { resendAvailableAt: undefined }; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'Something went wrong. Please try again.'; -} - -function selectMethod(ctx: ReverificationContext, id: string): Partial { - return { - activeMethod: ctx.methods.find(method => method.id === id) ?? ctx.activeMethod, - inputValue: '', - errorMessage: undefined, - direction: 1, - submitRequested: false, - }; -} - -function prepareActive(ctx: ReverificationContext) { - const method = ctx.activeMethod; - return method && needsPrepare(method) ? ctx.deps.prepare(method) : Promise.resolve(); -} - -function submit(ctx: ReverificationContext): Promise { - const method = ctx.activeMethod; - if (!method) { - return Promise.reject(new Error('No active method')); - } - return ctx.deps.attempt(method, ctx.inputValue); -} - -const applyResult = assign>((_, event) => ({ - methods: event.output.methods, - activeMethod: event.output.startingMethod, - inputValue: '', - errorMessage: undefined, - ...unlockResend(), - direction: 1, -})); -const afterResult = [ - { - guard: (_: ReverificationContext, event: DoneInvokeEvent) => - event.output.status === 'complete', - target: 'finishing' as const, - // We don't apply the result here as we want to keep the old one visible as we are finishing - }, - { - guard: (_: ReverificationContext, event: DoneInvokeEvent) => - event.output.startingMethod === null, - target: 'unavailable' as const, - actions: applyResult, - }, - { - guard: (_: ReverificationContext, event: DoneInvokeEvent) => { - const method = event.output.startingMethod; - return Boolean(method && needsPrepare(method)); - }, - target: 'preparing' as const, - actions: [applyResult, assign(() => lockResend())], - }, - { target: 'verifying' as const, actions: applyResult }, -]; - -const factorEvents = { - TYPE: { - actions: assign((_, event) => ({ inputValue: event.value, errorMessage: undefined })), - }, - SHOW_METHODS: { - target: 'methodPicker', - guard: ctx => ctx.methods.filter(method => method.id !== ctx.activeMethod?.id).length > 0, - actions: assign(ctx => ({ - direction: 1 as const, - overlayFrom: 'factor' as const, - submitRequested: false, - frozenActiveMethodId: ctx.activeMethod?.id, - })), - }, - SHOW_HELP: { - target: 'help', - actions: assign(() => ({ - direction: 1 as const, - overlayFrom: 'factor' as const, - submitRequested: false, - })), - }, - RESET: 'inactive', -} satisfies NonNullable['on']>; - -export const reverificationMachine = createMachine({ - id: 'reverification', - initial: 'inactive', - context: { - inputValue: '', - errorMessage: undefined, - direction: 1, - activeMethod: null, - methods: [], - resendAvailableAt: undefined, - submitRequested: false, - frozenActiveMethodId: undefined, - overlayFrom: 'factor', - supportEmail: '', - deps: unseatedDeps, - }, - states: { - inactive: { - entry: assign(() => ({ - inputValue: '', - errorMessage: undefined, - submitRequested: false, - ...unlockResend(), - })), - on: { START: 'starting' }, - }, - - starting: { - on: { RESET: 'inactive' }, - invoke: fromPromise(ctx => ctx.deps.start(), { - onDone: afterResult, - onError: 'unavailable', - }), - }, - - /* - Preparing a factor behind the scenes (e.g. sending an OTP) - - This shows the factor card with optimistic UI, so it needs to handle the same actions - as verifying, but if the user submits, we queue that up until after prepare resolves - */ - preparing: { - on: { - ...factorEvents, - SUBMIT: { - actions: assign(() => ({ submitRequested: true })), - }, - }, - invoke: fromPromise(prepareActive, { - onDone: [ - { - guard: (ctx: ReverificationContext) => ctx.submitRequested, - target: 'submitting' as const, - actions: assign(() => ({ submitRequested: false })), - }, - { target: 'verifying' as const }, - ], - onError: { - target: 'verifying', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - submitRequested: false, - ...unlockResend(), - })), - }, - }), - }, - - /* - When selecting a method from the picker, we prepare before going to the factor card - - We don't use optimistic UI here simply because we're in a better position to show - some feedback that does not feel janky. The view handles disabling inputs while preparing. - */ - methodPickerPreparing: { - entry: assign(() => lockResend()), - on: { RESET: 'inactive' }, - invoke: fromPromise(prepareActive, { - onDone: 'verifying', - onError: { - target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error), ...unlockResend() })), - }, - }), - }, - - verifying: { - always: [{ guard: ctx => ctx.activeMethod === null, target: 'unavailable' }], - on: { - ...factorEvents, - SUBMIT: 'submitting', - RESEND: { - target: 'preparing', - guard: ctx => - Boolean(ctx.activeMethod && needsPrepare(ctx.activeMethod)) && - (ctx.resendAvailableAt === undefined || Date.now() >= ctx.resendAvailableAt), - actions: assign(() => ({ - ...lockResend(), - inputValue: '', - errorMessage: undefined, - submitRequested: false, - })), - }, - }, - }, - - submitting: { - on: { RESET: 'inactive' }, - invoke: fromPromise(submit, { - onDone: afterResult, - onError: { - target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), - }, - }), - }, - - methodPicker: { - on: { - SELECT_METHOD: [ - { - target: 'methodPickerPreparing', - guard: (ctx, event) => { - const method = ctx.methods.find(candidate => candidate.id === event.id); - return Boolean(method && needsPrepare(method)); - }, - actions: assign((ctx, event) => selectMethod(ctx, event.id)), - }, - { target: 'verifying', actions: assign((ctx, event) => selectMethod(ctx, event.id)) }, - ], - SHOW_HELP: { - target: 'help', - actions: assign(() => ({ direction: 1 as const, overlayFrom: 'method-picker' as const })), - }, - BACK: { target: 'verifying', actions: assign(() => ({ direction: -1 as const })) }, - RESET: 'inactive', - }, - }, - - help: { - on: { - BACK: [ - { - target: 'methodPicker', - guard: ctx => ctx.overlayFrom === 'method-picker', - actions: assign(() => ({ direction: -1 as const })), - }, - { target: 'verifying', actions: assign(() => ({ direction: -1 as const })) }, - ], - RESET: 'inactive', - }, - }, - - unavailable: { - entry: (ctx: ReverificationContext) => { - ctx.deps.cancel(); - }, - on: { RESET: 'inactive' }, - }, - - finishing: { - on: { RESET: 'inactive' }, - invoke: fromPromise(ctx => ctx.deps.finish(), { - onDone: 'retrying', - onError: { - target: 'verifying', - actions: assign((_, event) => ({ errorMessage: errorMessage(event.error) })), - }, - }), - }, - - // This means we are retrying the action after successful reverification - // This state is usually left by the model state going to 'inactive', - // which it does when the retry has completed - retrying: { - on: { RESET: 'inactive' }, - }, - }, -}); - -const pendingStates = new Set(['submitting', 'finishing', 'retrying']); - -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'; - } - if (value === 'help') { - return 'help'; - } - if ( - value === 'verifying' || - value === 'submitting' || - value === 'preparing' || - value === 'finishing' || - value === 'retrying' - ) { - if (!method) { - return undefined; - } - return factorStep(method); - } - return undefined; -} - -/** - * Machine - State internal to the controller, not all steps are exposed to the UI - * Return 'ReverificationController' - The view state - * - status: 'idle' renders nothing - * - status: 'loading' | 'unavailable' carry no factor props - * - status: 'ready' | 'retrying' carry the factor view, including step - * - * There are two pending presentations. - * - status: 'loading' is the pending card rendered in place, before a factor exists - * - status: 'ready' && isPending is the inline pending state of the current factor - * - * 'retrying' is the point of no return: verification succeeded and the original action is being - * retried, so it keeps the last factor pending and has no `onCancel`. The factor machine runs - * while the model is active and resets when the model returns to inactive. - */ -export function useReverificationController(model: ReverificationModel): ReverificationController { - const active = model.status === 'active' ? model : null; - - const [snapshot, send] = useMachine( - reverificationMachine, - active - ? { - context: { - supportEmail: active.supportEmail, - deps: active, - }, - } - : undefined, - ); - - const [now, setNow] = useState(() => Date.now()); - const resendAvailableAt = snapshot.context.resendAvailableAt; - const canResend = resendAvailableAt === undefined || now >= resendAvailableAt; - const countingDown = !canResend; - - useEffect(() => { - if (!countingDown) { - return; - } - const id = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(id); - }, [countingDown, resendAvailableAt]); - - const cancelModel = - (model.status === 'loading' || model.status === 'active') && snapshot.value !== 'retrying' - ? model.cancel - : undefined; - const onCancel = cancelModel - ? () => { - send({ type: 'RESET' }); - cancelModel(); - } - : undefined; - const needsStart = active !== null && snapshot.value === 'inactive'; - const needsReset = model.status === 'inactive' && snapshot.value !== 'inactive'; - useEffect(() => { - if (needsStart) { - send({ type: 'START' }); - } else if (needsReset) { - send({ type: 'RESET' }); - } - }, [needsStart, needsReset, send]); - - if (model.status === 'inactive') { - return { status: 'idle' }; - } - - const { context } = snapshot; - const activeMethod = context.activeMethod; - - if (snapshot.value === 'unavailable') { - return { status: 'unavailable', onCancel }; - } - - const step = viewStep(snapshot.value, activeMethod); - - if (!step) { - return { status: 'loading', onCancel }; - } - - // If we are currently on the alternative methods screen and preparing a factor, activeMethod will - // have transitioned to the factor we are now preparing, so the one we want to hide is the old one - const excludeId = snapshot.value === 'methodPickerPreparing' ? context.frozenActiveMethodId : activeMethod?.id; - const methods = context.methods.filter(method => method.id !== excludeId); - const pendingMethodId = snapshot.value === 'methodPickerPreparing' ? activeMethod?.id : undefined; - - const view: ReverificationViewProps = { - step, - direction: context.direction, - value: context.inputValue, - onValueChange: (value: string) => send({ type: 'TYPE', value }), - errorMessage: context.errorMessage, - isPending: pendingStates.has(snapshot.value), - onSubmit: () => send({ type: 'SUBMIT' }), - onShowMethods: () => send({ type: 'SHOW_METHODS' }), - onShowHelp: () => send({ type: 'SHOW_HELP' }), - onBack: () => send({ type: 'BACK' }), - onEmailSupport: () => { - if (context.supportEmail) { - window.location.assign(`mailto:${context.supportEmail}`); - } - }, - methods, - pendingMethodId, - onSelectMethod: (id: string) => send({ type: 'SELECT_METHOD', id }), - otpChannel: activeMethod ? otpChannelFor(activeMethod.strategy) : undefined, - onResend: () => send({ type: 'RESEND' }), - canResend, - // We clamp this to 30s because the first render after locking resend - // will have the old `now` state set, which would result in a value above - // 30s. There are other solutions like reading Date.now() in render and - // letting the interval only retrigger render, but that makes the render - // impure which is something the React compiler would warn about. - // useSyncExternalStore feels unnecessarily complex here - resendRemainingSeconds: - countingDown && resendAvailableAt - ? Math.min(RESEND_COOLDOWN_MS / 1000, Math.max(0, Math.ceil((resendAvailableAt - now) / 1000))) - : undefined, - }; - - return snapshot.value === 'retrying' ? { status: 'retrying', ...view } : { status: 'ready', onCancel, ...view }; -} diff --git a/packages/mosaic/src/features/reverification/reverification.machine.ts b/packages/mosaic/src/features/reverification/reverification.machine.ts new file mode 100644 index 00000000000..d95f7e46b08 --- /dev/null +++ b/packages/mosaic/src/features/reverification/reverification.machine.ts @@ -0,0 +1,230 @@ +import type { SessionVerificationLevel } from '@clerk/shared/types'; + +import type { FlowDirection } from '../../components/flow'; +import { stateIn } from '../../machine/guards'; +import { setup } from '../../machine/setup'; +import type { DoneInvokeEvent, ErrorInvokeEvent } from '../../machine/types'; +import type { + ReverificationMethod, + ReverificationPreparableMethod, + ReverificationResult, +} from './reverification.types'; +import { needsPrepare } from './reverification.utils'; + +export const RESEND_COOLDOWN_MS = 30_000; + +export type ReverificationActors = { + startVerification: (level: SessionVerificationLevel | undefined) => Promise; + prepareFactor: (method: ReverificationPreparableMethod) => Promise; + attemptFactor: (input: { method: ReverificationMethod; value: string }) => Promise; + finishVerification: () => Promise; +}; + +export interface ReverificationContext { + level: SessionVerificationLevel | undefined; + methods: readonly ReverificationMethod[]; + activeMethod: ReverificationMethod | null; + pendingMethod: ReverificationPreparableMethod | null; + inputValue: string; + errorMessage: string | undefined; + direction: FlowDirection; + resendAvailableAt: number | undefined; +} + +export type ReverificationEvent = + | { type: 'TYPE'; value: string } + | { type: 'SUBMIT' } + | { type: 'RESEND' } + | { type: 'SHOW_METHODS' } + | { type: 'SHOW_HELP' } + | { type: 'SELECT_METHOD'; id: string } + | { type: 'BACK' }; + +const { createMachine, assign } = setup(); + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Something went wrong. Please try again.'; +} + +function canResend(ctx: ReverificationContext): boolean { + return ctx.resendAvailableAt === undefined || Date.now() >= ctx.resendAvailableAt; +} + +const lockResend = assign(() => ({ resendAvailableAt: Date.now() + RESEND_COOLDOWN_MS })); +const forward = assign(() => ({ direction: 1 as const })); +const backward = assign(() => ({ direction: -1 as const })); +const showError = assign((_, event) => ({ + errorMessage: errorMessage(event.error), + resendAvailableAt: undefined, +})); + +const applyResult = assign>((_, event) => ({ + methods: event.output.methods, + activeMethod: event.output.startingMethod, + inputValue: '', + errorMessage: undefined, + resendAvailableAt: undefined, + direction: 1, +})); + +const activate = (method: ReverificationMethod | null) => ({ + activeMethod: method, + pendingMethod: null, + inputValue: '', + errorMessage: undefined, + direction: 1 as const, +}); + +const afterResult = [ + { + target: '#reverification.finishing', + guard: (_: ReverificationContext, event: DoneInvokeEvent) => + event.output.status === 'complete', + }, + { + target: '#reverification.unavailable', + guard: (_: ReverificationContext, event: DoneInvokeEvent) => + event.output.startingMethod === null, + actions: applyResult, + }, + { + target: '#factor.editing.preparing', + guard: (_: ReverificationContext, event: DoneInvokeEvent) => { + const method = event.output.startingMethod; + return method !== null && needsPrepare(method); + }, + actions: [applyResult, lockResend], + }, + { target: '#factor', actions: applyResult }, +]; + +export const reverificationMachine = createMachine({ + id: 'reverification', + initial: 'starting', + context: { + level: undefined, + methods: [], + activeMethod: null, + pendingMethod: null, + inputValue: '', + errorMessage: undefined, + direction: 1, + resendAvailableAt: undefined, + }, + states: { + starting: { + invoke: { + src: 'startVerification', + input: ctx => ctx.level, + onDone: afterResult, + onError: 'unavailable', + }, + }, + + factor: { + id: 'factor', + initial: 'editing', + states: { + editing: { + initial: 'ready', + on: { + TYPE: { actions: assign((_, event) => ({ inputValue: event.value, errorMessage: undefined })) }, + SHOW_METHODS: { + target: '#reverification.methods', + guard: ctx => ctx.methods.some(method => method.id !== ctx.activeMethod?.id), + actions: forward, + }, + SHOW_HELP: { target: 'help', actions: forward }, + }, + states: { + ready: { + on: { + SUBMIT: '#factor.submitting', + RESEND: { + target: 'preparing', + guard: ctx => ctx.activeMethod !== null && needsPrepare(ctx.activeMethod) && canResend(ctx), + actions: [lockResend, assign(() => ({ inputValue: '', errorMessage: undefined }))], + }, + }, + }, + preparing: { + initial: 'idle', + invoke: { + src: 'prepareFactor', + input: ctx => ctx.activeMethod, + onDone: [ + { target: '#factor.submitting', guard: stateIn('factor.editing.preparing.queued') }, + { target: 'ready' }, + ], + onError: { target: 'ready', actions: showError }, + }, + states: { + idle: { on: { SUBMIT: 'queued' } }, + queued: {}, + }, + }, + }, + }, + submitting: { + invoke: { + src: 'attemptFactor', + input: ctx => ({ method: ctx.activeMethod, value: ctx.inputValue }), + onDone: afterResult, + onError: { target: 'editing', actions: showError }, + }, + }, + help: { + on: { BACK: { target: 'editing', actions: backward } }, + }, + }, + }, + + methods: { + initial: 'list', + states: { + list: { + on: { + SELECT_METHOD: ({ context, event }) => { + const method = context.methods.find(candidate => candidate.id === event.id); + if (method === undefined) { + return undefined; + } + return needsPrepare(method) + ? { target: 'preparing', context: { pendingMethod: method } } + : { target: '#factor', context: activate(method) }; + }, + SHOW_HELP: { target: 'help', actions: forward }, + BACK: { target: '#factor', actions: backward }, + }, + }, + preparing: { + entry: lockResend, + invoke: { + src: 'prepareFactor', + input: ctx => ctx.pendingMethod, + onDone: { target: '#factor', actions: assign(ctx => activate(ctx.pendingMethod)) }, + onError: { + target: '#factor', + actions: [assign(ctx => activate(ctx.pendingMethod)), showError], + }, + }, + }, + help: { + on: { BACK: { target: 'list', actions: backward } }, + }, + }, + }, + + unavailable: {}, + + finishing: { + invoke: { + src: 'finishVerification', + onDone: 'verified', + onError: { target: '#factor', actions: showError }, + }, + }, + + verified: { type: 'final' }, + }, +}); diff --git a/packages/mosaic/src/features/reverification/reverification.tsx b/packages/mosaic/src/features/reverification/reverification.tsx index ab75fff6c80..09f0c9a031d 100644 --- a/packages/mosaic/src/features/reverification/reverification.tsx +++ b/packages/mosaic/src/features/reverification/reverification.tsx @@ -1,37 +1,113 @@ -import { type ReverificationController, useReverificationController } from './reverification.controller'; -import { useReverificationModel } from './reverification.model'; -import { ReverificationPending, ReverificationUnavailable, ReverificationView } from './reverification.view'; +import { useEffect, useState } from 'react'; + +import { useMosaicSupportEmail } from '../../hooks/useMosaicSupportEmail'; +import { childActor } from '../../machine/createActor'; +import type { Actor, AnyActor, Snapshot } from '../../machine/types'; +import { useActor } from '../../machine/useMachine'; import { - type ReverificationFetcher, - useReverificationWithState, - type UseReverificationWithStateResult, -} from './use-reverification-with-state'; - -export type UseReverificationFlowResult = readonly [ - UseReverificationWithStateResult[0], - ReverificationController, -]; - -export function useReverificationFlow( - fetcher: F, -): UseReverificationFlowResult { - const [wrappedFetcher, reverificationState] = useReverificationWithState(fetcher); - const model = useReverificationModel(reverificationState); - const controller = useReverificationController(model); - - return [wrappedFetcher, controller]; + RESEND_COOLDOWN_MS, + type ReverificationContext, + type ReverificationEvent, + reverificationMachine, +} from './reverification.machine'; +import type { ReverificationMethod, ReverificationStep } from './reverification.types'; +import { otpChannelFor } from './reverification.utils'; +import { ReverificationPending, ReverificationUnavailable, ReverificationView } from './reverification.view'; + +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'; } -export function Reverification(controller: ReverificationController) { - if (controller.status === 'idle') { - return null; +function viewStep(snapshot: Snapshot): ReverificationStep | undefined { + if (snapshot.matches('factor.help') || snapshot.matches('methods.help')) { + return 'help'; + } + if (snapshot.matches('methods')) { + return 'method-picker'; } + const method = snapshot.context.activeMethod; + return method ? factorStep(method) : undefined; +} - if (controller.status === 'loading') { - return ; - } else if (controller.status === 'unavailable') { +function useResendCooldown(resendAvailableAt: number | undefined) { + const [now, setNow] = useState(() => Date.now()); + const canResend = resendAvailableAt === undefined || now >= resendAvailableAt; + + useEffect(() => { + if (canResend) { + return; + } + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [canResend, resendAvailableAt]); + + return { + canResend, + resendRemainingSeconds: + !canResend && resendAvailableAt + ? Math.min(RESEND_COOLDOWN_MS / 1000, Math.max(0, Math.ceil((resendAvailableAt - now) / 1000))) + : undefined, + }; +} + +function ReverificationChallenge({ actor }: { actor: Actor }) { + const [snapshot, send] = useActor(actor); + const supportEmail = useMosaicSupportEmail(); + const { context } = snapshot; + const resend = useResendCooldown(context.resendAvailableAt); + + if (snapshot.matches('unavailable')) { return ; } - return ; + const step = viewStep(snapshot); + if (!step) { + return ; + } + + const activeMethod = context.activeMethod; + + return ( + send({ type: 'TYPE', value })} + errorMessage={context.errorMessage} + isPending={snapshot.matches('factor.submitting') || snapshot.matches('finishing') || snapshot.matches('verified')} + onSubmit={() => send({ type: 'SUBMIT' })} + onShowMethods={() => send({ type: 'SHOW_METHODS' })} + onShowHelp={() => send({ type: 'SHOW_HELP' })} + onBack={() => send({ type: 'BACK' })} + onEmailSupport={() => { + if (supportEmail) { + window.location.assign(`mailto:${supportEmail}`); + } + }} + methods={context.methods.filter(method => method.id !== activeMethod?.id)} + pendingMethodId={snapshot.matches('methods.preparing') ? context.pendingMethod?.id : undefined} + onSelectMethod={id => send({ type: 'SELECT_METHOD', id })} + otpChannel={activeMethod ? otpChannelFor(activeMethod.strategy) : undefined} + onResend={() => send({ type: 'RESEND' })} + canResend={resend.canResend} + resendRemainingSeconds={resend.resendRemainingSeconds} + /> + ); +} + +export function Reverification({ actor }: { actor: AnyActor | undefined }) { + const challenge = childActor(actor, reverificationMachine); + if (!challenge) { + return null; + } + return ; } diff --git a/packages/mosaic/src/features/reverification/reverification.types.ts b/packages/mosaic/src/features/reverification/reverification.types.ts index 3fcaa6b43f2..7a3cc9d50e3 100644 --- a/packages/mosaic/src/features/reverification/reverification.types.ts +++ b/packages/mosaic/src/features/reverification/reverification.types.ts @@ -1,5 +1,3 @@ -import type { SessionVerificationLevel } from '@clerk/shared/types'; - import type { FlowDirection } from '../../components/flow'; export type ReverificationStrategy = 'password' | 'passkey' | 'email_code' | 'phone_code' | 'totp' | 'backup_code'; @@ -42,11 +40,6 @@ 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'; methods: readonly ReverificationMethod[]; diff --git a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts deleted file mode 100644 index 41daad8e368..00000000000 --- a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { ClerkRuntimeError } from '@clerk/shared/error'; -import { useReverification, useSafeLayoutEffect, useSession } from '@clerk/shared/react'; -import { useCallback, useEffect, useRef, useState } from 'react'; - -import type { ReverificationState } from './reverification.types'; - -export type ReverificationFetcher = (...args: any[]) => Promise | undefined; - -export type UseReverificationWithStateResult = readonly [ - ReturnType>, - 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, - }); -} - -/** - * Wraps useReverification without the default UI. Returns [handler, state]: call handler - * to run the action, and render your own UI from state.phase ('inactive' | 'active' | - * 'retrying'). While 'active', state.complete and state.cancel end the challenge. - * - * The handler runs one call at a time. A call made while another is pending rejects with a - * ClerkRuntimeError with code 'request_already_in_progress'. - */ -// The reason we need to enforce single-flight is that we need a direct link between -// a single invocation of the handler and a specific reverification. useReverification -// 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, -): UseReverificationWithStateResult { - const { session } = useSession(); - // 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, { - onNeedsReverification: ({ complete, cancel, level }) => { - const operation = runtimeRef.current.operation; - if (operation.status !== 'requesting') { - // This can happen e.g. when the component unmounts mid-flight - cancel(); - return; - } - - runtimeRef.current.operation = { - status: 'active', - promise: operation.promise, - sessionId: runtimeRef.current.sessionId, - complete, - cancel, - }; - - setReverificationState({ - phase: 'active', - level, - complete: completeChallenge, - cancel: cancelChallenge, - }); - }, - }); - - 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 (phase !== 'active') { - return; - } - const operation = runtimeRef.current.operation; - if (operation.status !== 'active') { - return; - } - // Do not reset on the transitive state - if (session === undefined) { - return; - } - if (session === null) { - cancelChallenge(); - return; - } - // 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 !== operation.sessionId) { - cancelChallenge(); - } - }, [phase, session, cancelChallenge]); - - // Cancel on unmount - Does not cancel ongoing retry after reverification has finished - useEffect(() => { - const runtime = runtimeRef.current; - return () => { - const operation = runtime.operation; - runtime.operation = { status: 'idle' }; - if (operation.status === 'active') { - operation.cancel(); - } - }; - }, []); - - return [singleFlight, reverificationState]; -} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx index b55fda0f5cc..b7695a5ec51 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx @@ -1,3 +1,4 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; import type * as SharedReact from '@clerk/shared/react'; import { createDeferredPromise } from '@clerk/shared/utils'; import { render, screen, waitFor, within } from '@testing-library/react'; @@ -12,6 +13,17 @@ let deleteSelfEnabled: boolean; let signedInSessions: { user?: { id: string } }[]; let deleteUser: ReturnType; let setActive: ReturnType; +let session: { + id: string; + startVerification: ReturnType; + attemptFirstFactorVerification: ReturnType; +}; + +const needsReverification = () => + new ClerkAPIResponseError('reverify', { + data: [{ code: 'session_reverification_required', message: 'Reverify', long_message: 'Reverify' }], + status: 403, + }); vi.mock('@clerk/shared/react', async importOriginal => { const actual = await importOriginal(); @@ -22,7 +34,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { isSignedIn: isLoaded, user: isLoaded ? { id: 'user_1', deleteSelfEnabled, delete: deleteUser } : undefined, }), - useSession: () => ({ session: { id: 'sess_1' } }), + useSession: () => ({ session }), useClerk: () => ({ setActive, client: { signedInSessions }, @@ -30,7 +42,6 @@ vi.mock('@clerk/shared/react', async importOriginal => { buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left', __internal_getOption: () => undefined, }), - useReverification: (fetcher: () => Promise) => fetcher, }; }); @@ -54,6 +65,17 @@ describe('UserProfileDeleteSection', () => { signedInSessions = []; deleteUser = vi.fn(() => Promise.resolve()); setActive = vi.fn(() => Promise.resolve()); + session = { + id: 'sess_1', + startVerification: vi.fn(() => + Promise.resolve({ + status: 'needs_first_factor', + supportedFirstFactors: [{ strategy: 'password' }], + supportedSecondFactors: null, + }), + ), + attemptFirstFactorVerification: vi.fn(() => Promise.resolve({ status: 'complete' })), + }; }); it('renders the fallback until the user has loaded', () => { @@ -162,4 +184,42 @@ describe('UserProfileDeleteSection', () => { const reopened = await openDialog(user); expect(within(reopened).getByRole('textbox')).toHaveValue(''); }); + + it('reverifies inside the dialog, then retries the delete and signs out', async () => { + deleteUser = vi.fn().mockRejectedValueOnce(needsReverification()).mockResolvedValueOnce(undefined); + 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' })); + + const password = await within(dialog).findByLabelText('Password'); + expect(session.startVerification).toHaveBeenCalledWith({ level: 'second_factor' }); + await user.type(password, 'hunter2'); + await user.click(within(dialog).getByRole('button', { name: 'Continue' })); + + await waitFor(() => expect(deleteUser).toHaveBeenCalledTimes(2)); + expect(session.attemptFirstFactorVerification).toHaveBeenCalledWith({ strategy: 'password', password: 'hunter2' }); + expect(setActive).toHaveBeenNthCalledWith(1, { session: 'sess_1' }); + await waitFor(() => expect(setActive).toHaveBeenLastCalledWith({ session: null, redirectUrl: '/signed-out' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('closes the reverification without retrying when cancelled', async () => { + deleteUser = vi.fn(() => Promise.reject(needsReverification())); + 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 within(dialog).findByLabelText('Password'); + + await user.keyboard('{Escape}'); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(deleteUser).toHaveBeenCalledOnce(); + expect(setActive).not.toHaveBeenCalled(); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx index 534eb8af7c7..ceb017a3ea1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.tsx @@ -1,7 +1,7 @@ import { useClerk, useUser } from '@clerk/shared/react'; import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; -import { Reverification, useReverificationFlow } from '../../reverification'; +import { Reverification, useReverificationActors } from '../../reverification'; import { UserProfileDeleteSectionView } from './user-profile-delete-section.view'; export type UserProfileDeleteSectionProps = { @@ -36,10 +36,9 @@ export function UserProfileDeleteSection(props: UserProfileDeleteSectionProps) { }; // -- Controllers -- - const [deleteAccountWithReverification, reverification] = useReverificationFlow(deleteAccount); const destructiveController = useDestructiveController({ - onDelete: deleteAccountWithReverification, - reverification, + onDelete: deleteAccount, + reverification: useReverificationActors(), }); // -- View -- @@ -54,7 +53,7 @@ export function UserProfileDeleteSection(props: UserProfileDeleteSectionProps) { return ( } + verificationSlot={} /> ); } diff --git a/packages/mosaic/src/machine/__tests__/hierarchy.test.ts b/packages/mosaic/src/machine/__tests__/hierarchy.test.ts new file mode 100644 index 00000000000..78ef08fd4f2 --- /dev/null +++ b/packages/mosaic/src/machine/__tests__/hierarchy.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { childActor, createActor } from '../createActor'; +import { createMachine } from '../createMachine'; +import { stateIn } from '../guards'; + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +function deferred() { + let resolve: (value: T) => void = () => {}; + let reject: (reason?: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +type Evt = { type: 'NEXT' } | { type: 'CLOSE' } | { type: 'BACK' } | { type: 'JUMP' }; + +describe('nested states', () => { + it('enters the initial leaf and exposes it as a dotted value', () => { + const machine = createMachine({ + initial: 'open', + states: { open: { initial: 'confirming', states: { confirming: {}, running: {} } } }, + }); + + const snapshot = createActor(machine).start().getSnapshot(); + + expect(snapshot.value).toBe('open.confirming'); + expect(snapshot.matches('open')).toBe(true); + expect(snapshot.matches('open.confirming')).toBe(true); + expect(snapshot.matches('open.running')).toBe(false); + expect(snapshot.matches('ope')).toBe(false); + }); + + it('bubbles unhandled events to the parent and lets children override them', () => { + const machine = createMachine({ + initial: 'open', + states: { + closed: {}, + open: { + initial: 'confirming', + on: { CLOSE: 'closed' }, + states: { + confirming: { on: { NEXT: 'running' } }, + running: { on: { CLOSE: {} } }, + }, + }, + }, + }); + const actor = createActor(machine).start(); + + actor.send({ type: 'NEXT' }); + actor.send({ type: 'CLOSE' }); + expect(actor.getSnapshot().value).toBe('open.running'); + + const other = createActor(machine).start(); + other.send({ type: 'CLOSE' }); + expect(other.getSnapshot().value).toBe('closed'); + }); + + it('resolves sibling, child and #id targets', () => { + const machine = createMachine({ + id: 'root', + initial: 'a', + states: { + a: { + initial: 'one', + on: { JUMP: '.two' }, + states: { + one: { on: { NEXT: 'two' } }, + two: { on: { BACK: '#root.b' } }, + }, + }, + b: { on: { NEXT: 'a.two' } }, + }, + }); + const actor = createActor(machine).start(); + + actor.send({ type: 'NEXT' }); + expect(actor.getSnapshot().value).toBe('a.two'); + actor.send({ type: 'BACK' }); + expect(actor.getSnapshot().value).toBe('b'); + actor.send({ type: 'NEXT' }); + expect(actor.getSnapshot().value).toBe('a.two'); + + const jumper = createActor(machine).start(); + jumper.send({ type: 'JUMP' }); + expect(jumper.getSnapshot().value).toBe('a.two'); + }); + + it('exits up to the shared parent and enters down to the target leaf', () => { + const log: string[] = []; + const track = (name: string) => ({ entry: () => log.push(`enter ${name}`), exit: () => log.push(`exit ${name}`) }); + const machine = createMachine({ + initial: 'a', + states: { + a: { + ...track('a'), + initial: 'one', + states: { one: { ...track('a.one'), on: { NEXT: 'two', JUMP: '#b' } }, two: track('a.two') }, + }, + b: { id: 'b', ...track('b'), initial: 'x', states: { x: track('b.x') } }, + }, + }); + const actor = createActor(machine).start(); + log.length = 0; + + actor.send({ type: 'NEXT' }); + expect(log).toEqual(['exit a.one', 'enter a.two']); + + const other = createActor(machine).start(); + log.length = 0; + other.send({ type: 'JUMP' }); + expect(log).toEqual(['exit a.one', 'exit a', 'enter b', 'enter b.x']); + }); + + it('keeps a parent invoke running across child transitions', async () => { + const work = deferred(); + const src = vi.fn(() => work.promise); + const machine = createMachine({ + initial: 'busy', + states: { + busy: { + initial: 'one', + invoke: { src, onDone: 'done' }, + states: { one: { on: { NEXT: 'two' } }, two: {} }, + }, + done: {}, + }, + }); + const actor = createActor(machine).start(); + + actor.send({ type: 'NEXT' }); + work.resolve('ok'); + await tick(); + + expect(src).toHaveBeenCalledTimes(1); + expect(actor.getSnapshot().value).toBe('done'); + }); +}); + +describe('invoking a child machine', () => { + type ChildCtx = { level: string; code: string }; + const child = createMachine({ + id: 'child', + initial: 'waiting', + context: { level: '', code: '' }, + states: { + waiting: { + on: { + SUBMIT: ({ event }) => ({ target: 'verified', context: { code: event.code } }), + }, + }, + verified: { type: 'final' }, + }, + }); + + const parent = createMachine<{ level: string; output: unknown }, Evt>({ + initial: 'idle', + context: { level: 'strict', output: undefined }, + states: { + idle: { on: { NEXT: 'verifying' } }, + verifying: { + on: { CLOSE: 'idle' }, + invoke: { + id: 'verify', + src: 'verifier', + input: ctx => ({ level: ctx.level }), + onDone: ({ event }) => ({ target: 'done', context: { output: event.output } }), + }, + }, + done: {}, + }, + }); + + it('starts the child with input and exposes it on snapshot.children', () => { + const actor = createActor(parent, { actors: { verifier: child } }).start(); + expect(actor.getSnapshot().children.verify).toBeUndefined(); + + actor.send({ type: 'NEXT' }); + const verify = childActor(actor.getSnapshot().children.verify, child); + + expect(verify?.getSnapshot().value).toBe('waiting'); + expect(verify?.getSnapshot().context.level).toBe('strict'); + }); + + it('takes onDone with the child context once the child reaches a final state', async () => { + const actor = createActor(parent, { actors: { verifier: child } }).start(); + actor.send({ type: 'NEXT' }); + + childActor(actor.getSnapshot().children.verify, child)?.send({ type: 'SUBMIT', code: '123' }); + await tick(); + + expect(actor.getSnapshot().value).toBe('done'); + expect(actor.getSnapshot().context.output).toEqual({ level: 'strict', code: '123' }); + expect(actor.getSnapshot().children.verify).toBeUndefined(); + }); + + it('stops the child when the invoking state is exited', () => { + const actor = createActor(parent, { actors: { verifier: child } }).start(); + actor.send({ type: 'NEXT' }); + const verify = childActor(actor.getSnapshot().children.verify, child); + + actor.send({ type: 'CLOSE' }); + + expect(verify?.getSnapshot().status).toBe('stopped'); + expect(actor.getSnapshot().children.verify).toBeUndefined(); + }); + + it('shares provided actors with the child', async () => { + const check = vi.fn((input: unknown) => Promise.resolve(input)); + const grandchild = createMachine<{ level: string }, Evt>({ + initial: 'checking', + context: { level: '' }, + states: { + checking: { invoke: { src: 'check', input: ctx => ctx.level, onDone: 'ok' } }, + ok: { type: 'final' }, + }, + }); + const actor = createActor(parent, { actors: { verifier: grandchild, check } }).start(); + + actor.send({ type: 'NEXT' }); + await tick(); + await tick(); + + expect(check).toHaveBeenCalledWith('strict'); + expect(actor.getSnapshot().value).toBe('done'); + }); + + it('returns undefined from childActor for a different machine', () => { + const actor = createActor(parent, { actors: { verifier: child } }).start(); + actor.send({ type: 'NEXT' }); + + expect(childActor(actor.getSnapshot().children.verify, parent)).toBeUndefined(); + }); +}); + +describe('stateIn', () => { + it('lets a parent transition branch on the active child', async () => { + const work = deferred(); + const machine = createMachine({ + initial: 'preparing', + states: { + preparing: { + initial: 'idle', + invoke: { + src: () => work.promise, + onDone: [{ target: 'submitting', guard: stateIn('preparing.queued') }, { target: 'ready' }], + }, + states: { idle: { on: { NEXT: 'queued' } }, queued: {} }, + }, + submitting: {}, + ready: {}, + }, + }); + const queued = createActor(machine).start(); + const plain = createActor(machine).start(); + + queued.send({ type: 'NEXT' }); + work.resolve(); + await tick(); + + expect(queued.getSnapshot().value).toBe('submitting'); + expect(plain.getSnapshot().value).toBe('ready'); + }); +}); diff --git a/packages/mosaic/src/machine/__tests__/useMachine.test.tsx b/packages/mosaic/src/machine/__tests__/useMachine.test.tsx index a994710ad4a..e8bf8d4c38a 100644 --- a/packages/mosaic/src/machine/__tests__/useMachine.test.tsx +++ b/packages/mosaic/src/machine/__tests__/useMachine.test.tsx @@ -53,6 +53,32 @@ describe('useMachine — drives a flow from a component', () => { expect(screen.getByTestId('state')).toHaveTextContent('deleted'); }); + it('invokes the latest provided actor after a re-render', async () => { + const machine = createMachine({ + initial: 'idle', + states: { + idle: { on: { RUN: 'running' } }, + running: { invoke: { src: 'work', onDone: 'idle' } }, + }, + }); + const first = vi.fn(() => Promise.resolve()); + const second = vi.fn(() => Promise.resolve()); + + function Runner({ work }: { work: () => Promise }) { + const [, send] = useMachine(machine, { actors: { work } }); + return ; + } + + const { rerender } = render(); + rerender(); + await act(async () => { + fireEvent.click(screen.getByText('Run')); + }); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + it('exposes the actor so components can ask whether a transition is available', () => { function DeleteOrg() { const [, send, actor] = useMachine(createDeleteOrgMachine(() => Promise.resolve())); diff --git a/packages/mosaic/src/machine/createActor.ts b/packages/mosaic/src/machine/createActor.ts index 1b577839796..d86eb0cdeb2 100644 --- a/packages/mosaic/src/machine/createActor.ts +++ b/packages/mosaic/src/machine/createActor.ts @@ -3,14 +3,21 @@ import type { Actions, Actor, AfterEvent, + AnyActor, AnyEventObject, + AnyStateMachine, AssignAction, CreateActorOptions, + DoneInvokeEvent, + ErrorInvokeEvent, EventObject, + GuardMeta, + InvokeConfig, Snapshot, SnapshotListener, StateConfig, StateMachine, + Transition, TransitionConfig, TransitionFn, Unsubscribe, @@ -22,6 +29,54 @@ const INIT_EVENT: AnyEventObject = { type: INIT }; // Collapse toArray into single helper: returns empty array, original if array, or wrapped value const toArr = (v: T | T[] | undefined): T[] => (!v ? [] : Array.isArray(v) ? v : [v]); +interface StateNode { + path: string; + parent: StateNode | undefined; + config: StateConfig; + children: Record>; +} + +interface Activity { + timers: ReturnType[]; + childId: string | undefined; +} + +const actorMachines = new WeakMap(); + +function isMachine(src: unknown): src is AnyStateMachine { + return typeof src === 'object' && src !== null && 'states' in src; +} + +function matchesValue(value: string, path: string): boolean { + return value === path || value.startsWith(`${path}.`); +} + +function isAncestor(ancestor: StateNode, node: StateNode): boolean { + for (let current = node.parent; current; current = current.parent) { + if (current === ancestor) { + return true; + } + } + return false; +} + +function descend(node: StateNode | undefined, path: string): StateNode | undefined { + if (path === '') { + return node; + } + return path.split('.').reduce | undefined>((current, key) => current?.children[key], node); +} + +export function childActor( + child: AnyActor | undefined, + machine: StateMachine, +): Actor | undefined { + if (child === undefined || actorMachines.get(child) !== machine) { + return undefined; + } + return child as unknown as Actor; +} + /** * Wrap a machine definition in a running instance (an "actor"). * @@ -41,37 +96,96 @@ export function createActor options: CreateActorOptions = {}, ): Actor { const teleport = options.snapshot; - + const actors = options.actors ?? {}; + + const ids = new Map>(); + function buildNode( + config: StateConfig, + path: string, + parent: StateNode | undefined, + ): StateNode { + const node: StateNode = { path, parent, config, children: {} }; + if (config.id !== undefined) { + ids.set(config.id, node); + } + for (const [key, child] of Object.entries(config.states ?? {})) { + node.children[key] = buildNode(child, path === '' ? key : `${path}.${key}`, node); + } + return node; + } // Internally the actor operates on the broader `EventObject`: invoke done/error // events aren't part of the user's `TEvent` union, so the config is viewed // through an event-agnostic lens to keep the runtime helpers honestly typed. - const states = machine.states as unknown as Record>; + const root = buildNode( + { states: machine.states as unknown as Record> }, + '', + undefined, + ); + if (machine.id !== undefined) { + ids.set(machine.id, root); + } // Tracks the latest setContext patch so it survives a stop/start cycle. let liveContextPatch: Partial = options.context ?? {}; let context: TContext = { ...machine.context, ...liveContextPatch, ...teleport?.context }; // `initial` may be derived from context (e.g. furthest-reachable step). const resolveInitial = () => (typeof machine.initial === 'function' ? machine.initial(context) : machine.initial); - let value = teleport?.value ?? resolveInitial(); + + function initialLeaf(node: StateNode): StateNode | undefined { + const self = node === root ? undefined : node; + if (Object.keys(node.children).length === 0) { + return self; + } + const key = node === root ? resolveInitial() : node.config.initial; + const next = key === undefined ? undefined : node.children[key]; + return next ? initialLeaf(next) : self; + } + + let leaf = teleport ? descend(root, teleport.value) : initialLeaf(root); + leaf = leaf && initialLeaf(leaf); + let fallbackValue = teleport?.value ?? resolveInitial(); // A teleported actor is already "started" and inert: start() must not re-run // entry/always/invoke for the state it was dropped into. let started = teleport !== undefined; - let status: Snapshot['status'] = states[value]?.type === 'final' ? 'done' : 'active'; + let status: Snapshot['status'] = leaf?.config.type === 'final' ? 'done' : 'active'; - // Bumped whenever we leave an invoking state (or stop), so a stale promise - // resolving after the fact is ignored — no transition, no setState-after-stop. - let invocationToken = 0; + // Invokes and `after` timers of every active state node. Leaving a node (or + // stopping) drops its entry, so a stale promise or child resolving after the + // fact is ignored — no transition, no setState-after-stop. + const activities = new Map, Activity>(); + let children: Record = {}; - // Pending `after` timer IDs — cleared when the state is exited or the actor stops. - let afterTimers: ReturnType[] = []; + const currentValue = () => leaf?.path ?? fallbackValue; + const guardMeta: GuardMeta = { matches: path => matchesValue(currentValue(), path) }; + + function makeSnapshot(): Snapshot { + const value = currentValue(); + return { value, context, status, children, matches: path => matchesValue(value, path) }; + } // The snapshot is cached and only replaced on an actual change, so // getSnapshot() is referentially stable for useSyncExternalStore. - let snapshot: Snapshot = { value, context, status }; + let snapshot = makeSnapshot(); const listeners: SnapshotListener[] = []; + function activeNodes(): StateNode[] { + const nodes: StateNode[] = []; + for (let node = leaf; node && node !== root; node = node.parent) { + nodes.unshift(node); + } + return nodes; + } + + function pathTo(node: StateNode): StateNode[] { + const nodes: StateNode[] = []; + for (let current: StateNode | undefined = node; current && current !== root; current = current.parent) { + nodes.unshift(current); + } + return nodes; + } + /** * Normalise a raw `Transition` value into a `TransitionConfig[]` the runtime * can process uniformly. Handles all four arms of the `Transition` union: @@ -117,135 +231,238 @@ export function createActor transitions: TransitionConfig[], event: EventObject, ): TransitionConfig | undefined { - return transitions.find(transition => !transition.guard || transition.guard(context, event)); + return transitions.find(transition => !transition.guard || transition.guard(context, event, guardMeta)); } - /** Whether a target state's entry guard currently permits landing on it. */ - function canEnter(stateId: string, event: EventObject): boolean { - const guard = states[stateId]?.guard; - return !guard || guard(context, event); + /** Whether a state's entry guard currently permits landing on it. */ + function canEnter(node: StateNode, event: EventObject): boolean { + const guard = node.config.guard; + return !guard || guard(context, event, guardMeta); + } + + function resolveTarget(source: StateNode, target: string): StateNode | undefined { + if (target.startsWith('#')) { + const [id = '', ...rest] = target.slice(1).split('.'); + return descend(ids.get(id), rest.join('.')); + } + if (target.startsWith('.')) { + return descend(source, target.slice(1)); + } + return descend(source.parent ?? root, target); + } + + function plan( + source: StateNode, + targetPath: string, + event: EventObject, + ): { nextLeaf: StateNode; exiting: StateNode[]; entering: StateNode[] } | undefined { + const target = resolveTarget(source, targetPath); + const nextLeaf = target && initialLeaf(target); + if (!target || !nextLeaf) { + throw new Error(`Unknown transition target "${targetPath}" from "${source.path}"`); + } + let domain = isAncestor(source, target) ? source : (source.parent ?? root); + while (domain !== root && domain !== source && !isAncestor(domain, target)) { + domain = domain.parent ?? root; + } + const exiting = activeNodes() + .filter(node => domain === root || isAncestor(domain, node)) + .reverse(); + const entering = pathTo(nextLeaf).filter(node => domain === root || isAncestor(domain, node)); + if (!entering.every(node => canEnter(node, event))) { + return undefined; + } + return { nextLeaf, exiting, entering }; } /** - * Run a chosen transition: exit (if external) → actions → enter target. - * Returns `false` — a true no-op — when the target's entry guard blocks it, so - * the caller skips the commit and subscribers are never notified. + * Run a chosen transition: exit → actions → enter target. + * Returns `false` — a true no-op — when an entry guard blocks it, so the + * caller skips the commit and subscribers are never notified. */ - function takeTransition(transition: TransitionConfig, event: EventObject): boolean { - const external = transition.target !== undefined; - if (external && !canEnter(transition.target as string, event)) { - return false; // entry guard blocks landing → snapshot unchanged, no notify + function takeTransition( + transition: TransitionConfig, + event: EventObject, + source: StateNode, + ): boolean { + if (transition.target === undefined) { + runActions(transition.actions, event); + return true; } - if (external) { - runActions(states[value].exit, event); - invocationToken++; // abandon the invoke of the state we're leaving - clearAfterTimers(); + const steps = plan(source, transition.target, event); + if (!steps) { + return false; + } + for (const node of steps.exiting) { + runActions(node.config.exit, event); + stopActivity(node); } runActions(transition.actions, event); - if (external) { - value = transition.target as string; - enterState(event); + leaf = steps.nextLeaf; + for (const node of steps.entering) { + runActions(node.config.entry, event); } + settle(event); return true; } - function startInvoke(event: EventObject): void { - const invoke = states[value].invoke; + function findTransition( + event: EventObject, + ): { transition: TransitionConfig; source: StateNode } | undefined { + for (const source of activeNodes().reverse()) { + const transition = pickTransition(normalizeTransition(source.config.on?.[event.type], event), event); + if (transition) { + return { transition, source }; + } + } + return undefined; + } + + function takeAlways(event: EventObject): boolean { + for (const source of activeNodes().reverse()) { + const immediate = pickTransition(normalizeTransition(source.config.always, event), event); + if (immediate && immediate.target !== undefined && takeTransition(immediate, event, source)) { + return true; + } + } + return false; + } + + function settle(event: EventObject): void { + if (leaf?.config.type === 'final' && leaf.parent === root) { + status = 'done'; + stopAllActivities(); + return; + } + if (takeAlways(event)) { + return; + } + for (const node of activeNodes()) { + if (!activities.has(node)) { + const activity: Activity = { timers: [], childId: undefined }; + activities.set(node, activity); + startInvoke(node, activity, event); + startAfterTimers(node, activity); + } + } + } + + function fire(node: StateNode, activity: Activity, raw: unknown, event: EventObject): void { + if (status !== 'active' || activities.get(node) !== activity) { + return; + } + const transition = pickTransition(normalizeTransition(raw, event), event); + if (transition && takeTransition(transition, event, node)) { + commit(); + } + } + + function startInvoke(node: StateNode, activity: Activity, event: EventObject): void { + const invoke = node.config.invoke; if (!invoke) { return; } - const token = ++invocationToken; - // SAFETY: startInvoke is called with the actor's internal EventObject, but + if (typeof invoke.src === 'string') { + const provided = actors[invoke.src]; + if (provided === undefined) { + throw new Error(`Unknown actor "${invoke.src}" invoked from "${node.path}"`); + } + if (isMachine(provided)) { + startChild(node, activity, invoke, provided); + } else { + settlePromise(node, activity, invoke, provided(invoke.input?.(context))); + } + return; + } + if (isMachine(invoke.src)) { + startChild(node, activity, invoke, invoke.src); + return; + } + // SAFETY: an inline src receives the actor's internal EventObject, but // InvokeConfig.src is typed to accept (context, TEvent | DoneInvokeEvent | ErrorInvokeEvent). - // The cast suppresses that mismatch; src implementations receive the INIT event - // on state entry and typically ignore it. The runtime views events through an - // event-agnostic lens (line 57) for this reason. - Promise.resolve(invoke.src(context, event as never)).then( + // src implementations receive the INIT event on state entry and typically ignore it. + settlePromise(node, activity, invoke, invoke.src(context, event as never)); + } + + function settlePromise( + node: StateNode, + activity: Activity, + invoke: InvokeConfig, + promise: Promise, + ): void { + Promise.resolve(promise).then( output => { - if (status !== 'active' || token !== invocationToken) { - return; - } - const doneEvent = { type: INVOKE_DONE, output }; - const transition = pickTransition(normalizeTransition(invoke.onDone, doneEvent), doneEvent); - if (!transition) { - return; - } - if (takeTransition(transition, doneEvent)) { - commit(); - } + const done: DoneInvokeEvent = { type: INVOKE_DONE, output }; + fire(node, activity, invoke.onDone, done); }, (error: unknown) => { - if (status !== 'active' || token !== invocationToken) { - return; - } - const errorEvent = { type: INVOKE_ERROR, error }; - const transition = pickTransition(normalizeTransition(invoke.onError, errorEvent), errorEvent); - if (!transition) { - return; - } - if (takeTransition(transition, errorEvent)) { - commit(); - } + const failed: ErrorInvokeEvent = { type: INVOKE_ERROR, error }; + fire(node, activity, invoke.onError, failed); }, ); } - function clearAfterTimers(): void { - for (const id of afterTimers) { - clearTimeout(id); - } - afterTimers = []; + function startChild( + node: StateNode, + activity: Activity, + invoke: InvokeConfig, + logic: AnyStateMachine, + ): void { + const id = invoke.id ?? node.path; + const input = invoke.input?.(context); + const child = createActor(logic, { context: typeof input === 'object' && input !== null ? input : {}, actors }); + activity.childId = id; + children = { ...children, [id]: child }; + child.subscribe(childSnapshot => { + if (childSnapshot.status === 'done') { + const done: DoneInvokeEvent = { type: INVOKE_DONE, output: childSnapshot.context }; + queueMicrotask(() => fire(node, activity, invoke.onDone, done)); + } + }); + child.start(); } - function startAfterTimers(): void { - const afterConfig = states[value].after; + function startAfterTimers(node: StateNode, activity: Activity): void { + const afterConfig = node.config.after; if (!afterConfig) { return; } - for (const [delayStr, raw] of Object.entries(afterConfig)) { + for (const [delayStr, raw] of Object.entries>(afterConfig)) { const delay = Number(delayStr); const id = setTimeout(() => { - afterTimers = afterTimers.filter(t => t !== id); - if (status !== 'active') { - return; - } + activity.timers = activity.timers.filter(t => t !== id); const afterEvent: AfterEvent = { type: AFTER, delay }; - const transition = pickTransition(normalizeTransition(raw, afterEvent), afterEvent); - if (!transition) { - return; - } - if (takeTransition(transition, afterEvent)) { - commit(); - } + fire(node, activity, raw, afterEvent); }, delay); - afterTimers.push(id); + activity.timers.push(id); } } - /** Entry side of a state: entry actions, then immediate/invoke resolution. */ - function enterState(event: EventObject): void { - const stateConfig = states[value]; - if (!stateConfig) { - return; - } // degenerate graph (e.g. empty wizard) — nothing to enter - runActions(stateConfig.entry, event); - - if (stateConfig.type === 'final') { - status = 'done'; + function stopActivity(node: StateNode): void { + const activity = activities.get(node); + if (!activity) { return; } - - const immediate = pickTransition(normalizeTransition(stateConfig.always, event), event); - if (immediate && immediate.target !== undefined && takeTransition(immediate, event)) { - return; + activities.delete(node); + for (const id of activity.timers) { + clearTimeout(id); + } + const { childId } = activity; + if (childId !== undefined) { + const { [childId]: child, ...rest } = children; + children = rest; + child?.stop(); } + } - startInvoke(event); - startAfterTimers(); + function stopAllActivities(): void { + for (const node of [...activities.keys()]) { + stopActivity(node); + } } function commit(): void { - snapshot = { value, context, status }; + snapshot = makeSnapshot(); for (let i = listeners.length; i--; ) { listeners[i](snapshot); } @@ -261,8 +478,12 @@ export function createActor // Reset state and context so a restart (e.g. after StrictMode stop/start) // begins from idle rather than re-entering and re-invoking a mid-flight state. context = { ...machine.context, ...liveContextPatch }; - value = resolveInitial(); - enterState(INIT_EVENT); + leaf = initialLeaf(root); + fallbackValue = resolveInitial(); + for (const node of activeNodes()) { + runActions(node.config.entry, INIT_EVENT); + } + settle(INIT_EVENT); commit(); return actor; }, @@ -273,9 +494,8 @@ export function createActor } started = false; // allow restart (e.g. StrictMode effect cleanup + remount) status = 'stopped'; - invocationToken++; // abandon any in-flight invoke - clearAfterTimers(); - snapshot = { value, context, status }; + stopAllActivities(); + snapshot = makeSnapshot(); for (let i = listeners.length; i--; ) { listeners[i](snapshot); } @@ -286,11 +506,11 @@ export function createActor if (!started || status !== 'active') { return; } - const transition = pickTransition(normalizeTransition(states[value]?.on?.[event.type], event), event); - if (!transition) { + const found = findTransition(event); + if (!found) { return; } // event not handled in this state → ignored - if (takeTransition(transition, event)) { + if (takeTransition(found.transition, event, found.source)) { commit(); } // entry-blocked → no commit, no notify }, @@ -318,11 +538,12 @@ export function createActor if (!started || status !== 'active') { return false; } - const transition = pickTransition(normalizeTransition(states[value]?.on?.[event.type], event), event); - if (!transition) { + const found = findTransition(event); + if (!found) { return false; } - return transition.target === undefined || canEnter(transition.target, event); + const { transition, source } = found; + return transition.target === undefined || plan(source, transition.target, event) !== undefined; }, setContext(patch: Partial) { @@ -341,26 +562,31 @@ export function createActor // resolved initial state — the same derivation used on start (e.g. the // Wizard's furthest-reachable step). `resolveInitial` always lands on an // enterable step, so this is provably one-shot and cannot loop. - if (!canEnter(value, event)) { - const reseated = resolveInitial(); - if (reseated !== value) { - runActions(states[value]?.exit, event); - invocationToken++; // abandon the invoke of the state we're leaving - clearAfterTimers(); - value = reseated; - enterState(event); + const active = activeNodes(); + if (!active.every(node => canEnter(node, event))) { + const reseated = initialLeaf(root); + if (reseated && reseated !== leaf) { + for (const node of active.reverse()) { + runActions(node.config.exit, event); + stopActivity(node); + } + leaf = reseated; + for (const node of activeNodes()) { + runActions(node.config.entry, event); + } + settle(event); commit(); } return; } - const immediate = pickTransition(normalizeTransition(states[value]?.always, event), event); - if (immediate && immediate.target !== undefined && takeTransition(immediate, event)) { + if (takeAlways(event)) { commit(); // nothing applies → no commit, no notify } }, }; + actorMachines.set(actor, machine); return actor; } diff --git a/packages/mosaic/src/machine/guards.ts b/packages/mosaic/src/machine/guards.ts new file mode 100644 index 00000000000..a4bb2538c19 --- /dev/null +++ b/packages/mosaic/src/machine/guards.ts @@ -0,0 +1,5 @@ +import type { GuardMeta } from './types'; + +export function stateIn(path: string) { + return (_context: unknown, _event: unknown, meta: GuardMeta): boolean => meta.matches(path); +} diff --git a/packages/mosaic/src/machine/types.ts b/packages/mosaic/src/machine/types.ts index 82f00c0e076..f7a12b24d60 100644 --- a/packages/mosaic/src/machine/types.ts +++ b/packages/mosaic/src/machine/types.ts @@ -16,8 +16,16 @@ export interface AnyEventObject extends EventObject { [key: string]: unknown; } +export interface GuardMeta { + matches: (path: string) => boolean; +} + /** Pure predicate that gates a transition. */ -export type Guard = (context: TContext, event: TEvent) => boolean; +export type Guard = ( + context: TContext, + event: TEvent, + meta: GuardMeta, +) => boolean; /** A side-effecting action — runs for its effect, returns nothing. */ export type ActionFunction = (context: TContext, event: TEvent) => void; @@ -108,6 +116,17 @@ export interface AfterEvent extends EventObject { delay: number; } +export type PromiseSrc = ( + context: TContext, + event: TEvent | DoneInvokeEvent | ErrorInvokeEvent, +) => Promise; + +export type AnyStateMachine = StateMachine; + +export type ProvidedActor = AnyStateMachine | ((input: any) => Promise); + +export type ProvidedActors = Record; + /** Invoke a promise on state entry and branch on its settlement. */ export interface InvokeConfig< TContext, @@ -115,13 +134,17 @@ export interface InvokeConfig< TOutput = unknown, TStates extends string = string, > { - /** Started on entry. The resolved value lands on `onDone` events as `output`. */ - src: (context: TContext, event: TEvent | DoneInvokeEvent | ErrorInvokeEvent) => Promise; + src: PromiseSrc | AnyStateMachine | string; + id?: string; + input?: (context: TContext) => unknown; onDone?: Transition, TStates>; onError?: Transition; } export interface StateConfig { + id?: string; + initial?: string; + states?: Record>; /** * Entry precondition — "may navigation LAND on this state right now?". Checked * uniformly by *every* transition (and the derived initial) that targets this @@ -196,11 +219,15 @@ export interface StateMachine { export type ActorStatus = 'active' | 'done' | 'stopped'; +export type AnyActor = Actor; + /** A point-in-time view of a running actor. */ export interface Snapshot { value: string; context: TContext; status: ActorStatus; + children: Readonly>; + matches: (path: string) => boolean; } /** A subscriber receives the latest snapshot on every transition. */ @@ -256,4 +283,5 @@ export interface CreateActorOptions { * actions, immediates, or invokes run for the teleported state). */ snapshot?: { value: string; context?: Partial }; + actors?: ProvidedActors; } diff --git a/packages/mosaic/src/machine/useMachine.ts b/packages/mosaic/src/machine/useMachine.ts index df2b11e7fb9..93e61db08a1 100644 --- a/packages/mosaic/src/machine/useMachine.ts +++ b/packages/mosaic/src/machine/useMachine.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useSyncExternalStore } from 'react'; import { createActor } from './createActor'; -import type { Actor, CreateActorOptions, EventObject, Snapshot, StateMachine } from './types'; +import type { Actor, CreateActorOptions, EventObject, ProvidedActors, Snapshot, StateMachine } from './types'; export interface UseMachineOptions extends CreateActorOptions { /** Called once when the machine reaches a final state (`type: 'final'`). */ @@ -39,8 +39,9 @@ export function useMachine( options?: UseMachineOptions, ): [Snapshot, Actor['send'], Actor] { const actorRef = useRef | null>(null); + const actorsRef = useRef({}); if (actorRef.current === null) { - actorRef.current = createActor(machine, options); + actorRef.current = createActor(machine, { ...options, actors: actorsRef.current }); } const actor = actorRef.current; @@ -49,13 +50,16 @@ export function useMachine( return () => actor.stop(); }, [actor]); - // Keep injected context (e.g. a function from props) current on every render. + // Keep injected context and actors (e.g. functions from props) current on every render. // useLayoutEffect with no deps runs synchronously after every render, before // paint — ensuring setContext fires before any user event triggers an invoke. useLayoutEffect(() => { if (options?.context) { actor.setContext(options.context); } + if (options?.actors) { + Object.assign(actorsRef.current, options.actors); + } }); const snapshot = useSyncExternalStore(actor.subscribe, actor.getSnapshot, actor.getSnapshot); diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx index 6b6fb78f1a9..aa4cdcf687c 100644 --- a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -3,15 +3,12 @@ import { Destructive } from '@clerk/mosaic/blocks/destructive'; import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import { Button } from '@clerk/mosaic/components/button'; -import { Card } from '@clerk/mosaic/components/card'; -import { Dialog } from '@clerk/mosaic/components/dialog'; -import { Flow, type FlowDirection } from '@clerk/mosaic/components/flow'; -import { Reverification, useReverificationFlow } from '@clerk/mosaic/features/reverification'; +import { Reverification, useReverificationActors } from '@clerk/mosaic/features/reverification'; import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; import { useUser } from '@clerk/nextjs'; -import { isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; +import { ClerkAPIResponseError } from '@clerk/shared/error'; import Link from 'next/link'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; const SUCCESS_DELAY_MS = 3000; @@ -20,7 +17,10 @@ async function mockDelete(delaySuccess: boolean) { const body = await response.json().catch(() => ({})); if (body?.clerk_error?.reason === 'reverification-error') { - return body; + throw new ClerkAPIResponseError('Reverification required', { + data: [{ code: 'session_reverification_required', message: 'Reverification required', long_message: '' }], + status: 403, + }); } if (!response.ok) { throw new Error(typeof body.error === 'string' ? body.error : `Mock delete failed (${response.status})`); @@ -37,250 +37,15 @@ async function resetMockDelete() { await fetch('/api/live/mock-delete', { method: 'DELETE' }); } -function CardHarness() { - const [status, setStatus] = useState<'idle' | 'success' | 'cancelled' | 'error'>('idle'); - const [message, setMessage] = useState(null); - const [requestPending, setRequestPending] = useState(false); - const [deleteAccount, reverification] = useReverificationFlow(() => mockDelete(false)); - const busy = requestPending || reverification.status !== 'idle'; - - return ( -
-
- -
- - - - {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.status === '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.status !== 'idle') { - 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.status === 'idle') { - return; - } - if (reverification.status !== 'idle') { - reverification.onCancel?.(); - } - setOpen(false); - if (reverification.status !== '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 [successMessage, setSuccessMessage] = useState(null); - const [deleteAccount, reverification] = useReverificationFlow(() => mockDelete(true)); const destructive = useDestructiveController({ onDelete: async () => { setSuccessMessage(null); - await resetMockDelete(); - try { - await deleteAccount(); - setSuccessMessage('Mock delete completed. The account was not deleted.'); - } catch (error) { - await resetMockDelete(); - throw error; - } + await mockDelete(true); + setSuccessMessage('Mock delete completed. The account was not deleted.'); }, - reverification, + reverification: useReverificationActors(), }); return ( @@ -290,6 +55,7 @@ function DestructiveHarness() { color='negative' onClick={() => { setSuccessMessage(null); + void resetMockDelete(); destructive.openDestructiveDialog(); }} disabled={destructive.open} @@ -305,7 +71,7 @@ function DestructiveHarness() { fieldLabel='Type “Delete account” below to continue' confirmationValue='Delete account' actionLabel='Delete account' - verificationSlot={} + verificationSlot={} /> ); @@ -322,8 +88,8 @@ export default function ReverificationLivePage() {

Reverification

- 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. + Delete account hits a mock route that asks for reverification, then a fake success. The account is not + deleted. Reset clears the mock state.

-
-
-

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

diff --git a/packages/swingset/src/stories/destructive.mdx b/packages/swingset/src/stories/destructive.mdx index 76949cf9ca9..1686a78b191 100644 --- a/packages/swingset/src/stories/destructive.mdx +++ b/packages/swingset/src/stories/destructive.mdx @@ -50,18 +50,17 @@ A rejection leaves the dialog up. The field is marked invalid for as long as the ## Reverification -Wrap the action with `useReverificationFlow` and pass the result to the controller. The controller decides which step is showing, cancels the verification when the dialog closes, and blocks closing while the action is being retried. Render the verification in `verificationSlot`, and confirmation and verification stay in the same dialog and card. +Pass `useReverificationActors()` to the controller. When the action fails with `session_reverification_required`, the controller starts reverification as a child of the dialog, retries the action once it succeeds, and blocks closing while the retry runs. Closing the dialog or switching sessions stops the verification. Render the child in `verificationSlot`, and confirmation and verification stay in the same dialog and card. ```tsx -import { Reverification, useReverificationFlow } from '@clerk/mosaic/features/reverification'; +import { Reverification, useReverificationActors } from '@clerk/mosaic/features/reverification'; -const [runDelete, reverification] = useReverificationFlow(deleteAccount); -const destructive = useDestructiveController({ onDelete: runDelete, reverification }); +const destructive = useDestructiveController({ onDelete: deleteAccount, reverification: useReverificationActors() }); } + verificationSlot={} />; ``` @@ -145,22 +144,21 @@ A machine maps onto those same props: Controlled: -| Prop | Type | Default | Description | -| ------------------- | -------------------------- | ------------ | -------------------------------------------------------------------------------- | -| `open` | `boolean` | — (required) | Whether the confirmation is showing. Controlled, the way any dialog is. | -| `onOpenChange` | `(open: boolean) => void` | — (required) | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | -| `trigger` | `ReactNode` | — | The button that asks to open the dialog. | -| `title` | `string` | — (required) | Names what is about to happen. | -| `description` | `ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | -| `fieldLabel` | `string` | — (required) | Labels the confirmation field. | -| `confirmationValue` | `string` | — (required) | The phrase the user has to type back. Also the field's placeholder. | -| `actionLabel` | `string` | — (required) | The confirming button's label. | -| `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | -| `finalFocus` | `DialogFocusTarget` | the trigger | Where focus returns on close, for a removal that unmounts the trigger. | -| `onDelete` | `() => void` | — (required) | Asks the caller to run the action, once the typed phrase matches. | -| `isDeleting` | `boolean` | `false` | Disables the field, renders the action pending, and ignores further submissions. | -| `errorMessage` | `string` | — | Marks the field invalid and renders under it. | -| `reverification` | `ReverificationController` | — | Renders reverification in the same dialog after the action requests it. | +| Prop | Type | Default | Description | +| ------------------- | ------------------------- | ------------ | -------------------------------------------------------------------------------- | +| `open` | `boolean` | — (required) | Whether the confirmation is showing. Controlled, the way any dialog is. | +| `onOpenChange` | `(open: boolean) => void` | — (required) | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | +| `trigger` | `ReactNode` | — | The button that asks to open the dialog. | +| `title` | `string` | — (required) | Names what is about to happen. | +| `description` | `ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | +| `fieldLabel` | `string` | — (required) | Labels the confirmation field. | +| `confirmationValue` | `string` | — (required) | The phrase the user has to type back. Also the field's placeholder. | +| `actionLabel` | `string` | — (required) | The confirming button's label. | +| `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | +| `finalFocus` | `DialogFocusTarget` | the trigger | Where focus returns on close, for a removal that unmounts the trigger. | +| `onDelete` | `() => void` | — (required) | Asks the caller to run the action, once the typed phrase matches. | +| `isDeleting` | `boolean` | `false` | Disables the field, renders the action pending, and ignores further submissions. | +| `errorMessage` | `string` | — | Marks the field invalid and renders under it. | With a handle: diff --git a/packages/swingset/src/stories/reverification.mdx b/packages/swingset/src/stories/reverification.mdx index 370d586c5cc..2c311d5d9d1 100644 --- a/packages/swingset/src/stories/reverification.mdx +++ b/packages/swingset/src/stories/reverification.mdx @@ -17,16 +17,14 @@ The example pauses briefly in each pending state. Enter `error` in password or b ## Usage -The feature wrapper reads Clerk state and drives the reverification flow. It renders nothing while `status` is `idle`. While a challenge is still preparing, the parent card shows a verification header, a centered spinner, and a disabled continue button, then shows the factor without a step transition. Keep the same card around the component for a ready factor, a pending retry, or the unavailable state. Isolated panel stories below are for visual development only. +Reverification runs as a child machine of the flow that needs it, such as `Destructive`. `Reverification` renders that child and renders nothing without one. 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, useReverificationFlow } from '@clerk/mosaic/features/reverification'; - -const [runAction, reverification] = useReverificationFlow(action); +import { Reverification } from '@clerk/mosaic/features/reverification'; - + ; ```