From 4196e4f07253e5b54becf3fa7ef4da285cb7079d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 16:51:58 -0600 Subject: [PATCH 01/38] feat(ui): add MFA row capabilities and default badges --- .../user-profile-mfa-section.view.test.tsx | 144 ++++++++++++++++++ .../user-profile-security-panel.view.test.tsx | 4 +- .../user-profile-mfa-row.view.tsx | 58 +++++++ .../user-profile-mfa-section.messages.ts | 17 +++ .../user-profile-mfa-section.styles.ts | 7 + .../user-profile-mfa-section.view.tsx | 75 +++------ packages/mosaic/src/styles/index.ts | 1 + 7 files changed, 253 insertions(+), 53 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-mfa-row.view.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-mfa-section.styles.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx new file mode 100644 index 00000000000..f8acca4e2ec --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx @@ -0,0 +1,144 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import type { UserProfileMfaSectionViewProps } from '../user-profile-mfa-section.view'; +import { UserProfileMfaSectionView } from '../user-profile-mfa-section.view'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileMfaSectionViewProps = { + methods: [], + onAdd: vi.fn(), + onRemove: vi.fn(), + onSetDefault: vi.fn(), + onRegenerateBackupCodes: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +describe('MFA section', () => { + it.each(['authenticator', 'sms'] as const)('displays the supplied default state for %s', type => { + const { props, rerender } = renderView({ methods: [{ id: 'method_1', type, isDefault: true }] }); + + expect(screen.getByText('Default')).toBeVisible(); + + rerender( + + + , + ); + + expect(screen.queryByText('Default')).not.toBeInTheDocument(); + }); + + it('keeps a protected method visible while removing a different SMS method by identity', async () => { + const user = userEvent.setup(); + const { props } = renderView({ + methods: [ + { id: 'totp', type: 'authenticator', isDefault: true, canRemove: false }, + { id: 'personal', type: 'sms', description: '+1 801-555-0100' }, + { id: 'work', type: 'sms', description: '+1 801-555-0200' }, + ], + }); + + expect(screen.getByText('Authenticator app')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Manage Authenticator app' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0100' })).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); + + expect(props.onRemove).toHaveBeenCalledExactlyOnceWith('work'); + }); + + it('offers Set as default only for an eligible SMS row and reflects updated props', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ + methods: [ + { id: 'personal', type: 'sms', description: '+1 801-555-0100', isDefault: true }, + { id: 'work', type: 'sms', description: '+1 801-555-0200', canSetDefault: true }, + ], + }); + + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0100' })); + expect(screen.queryByRole('menuitem', { name: 'Set as default' })).not.toBeInTheDocument(); + await user.keyboard('{Escape}'); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + + expect(props.onSetDefault).toHaveBeenCalledExactlyOnceWith('work'); + + rerender( + + + , + ); + + expect(screen.getAllByText('Default')).toHaveLength(1); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' })); + expect(screen.queryByRole('menuitem', { name: 'Set as default' })).not.toBeInTheDocument(); + }); + + it('renders supplied backup codes without other methods and only offers regeneration', async () => { + const user = userEvent.setup(); + const { props } = renderView({ methods: [{ id: 'backup', type: 'backup-codes' }] }); + + expect(screen.getByText('Backup codes')).toBeVisible(); + expect(screen.queryByText('No verification methods added')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); + expect(screen.getAllByRole('menuitem')).toHaveLength(1); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + + expect(props.onRegenerateBackupCodes).toHaveBeenCalledOnce(); + expect(props.onRemove).not.toHaveBeenCalled(); + expect(props.onSetDefault).not.toHaveBeenCalled(); + }); + + it('keeps rows visible without action callbacks', () => { + renderView({ + methods: [ + { id: 'totp', type: 'authenticator', isDefault: true }, + { id: 'phone', type: 'sms', description: '+1 801-555-0100', canSetDefault: true }, + { id: 'backup', type: 'backup-codes' }, + ], + onAdd: undefined, + onRemove: undefined, + onSetDefault: undefined, + onRegenerateBackupCodes: undefined, + }); + + expect(screen.getByText('Authenticator app')).toBeVisible(); + expect(screen.getByText('+1 801-555-0100')).toBeVisible(); + expect(screen.getByText('Backup codes')).toBeVisible(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it.each([true, false])('keeps the empty section visible with Add available: %s', canAdd => { + renderView({ onAdd: canAdd ? vi.fn() : undefined }); + + expect(screen.getByRole('region', { name: '2-step verification' })).toBeVisible(); + expect(screen.getByText('No verification methods added')).toBeVisible(); + if (canAdd) { + expect(screen.getByRole('button', { name: 'Add verification method' })).toBeVisible(); + } else { + expect(screen.queryByRole('button', { name: 'Add verification method' })).not.toBeInTheDocument(); + } + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 4d810438797..d690f6c66a4 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -229,7 +229,7 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); }); - it('only shows backup codes with another verification method and only allows regeneration', async () => { + it('shows supplied backup codes independently and only allows regeneration', async () => { const onRegenerateBackupCodes = vi.fn(); const onRemoveMfaMethod = vi.fn(); const backupCodes = { id: 'backup_1', type: 'backup-codes' as const }; @@ -239,7 +239,7 @@ describe('UserProfileSecurityPanelView', () => { onRemoveMfaMethod, }); - expect(screen.queryByText('Backup codes')).not.toBeInTheDocument(); + expect(screen.getByText('Backup codes')).toBeVisible(); backupOnlyView.unmount(); const user = userEvent.setup(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-row.view.tsx new file mode 100644 index 00000000000..d82ffd41c36 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-row.view.tsx @@ -0,0 +1,58 @@ +import { Badge } from '../../components/badge'; +import { Section } from '../../components/section'; +import { fill } from '../../utils/messages'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +import { userProfileMfaMessages as m } from './user-profile-mfa-section.messages'; +import { styles } from './user-profile-mfa-section.styles'; +import type { UserProfileMfaMethod, UserProfileMfaSectionViewProps } from './user-profile-mfa-section.view'; +import { UserProfileSecurityIcon } from './user-profile-security-icon'; + +export function UserProfileMfaRowView({ + method, + onRemove, + onSetDefault, + onRegenerateBackupCodes, +}: Pick & { + method: UserProfileMfaMethod; +}) { + const label = method.label ?? m.methods[method.type]; + const manageLabel = + method.type === 'sms' && method.description + ? fill(m.manageSms, { label, phoneNumber: method.description }) + : fill(m.manage, { label }); + const actions: UserProfileMenuAction[] = []; + + if (method.type === 'sms' && method.canSetDefault && onSetDefault) { + actions.push({ label: m.setDefault, onClick: () => onSetDefault(method.id) }); + } + + if (method.type === 'backup-codes') { + if (onRegenerateBackupCodes) { + actions.push({ label: m.regenerate, onClick: onRegenerateBackupCodes }); + } + } else if (onRemove && method.canRemove !== false) { + actions.push({ label: m.remove, color: 'negative', onClick: () => onRemove(method.id) }); + } + + return ( + + + + + {label} + {method.isDefault ? {m.default} : null} + + {method.description ? {method.description} : null} + + {actions.length > 0 ? ( + + + + ) : null} + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts new file mode 100644 index 00000000000..2807e02fa81 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts @@ -0,0 +1,17 @@ +export const userProfileMfaMessages = { + label: '2-step verification', + add: 'Add', + addLabel: 'Add verification method', + empty: 'No verification methods added', + methods: { + sms: 'SMS verification', + authenticator: 'Authenticator app', + 'backup-codes': 'Backup codes', + }, + default: 'Default', + setDefault: 'Set as default', + remove: 'Remove method', + regenerate: 'Regenerate', + manage: 'Manage {label}', + manageSms: 'Manage {label} {phoneNumber}', +}; diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.styles.ts new file mode 100644 index 00000000000..a0da23b6d28 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.styles.ts @@ -0,0 +1,7 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + label: { gap: space['2'], alignItems: 'center', display: 'flex', flexWrap: 'wrap' }, +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx index 1d22b103b02..85e1eae2a85 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx @@ -1,10 +1,8 @@ import { Button } from '../../components/button'; import { Icon } from '../../components/icon'; import { Menu } from '../../components/menu'; -import { Section } from '../../components/section'; -import type { UserProfileMenuAction } from './user-profile-action-menu'; -import { UserProfileActionMenu } from './user-profile-action-menu'; -import { UserProfileSecurityIcon } from './user-profile-security-icon'; +import { UserProfileMfaRowView } from './user-profile-mfa-row.view'; +import { userProfileMfaMessages as m } from './user-profile-mfa-section.messages'; import { UserProfileSecurityList } from './user-profile-security-list'; export interface UserProfileMfaMethod { @@ -12,6 +10,9 @@ export interface UserProfileMfaMethod { type: 'sms' | 'authenticator' | 'backup-codes'; label?: string; description?: string; + isDefault?: boolean; + canRemove?: boolean; + canSetDefault?: boolean; } export type UserProfileMfaAddableMethod = Extract; @@ -22,14 +23,9 @@ export interface UserProfileMfaSectionViewProps { onAdd?: (type: UserProfileMfaAddableMethod) => void; onRegenerateBackupCodes?: () => void; onRemove?: (id: string) => void; + onSetDefault?: (id: string) => void; } -const labels: Record = { - sms: 'SMS verification', - authenticator: 'Authenticator app', - 'backup-codes': 'Backup codes', -}; - const addableMethods: UserProfileMfaAddableMethod[] = ['sms', 'authenticator']; export function UserProfileMfaSectionView({ @@ -38,10 +34,9 @@ export function UserProfileMfaSectionView({ onAdd, onRegenerateBackupCodes, onRemove, + onSetDefault, }: UserProfileMfaSectionViewProps) { const availableMethods = addableMethods.filter(type => !methods.some(method => method.type === type)); - const hasConfiguredMethod = methods.some(method => method.type === 'sms' || method.type === 'authenticator'); - const visibleMethods = methods.filter(method => method.type !== 'backup-codes' || hasConfiguredMethod); return ( 0 ? ( ( + + + ); +} diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index dcd7e7b08ad..26a5240999e 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -210,3 +210,4 @@ export type TargetVarName = keyof typeof targetVars; export type TypeScaleVarName = keyof typeof typeScaleVars; export { mergeStyleProps, themeProps } from '../props'; export { UserProfileMfaSectionView } from '../features/user-profile/user-profile-mfa-section.view'; +export { UserProfileAuthenticatorSetupView } from '../features/user-profile/user-profile-authenticator-setup.view'; From f9653aea631c4ae3fc59ba1e7a93058ad87523db Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 17:45:21 -0600 Subject: [PATCH 11/38] docs(swingset): demonstrate authenticator setup --- packages/swingset/src/lib/registry.ts | 2 + .../src/stories/user-profile-mfa-section.mdx | 29 +++++++++++- .../user-profile-mfa-section.stories.tsx | 45 +++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 56474e96ed4..0c835b3695b 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -306,6 +306,7 @@ import { } from '../stories/user-profile-enterprise-accounts-section.stories'; import { AddMethod as UserProfileMfaSectionAddMethod, + AuthenticatorSetup as UserProfileMfaSectionAuthenticatorSetup, Default as UserProfileMfaSectionDefault, Empty as UserProfileMfaSectionEmpty, meta as userProfileMfaSectionMeta, @@ -678,6 +679,7 @@ const userProfilePasskeysSectionModule: StoryModule = { }; const userProfileMfaSectionModule: StoryModule = { AddMethod: UserProfileMfaSectionAddMethod, + AuthenticatorSetup: UserProfileMfaSectionAuthenticatorSetup, meta: userProfileMfaSectionMeta, Default: UserProfileMfaSectionDefault, Empty: UserProfileMfaSectionEmpty, diff --git a/packages/swingset/src/stories/user-profile-mfa-section.mdx b/packages/swingset/src/stories/user-profile-mfa-section.mdx index 5a1a2f331c2..df9d4af4664 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.mdx +++ b/packages/swingset/src/stories/user-profile-mfa-section.mdx @@ -47,6 +47,15 @@ Each method has an `id`, a `type` (`sms`, `authenticator`, or `backup-codes`), a | `canRemove` | `boolean` | `true` | Allows removal of an SMS or authenticator method when `onRemove` is supplied. | | `canSetDefault` | `boolean` | `false` | Allows Set as default on an SMS method when `onSetDefault` is supplied. | +### Authenticator setup + +`UserProfileAuthenticatorSetupView` renders the setup header and content inside a `Card.Root`. + +| Prop | Type | Default | Description | +| -------- | -------- | ------------ | ---------------------------------------------------------------------------------- | +| `secret` | `string` | — (required) | Prepared authenticator setup key. | +| `uri` | `string` | — (required) | Matching authenticator URI, encoded in the QR code and available for manual entry. | + ## Usage The caller decides whether to mount the section from the instance's second-factor configuration, even when existing methods are present. When mounted, the section stays visible with no methods or callbacks. The caller also prepares the method order, default indicators, and removal permissions, including restrictions when MFA is required. Backup-code rows only offer regeneration. @@ -55,10 +64,28 @@ Callbacks report user intent; updated props determine the displayed result. Set ### Choose a method -Open Add and choose a method. Each option is a button with a chevron, matching the reverification picker. This example reports the choice below the section; setup and verification screens are still being developed. Choosing an option reports it immediately and closes the picker. Closing without choosing a method leaves the selection unchanged; focus returns to Add. Omitting `onAdd` or supplying no choices hides Add while keeping the section visible. +Open Add and choose a method. Each option is a button with a chevron, matching the reverification picker. This example reports the choice below the section; enrollment and verification are still being developed. Choosing an option reports it immediately and closes the picker. Closing without choosing a method leaves the selection unchanged; focus returns to Add. Omitting `onAdd` or supplying no choices hides Add while keeping the section visible. +### Authenticator QR code and setup key + +Open the dialog to view the QR code, then switch to the read-only setup key and URI for manual entry. Both views use the same supplied credentials. Closing and reopening starts with the QR code again. This example uses fixed demo credentials; it does not enroll an authenticator. + +The setup view owns the display toggle. The caller supplies matching `secret` and `uri` values and owns enrollment, loading, and errors. Verification fields are still being developed. + + + ### Confirm removal and retry Choose Remove method from either row. Cancel keeps the method and returns focus to its menu. The first confirmed removal in this example fails after a short delay; retry completes it. Removing both methods leaves the empty section visible. diff --git a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx index aefe15871d7..8ac71c4e7d3 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -1,4 +1,8 @@ +import { Button } from '@clerk/mosaic/components/button'; +import { Card } from '@clerk/mosaic/components/card'; +import { Dialog } from '@clerk/mosaic/components/dialog'; import { Text } from '@clerk/mosaic/components/text'; +import { UserProfileAuthenticatorSetupView } from '@clerk/mosaic/features/user-profile/user-profile-authenticator-setup.view'; import type { UserProfileMfaMethod } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; import { UserProfileMfaSectionView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; import { useRef, useState } from 'react'; @@ -84,6 +88,47 @@ export function AddMethod() { ); } +export function AuthenticatorSetup() { + return ( + + + } + > + Set up authenticator + + + + + + + } + > + Cancel + + + + + + ); +} + export function Removal() { const [methods, setMethods] = useState([ { id: 'authenticator', type: 'authenticator' }, From b1d11275a0371f08ed3f369fb1991bdfec3edf10 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 17:55:06 -0600 Subject: [PATCH 12/38] feat(ui): add authenticator code verification dialog --- ...-profile-add-authenticator.dialog.test.tsx | 174 ++++++++++++++++++ .../user-profile-add-authenticator.dialog.tsx | 116 ++++++++++++ ...user-profile-add-authenticator.messages.ts | 6 + .../user-profile-add-authenticator.styles.ts | 18 ++ packages/mosaic/src/styles/index.ts | 2 +- 5 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-add-authenticator.styles.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx new file mode 100644 index 00000000000..90eedff1c18 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx @@ -0,0 +1,174 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import type { UserProfileAddAuthenticatorDialogProps } from '../user-profile-add-authenticator.dialog'; +import { UserProfileAddAuthenticatorDialog } from '../user-profile-add-authenticator.dialog'; + +const setup = { + secret: 'JBSWY3DPEHPK3PXP', + uri: 'otpauth://totp/Swingset:demo@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Swingset', +}; + +function renderView(overrides: Partial = {}) { + const props: UserProfileAddAuthenticatorDialogProps = { + ...setup, + open: true, + onOpenChange: vi.fn(), + code: '', + onCodeChange: vi.fn(), + onSubmit: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +function VerificationExample({ onSubmit }: Pick) { + const [code, setCode] = useState(''); + return ( + + undefined} + code={code} + onCodeChange={setCode} + onSubmit={onSubmit} + /> + + ); +} + +describe('UserProfileAddAuthenticatorDialog', () => { + it.each(['typing', 'pasting'] as const)('submits a complete authenticator code after %s', async method => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render(); + const dialog = screen.getByRole('dialog', { name: 'Add an authenticator app' }); + expect(screen.getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible(); + expect(screen.queryByRole('button', { name: /Resend/ })).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus()); + await user.click(screen.getByRole('textbox', { name: 'Verification code' })); + + if (method === 'typing') { + await user.keyboard('12345'); + expect(onSubmit).not.toHaveBeenCalled(); + await user.keyboard('6'); + } else { + await user.paste('123456'); + } + + expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); + expect(screen.getByRole('dialog')).toBe(dialog); + }); + + it('submits the current code through Verify or the form and does not submit on Cancel', async () => { + const user = userEvent.setup(); + const { props } = renderView({ code: '654321' }); + await user.click(screen.getByRole('button', { name: 'Verify', exact: true })); + expect(props.onSubmit).toHaveBeenCalledExactlyOnceWith('654321'); + + const form = screen.getByRole('textbox', { name: 'Verification code' }).closest('form'); + if (!form) { + throw new Error('Verification form missing'); + } + form.requestSubmit(); + expect(props.onSubmit).toHaveBeenCalledTimes(2); + expect(props.onSubmit).toHaveBeenLastCalledWith('654321'); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + expect(props.onSubmit).toHaveBeenCalledTimes(2); + }); + + it('blocks incomplete and pending submissions, including native form submission', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ code: '123' }); + const verify = screen.getByRole('button', { name: 'Verify', exact: true }); + expect(verify).toBeDisabled(); + await user.click(screen.getByRole('textbox', { name: 'Verification code' })); + await user.keyboard('{Enter}'); + expect(props.onSubmit).not.toHaveBeenCalled(); + + rerender( + + + , + ); + expect(verify).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('progressbar', { name: 'Verifying code' })).toBeInTheDocument(); + for (const slot of screen.getAllByRole('textbox')) { + expect(slot).toBeDisabled(); + } + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + await user.click(verify); + const form = screen.getByRole('textbox', { name: 'Verification code' }).closest('form'); + if (!form) { + throw new Error('Verification form missing'); + } + form.requestSubmit(); + expect(props.onSubmit).not.toHaveBeenCalled(); + }); + + it('preserves the setup mode and code on failure, then clears feedback when retry begins', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ code: '123456' }); + const dialog = screen.getByRole('dialog'); + await user.click(screen.getByRole('button', { name: 'Can’t scan? View setup key' })); + + rerender( + + + , + ); + expect(screen.getByRole('dialog')).toBe(dialog); + expect(screen.getByRole('textbox', { name: 'Setup key' })).toHaveValue(setup.secret); + expect(screen.getByRole('textbox', { name: 'Setup URI' })).toHaveValue(setup.uri); + const group = screen.getByRole('group', { name: 'Verification code' }); + expect(group).toHaveAccessibleDescription('That code has expired. Please try again.'); + expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveAttribute('aria-invalid', 'true'); + await user.click(screen.getByRole('button', { name: 'Verify', exact: true })); + expect(props.onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); + + rerender( + + + , + ); + expect(group).not.toHaveAccessibleDescription(); + expect(screen.getByRole('textbox', { name: 'Verification code' })).not.toHaveAttribute('aria-invalid'); + expect(screen.getByRole('textbox', { name: 'Setup key' })).toHaveValue(setup.secret); + }); + + it('keeps the entered code when switching between QR and manual setup', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render(); + await user.click(screen.getByRole('textbox', { name: 'Verification code' })); + await user.keyboard('123'); + await user.click(screen.getByRole('button', { name: 'Can’t scan? View setup key' })); + await user.click(screen.getByRole('button', { name: 'Scan QR code instead' })); + await user.click(screen.getByRole('textbox', { name: 'Character 4 of 6' })); + await user.keyboard('456'); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx new file mode 100644 index 00000000000..029ef4b4c62 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx @@ -0,0 +1,116 @@ +import { useId } from 'react'; + +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import type { DialogTriggerProps } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Otp } from '../../components/otp'; +import { userProfileAddAuthenticatorMessages as m } from './user-profile-add-authenticator.messages'; +import { styles } from './user-profile-add-authenticator.styles'; +import type { UserProfileAuthenticatorSetupViewProps } from './user-profile-authenticator-setup.view'; +import { UserProfileAuthenticatorSetupView } from './user-profile-authenticator-setup.view'; + +export interface UserProfileAddAuthenticatorDialogProps extends UserProfileAuthenticatorSetupViewProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code: string) => void; + isPending?: boolean; + errorMessage?: string; +} + +export function UserProfileAddAuthenticatorDialog({ + open, + onOpenChange, + trigger, + secret, + uri, + code, + onCodeChange, + onSubmit, + isPending = false, + errorMessage, +}: UserProfileAddAuthenticatorDialogProps) { + const formId = useId(); + const submitCode = (value: string) => { + if (!isPending && value.length === 6) { + onSubmit(value); + } + }; + + return ( + + {trigger ? : null} + + + + { + event.preventDefault(); + submitCode(code); + }} + /> + } + > + + {m.codeLabel} + + + {errorMessage} + + + + + + } + > + {m.cancel} + + + {m.verify} + + + + + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts new file mode 100644 index 00000000000..53199532806 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts @@ -0,0 +1,6 @@ +export const userProfileAddAuthenticatorMessages = { + codeLabel: 'Verification code', + cancel: 'Cancel', + verify: 'Verify', + pending: 'Verifying code', +}; diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.styles.ts new file mode 100644 index 00000000000..421f8cc6100 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.styles.ts @@ -0,0 +1,18 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + verification: { + paddingBlockStart: 0, + }, + field: { + paddingBlockStart: space['4'], + borderTopColor: colorVars['--cl-color-border'], + borderTopStyle: 'solid', + borderTopWidth: '1px', + }, + label: { + textAlign: 'center', + }, +}); diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index 26a5240999e..4e914229edf 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -210,4 +210,4 @@ export type TargetVarName = keyof typeof targetVars; export type TypeScaleVarName = keyof typeof typeScaleVars; export { mergeStyleProps, themeProps } from '../props'; export { UserProfileMfaSectionView } from '../features/user-profile/user-profile-mfa-section.view'; -export { UserProfileAuthenticatorSetupView } from '../features/user-profile/user-profile-authenticator-setup.view'; +export { UserProfileAddAuthenticatorDialog } from '../features/user-profile/user-profile-add-authenticator.dialog'; From b859995e4f202cd5ac00afb2f5b75a73fc09e566 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 17:55:23 -0600 Subject: [PATCH 13/38] docs(swingset): demonstrate authenticator verification retry --- .../src/stories/user-profile-mfa-section.mdx | 37 +++++--- .../user-profile-mfa-section.stories.tsx | 92 ++++++++++++------- 2 files changed, 81 insertions(+), 48 deletions(-) diff --git a/packages/swingset/src/stories/user-profile-mfa-section.mdx b/packages/swingset/src/stories/user-profile-mfa-section.mdx index df9d4af4664..27979271583 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.mdx +++ b/packages/swingset/src/stories/user-profile-mfa-section.mdx @@ -47,32 +47,42 @@ Each method has an `id`, a `type` (`sms`, `authenticator`, or `backup-codes`), a | `canRemove` | `boolean` | `true` | Allows removal of an SMS or authenticator method when `onRemove` is supplied. | | `canSetDefault` | `boolean` | `false` | Allows Set as default on an SMS method when `onSetDefault` is supplied. | -### Authenticator setup - -`UserProfileAuthenticatorSetupView` renders the setup header and content inside a `Card.Root`. - -| Prop | Type | Default | Description | -| -------- | -------- | ------------ | ---------------------------------------------------------------------------------- | -| `secret` | `string` | — (required) | Prepared authenticator setup key. | -| `uri` | `string` | — (required) | Matching authenticator URI, encoded in the QR code and available for manual entry. | +### Authenticator dialog + +`UserProfileAddAuthenticatorDialog` combines setup and code verification in one controlled dialog. + +| Prop | Type | Default | Description | +| -------------- | ------------------------------ | ------------ | ------------------------------------------------------------------------------------ | +| `open` | `boolean` | — (required) | Whether the dialog is open. | +| `onOpenChange` | `(open: boolean) => void` | — (required) | Receives open and dismiss requests. | +| `trigger` | `DialogTriggerProps['render']` | — | Optional trigger that receives focus when the dialog closes. | +| `secret` | `string` | — (required) | Prepared authenticator setup key. | +| `uri` | `string` | — (required) | Matching authenticator URI, encoded in the QR code and available for manual entry. | +| `code` | `string` | — (required) | Current verification code. | +| `onCodeChange` | `(value: string) => void` | — (required) | Receives code edits. | +| `onSubmit` | `(code: string) => void` | — (required) | Receives six digits after completion or a Verify submission. | +| `isPending` | `boolean` | `false` | Disables code entry and Cancel; Verify shows progress and blocks repeat submissions. | +| `errorMessage` | `string` | — | Feedback associated with the verification field. | ## Usage The caller decides whether to mount the section from the instance's second-factor configuration, even when existing methods are present. When mounted, the section stays visible with no methods or callbacks. The caller also prepares the method order, default indicators, and removal permissions, including restrictions when MFA is required. Backup-code rows only offer regeneration. -Callbacks report user intent; updated props determine the displayed result. Set as default awaits its callback, blocks overlapping method actions, and leaves the current badge in place until `methods` changes. Rejections appear below the selected row and are announced to assistive technology. Retrying clears the error. The section owns one removal confirmation, opened with the selected method. Confirmation owns its pending and error state; update `methods` after a successful removal. Removing SMS verification leaves the phone number on the account. Enrollment dialogs are still being developed. +Callbacks report user intent; updated props determine the displayed result. Set as default awaits its callback, blocks overlapping method actions, and leaves the current badge in place until `methods` changes. Rejections appear below the selected row and are announced to assistive technology. Retrying clears the error. The section owns one removal confirmation, opened with the selected method. Confirmation owns its pending and error state; update `methods` after a successful removal. Removing SMS verification leaves the phone number on the account. ### Choose a method -Open Add and choose a method. Each option is a button with a chevron, matching the reverification picker. This example reports the choice below the section; enrollment and verification are still being developed. Choosing an option reports it immediately and closes the picker. Closing without choosing a method leaves the selection unchanged; focus returns to Add. Omitting `onAdd` or supplying no choices hides Add while keeping the section visible. +Open Add and choose a method. Each option is a button with a chevron, matching the reverification picker. This example reports the choice below the section; account enrollment wiring is still being developed. Choosing an option reports it immediately and closes the picker. Closing without choosing a method leaves the selection unchanged; focus returns to Add. Omitting `onAdd` or supplying no choices hides Add while keeping the section visible. -### Authenticator QR code and setup key +### Set up and verify an authenticator + +Open the dialog, scan the QR code or view the read-only setup key and URI, then enter six digits. Typing or pasting a complete code submits automatically. Verify is available for retrying the entered code. Authenticator apps generate their own codes, so there is no resend action. -Open the dialog to view the QR code, then switch to the read-only setup key and URI for manual entry. Both views use the same supplied credentials. Closing and reopening starts with the QR code again. This example uses fixed demo credentials; it does not enroll an authenticator. +The first attempt in this example fails after a short delay. Retry to see the pending state and successful closure. Closing and reopening resets the example. The example uses fixed demo credentials and simulated verification; it does not enroll an authenticator. -The setup view owns the display toggle. The caller supplies matching `secret` and `uri` values and owns enrollment, loading, and errors. Verification fields are still being developed. +The caller owns the code, pending state, error feedback, and success closure. Clear the error on edit or retry and keep the same setup credentials throughout the attempt. The caller also decides whether to accept dismissal while pending; this example keeps the dialog open until verification finishes. The setup view owns only the QR/manual display toggle. diff --git a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx index 8ac71c4e7d3..c54485ca8d6 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -1,8 +1,6 @@ import { Button } from '@clerk/mosaic/components/button'; -import { Card } from '@clerk/mosaic/components/card'; -import { Dialog } from '@clerk/mosaic/components/dialog'; import { Text } from '@clerk/mosaic/components/text'; -import { UserProfileAuthenticatorSetupView } from '@clerk/mosaic/features/user-profile/user-profile-authenticator-setup.view'; +import { UserProfileAddAuthenticatorDialog } from '@clerk/mosaic/features/user-profile/user-profile-add-authenticator.dialog'; import type { UserProfileMfaMethod } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; import { UserProfileMfaSectionView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; import { useRef, useState } from 'react'; @@ -89,43 +87,67 @@ export function AddMethod() { } export function AuthenticatorSetup() { + const [open, setOpen] = useState(false); + const [code, setCode] = useState(''); + const [isPending, setIsPending] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + const [verified, setVerified] = useState(false); + const hasFailed = useRef(false); + + const submit = async () => { + if (isPending) { + return; + } + setIsPending(true); + setErrorMessage(undefined); + await new Promise(resolve => setTimeout(resolve, 1000)); + setIsPending(false); + if (!hasFailed.current) { + hasFailed.current = true; + setErrorMessage('That code could not be verified. Please try again.'); + return; + } + setVerified(true); + setOpen(false); + }; + return ( - - + { + if (isPending) { + return; + } + setOpen(next); + setCode(''); + setErrorMessage(undefined); + if (next) { + setVerified(false); + hasFailed.current = false; + } + }} + trigger={ } - > - Set up authenticator - - - - - - - } - > - Cancel - - - - - + secret='JBSWY3DPEHPK3PXP' + uri='otpauth://totp/Swingset:demo@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Swingset' + code={code} + onCodeChange={value => { + setCode(value); + setErrorMessage(undefined); + }} + isPending={isPending} + errorMessage={errorMessage} + onSubmit={() => void submit()} + /> + {verified ? Authenticator verified in this demo : null} + ); } From 839e645cdf3122b7321858d8641392642f7964b4 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 17 Sep 2026 09:32:00 -0600 Subject: [PATCH 14/38] feat(ui): reuse Mosaic phone steps for SMS verification --- .../user-profile-add-sms.dialog.test.tsx | 178 ++++++++++++++++ .../user-profile-add-phone.dialog.tsx | 178 +--------------- .../user-profile-add-sms.dialog.tsx | 200 ++++++++++++++++++ .../user-profile-add-sms.messages.ts | 11 + .../user-profile-mfa-row.view.tsx | 2 +- .../user-profile-mfa-section.view.tsx | 2 +- .../user-profile/user-profile-phone.steps.tsx | 193 +++++++++++++++++ packages/mosaic/src/styles/index.ts | 1 + 8 files changed, 588 insertions(+), 177 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.dialog.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-add-sms.messages.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.dialog.test.tsx new file mode 100644 index 00000000000..7232ecc7e2a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.dialog.test.tsx @@ -0,0 +1,178 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import type { UserProfileAddSmsDialogProps } from '../user-profile-add-sms.dialog'; +import { UserProfileAddSmsDialog } from '../user-profile-add-sms.dialog'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileAddSmsDialogProps = { + open: true, + onOpenChange: vi.fn(), + step: 'select', + phoneNumbers: [ + { id: 'personal', phoneNumber: '+18015550100' }, + { id: 'work', phoneNumber: '+18015550200' }, + ], + selectedPhoneId: 'personal', + onSelectedPhoneIdChange: vi.fn(), + onAddPhone: vi.fn(), + onBack: vi.fn(), + phoneNumber: '+18015550100', + onPhoneNumberChange: vi.fn(), + code: '', + onCodeChange: vi.fn(), + onSubmit: vi.fn(), + onResend: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +describe('UserProfileAddSmsDialog', () => { + it('chooses an existing number with Select or requests a new number', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + const select = screen.getByRole('combobox', { name: 'Phone number +1 (801) 555-0100' }); + await waitFor(() => expect(select).toHaveFocus()); + + await user.click(select); + await user.click(screen.getByRole('option', { name: '+1 (801) 555-0200' })); + expect(props.onSelectedPhoneIdChange).toHaveBeenCalledWith('work'); + expect(props.onSubmit).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Continue' })); + expect(props.onSubmit).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole('button', { name: 'Add a new phone number' })); + expect(props.onAddPhone).toHaveBeenCalledOnce(); + }); + + it('adds and verifies a new number in the same dialog, preserving the number on Back', async () => { + const user = userEvent.setup(); + const onVerify = vi.fn(); + function Example() { + const [step, setStep] = useState('select'); + const [phoneNumber, setPhoneNumber] = useState('+18015550300'); + const [code, setCode] = useState(''); + return ( + + setStep('phone')} + onBack={() => setStep(step === 'verify' ? 'phone' : 'select')} + phoneNumber={phoneNumber} + onPhoneNumberChange={setPhoneNumber} + code={code} + onCodeChange={setCode} + onSubmit={value => (step === 'phone' ? setStep('verify') : onVerify(value))} + onResend={vi.fn()} + /> + + ); + } + render(); + const dialog = screen.getByRole('dialog'); + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + await user.click(screen.getByRole('button', { name: 'Add a new phone number' })); + expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBe(dialog); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Phone' })).toHaveFocus()); + await user.click(screen.getByRole('button', { name: 'Send code' })); + expect(screen.getByRole('dialog', { name: 'Verify your phone number' })).toBe(dialog); + expect(screen.getByText('Enter the code sent to +1 (801) 555-0300')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + + await user.click(screen.getByRole('button', { name: 'Back' })); + expect(screen.getByRole('textbox', { name: 'Phone' })).toHaveValue('(801) 555-0300'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + await user.type(screen.getByRole('textbox', { name: 'Verification code' }), '123456'); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('123456'); + }); + + it.each([ + { step: 'select', action: 'Continue', role: 'combobox', name: 'Phone number +1 (801) 555-0100' }, + { step: 'phone', action: 'Send code', role: 'textbox', name: 'Phone' }, + { step: 'verify', action: 'Verify', role: 'textbox', name: 'Verification code' }, + ] as const)( + 'blocks repeat submissions and supports retry on the $step step', + async ({ step, action, role, name }) => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ step, code: '123456', isPending: true }); + const field = screen.getByRole(role, { name }); + expect(field).toBeDisabled(); + const submit = screen.getByRole('button', { name: action, exact: true }); + expect(submit).toHaveAttribute('aria-busy', 'true'); + await user.click(submit); + const form = field.closest('form'); + if (!form) { + throw new Error('Step form missing'); + } + form.requestSubmit(); + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: step === 'select' ? 'Cancel' : 'Back' })).toBeDisabled(); + + rerender( + + + , + ); + expect(screen.getByRole(role, { name })).toHaveAttribute('aria-invalid', 'true'); + const describedControl = + step === 'verify' ? screen.getByRole('group', { name }) : screen.getByRole(role, { name }); + expect(describedControl).toHaveAccessibleDescription('Please try again.'); + await user.click(screen.getByRole('button', { name: action, exact: true })); + expect(props.onSubmit).toHaveBeenCalledOnce(); + }, + ); + + it('waits for resend to finish before allowing verification or Back', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ step: 'verify', code: '123456', isResending: true }); + const code = screen.getByRole('textbox', { name: 'Verification code' }); + expect(code).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Verify', exact: true })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Sending a new code…' })).toBeDisabled(); + + rerender( + + + , + ); + expect(code).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (12)' })).toBeDisabled(); + rerender( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })); + expect(props.onResend).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx index 7296eb59bdc..99e0fb56bba 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx @@ -1,18 +1,10 @@ -import { stringToFormattedPhoneString } from '@clerk/shared/phone'; -import * as stylex from '@stylexjs/stylex'; -import type { FormEvent, Ref } from 'react'; -import { useId, useRef } from 'react'; +import { useRef } from 'react'; -import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; -import { Field } from '../../../components/field'; -import { Flow, useFlowAutoFocus } from '../../../components/flow'; -import { Otp } from '../../../components/otp'; -import { PhoneInput } from '../../../components/phone-input'; -import { fill, rich, useMessages } from '../../../localization'; -import { styles } from '../user-profile-profile-panel.styles'; +import { Flow } from '../../../components/flow'; +import { EnterPhoneStep, VerifyPhoneStep } from '../user-profile-phone.steps'; export interface UserProfileAddPhoneDialogProps { open: boolean; @@ -85,167 +77,3 @@ export function UserProfileAddPhoneDialog(props: UserProfileAddPhoneDialogProps) ); } - -interface EnterPhoneStepProps { - inputRef: Ref; - phoneNumber: string; - onPhoneNumberChange: (value: string) => void; - onSubmit: () => void; - isPending?: boolean; - errorMessage?: string; -} - -function EnterPhoneStep(props: EnterPhoneStepProps) { - const m = useMessages('userProfileAddPhone'); - const phoneFormId = useId(); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - props.onSubmit(); - }; - - return ( - <> - - {m.phone.title} - {m.phone.description} - - - } - > - - {m.phone.label} - - - {props.errorMessage} - - - - - - {m.phone.submit} - - - - ); -} - -interface VerifyPhoneStepProps { - phoneNumber: string; - code: string; - onCodeChange: (value: string) => void; - onSubmit: (code?: string) => void; - onResend: () => void; - isPending?: boolean; - errorMessage?: string; - isResending?: boolean; - resendSeconds?: number; -} - -function VerifyPhoneStep(props: VerifyPhoneStepProps) { - const m = useMessages('userProfileAddPhone'); - const verifyFormId = useId(); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - props.onSubmit(); - }; - - return ( - <> - - {m.verify.title} - - {fill(m.verify.description, { phoneNumber: stringToFormattedPhoneString(props.phoneNumber) })} - - - - } - > - - {m.verify.label} - ()} - name='code' - value={props.code} - onValueChange={props.onCodeChange} - onComplete={props.onSubmit} - /> - - {props.errorMessage} - - - - - - - } - > - {m.verify.cancel} - - - {m.verify.submit} - - - - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx new file mode 100644 index 00000000000..424f280c5da --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx @@ -0,0 +1,200 @@ +import { useMergeRefs } from '@floating-ui/react'; +import type { Ref } from 'react'; +import { useId, useRef } from 'react'; + +import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import type { DialogTriggerProps } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Flow, type FlowDirection, useFlowAutoFocus } from '../../components/flow'; +import { Select } from '../../components/select'; +import { userProfileAddSmsMessages as m } from './user-profile-add-sms.messages'; +import { EnterPhoneStep, VerifyPhoneStep } from './user-profile-phone.steps'; + +export interface UserProfileAddSmsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + step: 'select' | 'phone' | 'verify'; + direction?: FlowDirection; + phoneNumbers: readonly { id: string; phoneNumber: string }[]; + selectedPhoneId: string; + onSelectedPhoneIdChange: (id: string) => void; + onAddPhone: () => void; + onBack: () => void; + phoneNumber: string; + onPhoneNumberChange: (value: string) => void; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code?: string) => void; + onResend: () => void; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + resendSeconds?: number; +} + +export function UserProfileAddSmsDialog(props: UserProfileAddSmsDialogProps) { + const selectRef = useRef(null); + const phoneRef = useRef(null); + + return ( + + {props.trigger ? : null} + + + + {current => { + const backAction = ( + + ); + return ( + <> + + + + + + + + + + + ); + }} + + + + + ); +} + +function SelectPhoneStep(props: UserProfileAddSmsDialogProps & { inputRef: Ref }) { + const formId = useId(); + const inputRef = useMergeRefs([props.inputRef, useFlowAutoFocus()]); + return ( + <> + + {m.title} + {m.description} + + { + event.preventDefault(); + if (!props.isPending && props.selectedPhoneId) { + props.onSubmit(); + } + }} + /> + } + > + + {m.phoneLabel} + ({ + value: phone.id, + label: stringToFormattedPhoneString(phone.phoneNumber), + }))} + value={props.selectedPhoneId} + onValueChange={props.onSelectedPhoneIdChange} + > + + + + + {props.errorMessage} + + + + + + + } + > + {m.cancel} + + + {m.continue} + + + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-sms.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-add-sms.messages.ts new file mode 100644 index 00000000000..98d38d14bbf --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-sms.messages.ts @@ -0,0 +1,11 @@ +export const userProfileAddSmsMessages = { + title: 'Set up SMS verification', + description: 'Choose a phone number to receive verification codes by text message.', + phoneLabel: 'Phone number', + phonePlaceholder: 'Choose a phone number', + addPhone: 'Add a new phone number', + continue: 'Continue', + pending: 'Continuing', + cancel: 'Cancel', + back: 'Back', +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-row.view.tsx index ed84e2c6945..cb7a995fcdd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-row.view.tsx @@ -2,7 +2,7 @@ import { useId } from 'react'; import { Badge } from '../../components/badge'; import { Section } from '../../components/section'; -import { fill } from '../../utils/messages'; +import { fill } from '../../localization'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; import { userProfileMfaMessages as m } from './user-profile-mfa-section.messages'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx index bd0cbf2d415..35810f276c6 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { Confirmation } from '../../blocks/confirmation'; -import { fill } from '../../utils/messages'; +import { fill } from '../../localization'; import { UserProfileAddMfaDialog } from './user-profile-add-mfa.dialog'; import { UserProfileMfaRowView } from './user-profile-mfa-row.view'; import { useUserProfileMfaSectionController } from './user-profile-mfa-section.controller'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx new file mode 100644 index 00000000000..c09b167472d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx @@ -0,0 +1,193 @@ +import { useMergeRefs } from '@floating-ui/react'; +import * as stylex from '@stylexjs/stylex'; +import type { FormEvent, ReactNode, Ref } from 'react'; +import { useId } from 'react'; + +import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { useFlowAutoFocus } from '../../components/flow'; +import { Otp } from '../../components/otp'; +import { PhoneInput } from '../../components/phone-input'; +import { fill, rich, useMessages } from '../../localization'; +import { styles } from './user-profile-profile-panel.styles'; + +interface EnterPhoneStepProps { + inputRef?: Ref; + secondaryAction?: ReactNode; + phoneNumber: string; + onPhoneNumberChange: (value: string) => void; + onSubmit: () => void; + isPending?: boolean; + errorMessage?: string; +} + +export function EnterPhoneStep(props: EnterPhoneStepProps) { + const m = useMessages('userProfileAddPhone'); + const phoneFormId = useId(); + const inputRef = useMergeRefs([props.inputRef, useFlowAutoFocus()]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!props.isPending) { + props.onSubmit(); + } + }; + + return ( + <> + + {m.phone.title} + {m.phone.description} + + + } + > + + {m.phone.label} + + + {props.errorMessage} + + + + + {props.secondaryAction} + + {m.phone.submit} + + + + ); +} + +interface VerifyPhoneStepProps { + secondaryAction?: ReactNode; + phoneNumber: string; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code?: string) => void; + onResend: () => void; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + resendSeconds?: number; +} + +export function VerifyPhoneStep(props: VerifyPhoneStepProps) { + const m = useMessages('userProfileAddPhone'); + const verifyFormId = useId(); + + const submitCode = (code?: string) => { + if (!props.isPending && !props.isResending) { + props.onSubmit(code); + } + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + submitCode(); + }; + + return ( + <> + + {m.verify.title} + + {fill(m.verify.description, { phoneNumber: stringToFormattedPhoneString(props.phoneNumber) })} + + + + } + > + + {m.verify.label} + ()} + name='code' + value={props.code} + onValueChange={props.onCodeChange} + onComplete={submitCode} + /> + + {props.errorMessage} + + + + + + {props.secondaryAction ?? ( + + } + > + {m.verify.cancel} + + )} + + {m.verify.submit} + + + + ); +} diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index 4e914229edf..ba32fd42afe 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -210,4 +210,5 @@ export type TargetVarName = keyof typeof targetVars; export type TypeScaleVarName = keyof typeof typeScaleVars; export { mergeStyleProps, themeProps } from '../props'; export { UserProfileMfaSectionView } from '../features/user-profile/user-profile-mfa-section.view'; +export { UserProfileAddSmsDialog } from '../features/user-profile/user-profile-add-sms.dialog'; export { UserProfileAddAuthenticatorDialog } from '../features/user-profile/user-profile-add-authenticator.dialog'; From e06b3d2e13a1fe391d2b4a9912105a24e12f5cac Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 17 Sep 2026 09:32:32 -0600 Subject: [PATCH 15/38] docs(swingset): demonstrate SMS setup and verification --- packages/swingset/src/lib/registry.ts | 2 + .../src/stories/user-profile-mfa-section.mdx | 50 ++++++ .../user-profile-mfa-section.stories.tsx | 153 +++++++++++++++++- 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 0c835b3695b..b4f64b9f975 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -312,6 +312,7 @@ import { meta as userProfileMfaSectionMeta, ReadOnly as UserProfileMfaSectionReadOnly, Removal as UserProfileMfaSectionRemoval, + SmsSetup as UserProfileMfaSectionSmsSetup, } from '../stories/user-profile-mfa-section.stories'; import { CreationUnavailable as UserProfilePasskeysSectionCreationUnavailable, @@ -680,6 +681,7 @@ const userProfilePasskeysSectionModule: StoryModule = { const userProfileMfaSectionModule: StoryModule = { AddMethod: UserProfileMfaSectionAddMethod, AuthenticatorSetup: UserProfileMfaSectionAuthenticatorSetup, + SmsSetup: UserProfileMfaSectionSmsSetup, meta: userProfileMfaSectionMeta, Default: UserProfileMfaSectionDefault, Empty: UserProfileMfaSectionEmpty, diff --git a/packages/swingset/src/stories/user-profile-mfa-section.mdx b/packages/swingset/src/stories/user-profile-mfa-section.mdx index 27979271583..ebf81d62680 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.mdx +++ b/packages/swingset/src/stories/user-profile-mfa-section.mdx @@ -64,6 +64,33 @@ Each method has an `id`, a `type` (`sms`, `authenticator`, or `backup-codes`), a | `isPending` | `boolean` | `false` | Disables code entry and Cancel; Verify shows progress and blocks repeat submissions. | | `errorMessage` | `string` | — | Feedback associated with the verification field. | +### SMS dialog + +`UserProfileAddSmsDialog` combines number selection with the shared phone-entry and verification steps. + +| Prop | Type | Default | Description | +| ------------------------- | ------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------- | +| `open` | `boolean` | — (required) | Whether the dialog is open. | +| `onOpenChange` | `(open: boolean) => void` | — (required) | Receives open and dismiss requests. | +| `trigger` | `DialogTriggerProps['render']` | — | Trigger that receives focus after dismissal. | +| `step` | `'select' \| 'phone' \| 'verify'` | — (required) | Active screen. | +| `direction` | `1 \| -1` | `1` | Forward or backward transition. | +| `phoneNumbers` | `readonly { id: string; phoneNumber: string }[]` | — (required) | Eligible existing numbers, in display order. | +| `selectedPhoneId` | `string` | — (required) | Selected number ID; an empty string disables Continue. | +| `onSelectedPhoneIdChange` | `(id: string) => void` | — (required) | Receives a Select choice. | +| `onAddPhone` | `() => void` | — (required) | Requests phone entry. | +| `onBack` | `() => void` | — (required) | Requests the previous screen. | +| `phoneNumber` | `string` | — (required) | Phone-entry value or the number being verified. | +| `onPhoneNumberChange` | `(value: string) => void` | — (required) | Receives phone edits. | +| `code` | `string` | — (required) | Current verification code. | +| `onCodeChange` | `(value: string) => void` | — (required) | Receives code edits. | +| `onSubmit` | `(code?: string) => void` | — (required) | Submits the active step. OTP completion supplies the completed code; form submission uses caller state. | +| `onResend` | `() => void` | — (required) | Requests another verification code. | +| `isPending` | `boolean` | `false` | Blocks edits and navigation and shows submit progress. | +| `errorMessage` | `string` | — | Error associated with the active field. | +| `isResending` | `boolean` | `false` | Blocks code entry, verification, resend, and Back while sending. | +| `resendSeconds` | `number` | `0` | Seconds remaining before resend becomes available. | + ## Usage The caller decides whether to mount the section from the instance's second-factor configuration, even when existing methods are present. When mounted, the section stays visible with no methods or callbacks. The caller also prepares the method order, default indicators, and removal permissions, including restrictions when MFA is required. Backup-code rows only offer regeneration. @@ -97,6 +124,29 @@ The caller owns the code, pending state, error feedback, and success closure. Cl ]} /> +### Set up SMS verification + +Choose an existing number with Select or add a new number. New numbers use the same phone-entry and code-verification steps as Add phone number. Back returns to the previous screen; returning from verification to phone entry keeps the entered number. + +In this example, the first existing number is already verified and enables SMS directly. The second existing number and newly entered numbers require a code. Enter any six digits to see an initial error, then choose Verify to retry successfully. Resend shows a countdown and pending feedback. Closing and reopening resets the example. All requests are simulated; no text messages are sent or account changes made. + +The caller supplies eligible numbers and owns selection, navigation, sending codes, enabling SMS, and success closure. It decides whether an existing number needs verification and may open directly on the phone step when there are no existing numbers. The view renders the supplied step and reports actions. Keep dismissal blocked during pending requests, clear errors on edits or retries, and preserve the phone number when going Back. + + + ### Confirm removal and retry Choose Remove method from either row. Cancel keeps the method and returns focus to its menu. The first confirmed removal in this example fails after a short delay; retry completes it. Removing both methods leaves the empty section visible. diff --git a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx index c54485ca8d6..2262f1c6904 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -1,9 +1,11 @@ import { Button } from '@clerk/mosaic/components/button'; import { Text } from '@clerk/mosaic/components/text'; import { UserProfileAddAuthenticatorDialog } from '@clerk/mosaic/features/user-profile/user-profile-add-authenticator.dialog'; +import type { UserProfileAddSmsDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-add-sms.dialog'; +import { UserProfileAddSmsDialog } from '@clerk/mosaic/features/user-profile/user-profile-add-sms.dialog'; import type { UserProfileMfaMethod } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; import { UserProfileMfaSectionView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -151,6 +153,155 @@ export function AuthenticatorSetup() { ); } +export function SmsSetup() { + const phoneNumbers = [ + { id: 'personal', phoneNumber: '+18015550100', verified: true }, + { id: 'work', phoneNumber: '+18015550200', verified: false }, + ]; + const [open, setOpen] = useState(false); + const [step, setStep] = useState('select'); + const [direction, setDirection] = useState<1 | -1>(1); + const [selectedPhoneId, setSelectedPhoneId] = useState('personal'); + const [phoneNumber, setPhoneNumber] = useState(''); + const [code, setCode] = useState(''); + const [verifyFrom, setVerifyFrom] = useState<'select' | 'phone'>('select'); + const [isPending, setIsPending] = useState(false); + const [isResending, setIsResending] = useState(false); + const [resendSeconds, setResendSeconds] = useState(0); + const [errorMessage, setErrorMessage] = useState(); + const [enabledPhone, setEnabledPhone] = useState(''); + const hasFailed = useRef(false); + + useEffect(() => { + if (resendSeconds <= 0) { + return; + } + const timeout = setTimeout(() => setResendSeconds(seconds => seconds - 1), 1000); + return () => clearTimeout(timeout); + }, [resendSeconds]); + + const submit = async () => { + if (isPending || isResending) { + return; + } + setIsPending(true); + setErrorMessage(undefined); + await new Promise(resolve => setTimeout(resolve, 800)); + setIsPending(false); + if (step === 'verify') { + if (!hasFailed.current) { + hasFailed.current = true; + setErrorMessage('That code could not be verified. Please try again.'); + return; + } + setEnabledPhone(phoneNumber); + setOpen(false); + setResendSeconds(0); + return; + } + if (step === 'select') { + const phone = phoneNumbers.find(number => number.id === selectedPhoneId); + if (!phone) { + return; + } + if (phone.verified) { + setEnabledPhone(phone.phoneNumber); + setOpen(false); + return; + } + setPhoneNumber(phone.phoneNumber); + } + setVerifyFrom(step); + setCode(''); + setResendSeconds(12); + setDirection(1); + setStep('verify'); + }; + + const resend = async () => { + if (isPending || isResending || resendSeconds > 0) { + return; + } + setIsResending(true); + setErrorMessage(undefined); + setCode(''); + await new Promise(resolve => setTimeout(resolve, 800)); + setIsResending(false); + setResendSeconds(12); + }; + + return ( +
+ { + if (isPending || isResending) { + return; + } + setOpen(next); + setStep('select'); + setDirection(1); + setSelectedPhoneId('personal'); + setPhoneNumber(''); + setCode(''); + setErrorMessage(undefined); + setResendSeconds(0); + if (next) { + setEnabledPhone(''); + hasFailed.current = false; + } + }} + trigger={ + + } + step={step} + direction={direction} + phoneNumbers={phoneNumbers} + selectedPhoneId={selectedPhoneId} + onSelectedPhoneIdChange={id => { + setSelectedPhoneId(id); + setErrorMessage(undefined); + }} + onAddPhone={() => { + setPhoneNumber(''); + setErrorMessage(undefined); + setDirection(1); + setStep('phone'); + }} + onBack={() => { + setStep(step === 'verify' ? verifyFrom : 'select'); + setDirection(-1); + setCode(''); + setErrorMessage(undefined); + setResendSeconds(0); + }} + phoneNumber={phoneNumber} + onPhoneNumberChange={value => { + setPhoneNumber(value); + setErrorMessage(undefined); + }} + code={code} + onCodeChange={value => { + setCode(value); + setErrorMessage(undefined); + }} + onSubmit={() => void submit()} + onResend={() => void resend()} + isPending={isPending} + isResending={isResending} + resendSeconds={resendSeconds} + errorMessage={errorMessage} + /> + {enabledPhone ? SMS verification enabled for {enabledPhone} in this demo : null} +
+ ); +} + export function Removal() { const [methods, setMethods] = useState([ { id: 'authenticator', type: 'authenticator' }, From 894a1ed114efdcdd5a99caefc4c7260d832757fb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 17 Sep 2026 10:08:35 -0600 Subject: [PATCH 16/38] fix(mosaic): adapt MFA setup to extracted package --- packages/mosaic/package.json | 1 + .../src/features/user-profile/user-profile-add-sms.dialog.tsx | 2 +- .../src/features/user-profile/user-profile-phone.steps.tsx | 2 +- pnpm-lock.yaml | 3 +++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 39f46ab4a99..8f72043e757 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -72,6 +72,7 @@ "@types/react": "catalog:react", "@types/react-dom": "catalog:react", "bundlewatch": "^0.4.2", + "qrcode.react": "4.2.0", "react": "catalog:react", "react-dom": "catalog:react", "tsdown": "catalog:repo" diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx index 424f280c5da..4d4e637e09a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx @@ -1,8 +1,8 @@ +import { stringToFormattedPhoneString } from '@clerk/shared/phone'; import { useMergeRefs } from '@floating-ui/react'; import type { Ref } from 'react'; import { useId, useRef } from 'react'; -import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; import type { DialogTriggerProps } from '../../components/dialog'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx index c09b167472d..6d38cdf923c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx @@ -1,9 +1,9 @@ +import { stringToFormattedPhoneString } from '@clerk/shared/phone'; import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import type { FormEvent, ReactNode, Ref } from 'react'; import { useId } from 'react'; -import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; import { Dialog } from '../../components/dialog'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1b73c7804b..09938ef4720 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -878,6 +878,9 @@ importers: bundlewatch: specifier: ^0.4.2 version: 0.4.2 + qrcode.react: + specifier: 4.2.0 + version: 4.2.0(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 From fb5f11d04586cacb0677cb0a1075ac4e969338b2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 17 Sep 2026 10:15:17 -0600 Subject: [PATCH 17/38] docs(mosaic): describe two-step verification improvements --- .changeset/quiet-mfa-setup.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quiet-mfa-setup.md diff --git a/.changeset/quiet-mfa-setup.md b/.changeset/quiet-mfa-setup.md new file mode 100644 index 00000000000..f5601ae4a69 --- /dev/null +++ b/.changeset/quiet-mfa-setup.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Add Mosaic two-step verification screens for setting up authenticator apps and SMS verification, including QR codes, phone number selection, and verification code entry. Show progress and retry feedback when changing the default verification method or removing a method. From 6fda4da60a5f02d96fc2220064f082a87a4322f5 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 17 Sep 2026 10:34:29 -0600 Subject: [PATCH 18/38] refactor(mosaic): avoid allocating empty MFA method defaults --- .../features/user-profile/user-profile-mfa-section.view.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx index 35810f276c6..d544b9ecebd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx @@ -32,7 +32,7 @@ export interface UserProfileMfaSectionViewProps { export function UserProfileMfaSectionView({ methods, - addableMethods = [], + addableMethods, sectionTitle, onAdd, onRegenerateBackupCodes, @@ -47,7 +47,7 @@ export function UserProfileMfaSectionView({ <> 0 ? ( + onAdd && addableMethods?.length ? ( Date: Thu, 17 Sep 2026 11:45:24 -0600 Subject: [PATCH 19/38] feat(mosaic): add backup codes and connect MFA setup flow --- .changeset/quiet-mfa-setup.md | 2 +- .../user-profile-backup-codes.dialog.test.tsx | 170 +++++++++ .../user-profile-add-authenticator.dialog.tsx | 9 +- .../user-profile-add-sms.dialog.tsx | 4 +- .../user-profile-backup-codes.dialog.tsx | 162 +++++++++ .../user-profile-backup-codes.messages.ts | 12 + .../user-profile-backup-codes.styles.ts | 35 ++ packages/mosaic/src/styles/index.ts | 1 + packages/swingset/package.json | 3 +- packages/swingset/src/lib/registry.ts | 8 - .../stories/fixtures/user-profile-mfa.test.ts | 119 +++++++ .../src/stories/fixtures/user-profile-mfa.ts | 316 +++++++++++++++++ .../src/stories/user-profile-mfa-section.mdx | 91 ++--- .../user-profile-mfa-section.stories.tsx | 333 +++--------------- 14 files changed, 907 insertions(+), 358 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-backup-codes.dialog.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-backup-codes.messages.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts create mode 100644 packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts create mode 100644 packages/swingset/src/stories/fixtures/user-profile-mfa.ts diff --git a/.changeset/quiet-mfa-setup.md b/.changeset/quiet-mfa-setup.md index f5601ae4a69..6f5225c29fa 100644 --- a/.changeset/quiet-mfa-setup.md +++ b/.changeset/quiet-mfa-setup.md @@ -2,4 +2,4 @@ '@clerk/mosaic': patch --- -Add Mosaic two-step verification screens for setting up authenticator apps and SMS verification, including QR codes, phone number selection, and verification code entry. Show progress and retry feedback when changing the default verification method or removing a method. +Add Mosaic two-step verification screens for setting up authenticator apps, SMS verification, and backup codes, with actions to manage verification methods and save or regenerate backup codes. diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx new file mode 100644 index 00000000000..3b9d9fa9457 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx @@ -0,0 +1,170 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import type { UserProfileBackupCodesDialogProps } from '../user-profile-backup-codes.dialog'; +import { UserProfileBackupCodesDialog } from '../user-profile-backup-codes.dialog'; + +const codes = ['pwkkay19', 'cvgunlqs', '4czio578', 'a38eewtw', 'qqnwzvyr', 'znq8j16s']; + +function renderView(overrides: Partial = {}) { + const props: UserProfileBackupCodesDialogProps = { + open: true, + onOpenChange: vi.fn(), + codes, + onRetry: vi.fn(), + onCopy: vi.fn(), + onDownload: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +describe('UserProfileBackupCodesDialog', () => { + it('returns focus to the caller’s target after completing a flow without a dialog trigger', async () => { + const user = userEvent.setup(); + function Example() { + const [open, setOpen] = useState(true); + const target = useRef(null); + return ( + + + setOpen(false)} + /> + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Copy and close' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage verification methods' })).toHaveFocus()); + }); + + it('displays all supplied codes and delegates saving without closing before success', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + expect(screen.getByRole('dialog', { name: 'Save your backup codes' })).toHaveAccessibleDescription( + 'Save these somewhere safe. Each code can be used once if you lose access to your phone.', + ); + const list = screen.getByRole('list', { name: 'Backup codes' }); + expect( + within(list) + .getAllByRole('listitem') + .map(item => item.textContent), + ).toEqual(codes); + await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus()); + expect(props.onRetry).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Download', exact: true })); + expect(props.onDownload).toHaveBeenCalledTimes(1); + expect(props.onOpenChange).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: 'Copy and close' })); + expect(props.onCopy).toHaveBeenCalledTimes(1); + expect(props.onOpenChange).not.toHaveBeenCalled(); + }); + + it('retries failed generation without offering empty codes to save', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ codes: [], pendingAction: 'generate' }); + expect(screen.getByRole('progressbar', { name: 'Generating backup codes' })).toBeInTheDocument(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Copy and close' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Download', exact: true })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Try again' })); + expect(props.onRetry).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + + rerender( + + + , + ); + expect(screen.getByRole('alert')).toHaveTextContent('Unable to generate backup codes. Please try again.'); + await user.click(screen.getByRole('button', { name: 'Try again' })); + expect(props.onRetry).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['copy', 'Copy and close', 'Download', 'Copying backup codes'], + ['download', 'Download', 'Copy and close', 'Downloading backup codes'], + ] as const)( + 'keeps codes available for retry after %s fails and blocks overlapping actions', + async (action, label, otherLabel, pendingLabel) => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ pendingAction: action }); + const dialog = screen.getByRole('dialog'); + const button = screen.getByRole('button', { name: label, exact: true }); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('progressbar', { name: pendingLabel })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: otherLabel, exact: true })).toBeDisabled(); + await user.click(button); + expect(props.onCopy).not.toHaveBeenCalled(); + expect(props.onDownload).not.toHaveBeenCalled(); + + rerender( + + + , + ); + expect(screen.getByRole('dialog')).toBe(dialog); + expect(screen.getByRole('alert')).toHaveTextContent('Unable to save your backup codes. Please try again.'); + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(codes); + await user.click(button); + expect(action === 'copy' ? props.onCopy : props.onDownload).toHaveBeenCalledTimes(1); + }, + ); + + it('replaces old codes during regeneration and renders the newly supplied set', () => { + const { props, rerender } = renderView(); + rerender( + + + , + ); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Download', exact: true })).not.toBeInTheDocument(); + + const replacementCodes = ['newcode1', 'newcode2']; + rerender( + + + , + ); + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(replacementCodes); + expect(screen.queryByText(codes[0])).not.toBeInTheDocument(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx index 029ef4b4c62..1979842d337 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx @@ -2,7 +2,7 @@ import { useId } from 'react'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; -import type { DialogTriggerProps } from '../../components/dialog'; +import type { DialogFocusTarget, DialogTriggerProps } from '../../components/dialog'; import { Dialog } from '../../components/dialog'; import { Field } from '../../components/field'; import { Otp } from '../../components/otp'; @@ -15,6 +15,7 @@ export interface UserProfileAddAuthenticatorDialogProps extends UserProfileAuthe open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; + finalFocus?: DialogFocusTarget; code: string; onCodeChange: (value: string) => void; onSubmit: (code: string) => void; @@ -26,6 +27,7 @@ export function UserProfileAddAuthenticatorDialog({ open, onOpenChange, trigger, + finalFocus, secret, uri, code, @@ -47,7 +49,10 @@ export function UserProfileAddAuthenticatorDialog({ onOpenChange={onOpenChange} > {trigger ? : null} - + void; trigger?: DialogTriggerProps['render']; + finalFocus?: DialogFocusTarget; step: 'select' | 'phone' | 'verify'; direction?: FlowDirection; phoneNumbers: readonly { id: string; phoneNumber: string }[]; @@ -49,6 +50,7 @@ export function UserProfileAddSmsDialog(props: UserProfileAddSmsDialogProps) { void; + trigger?: DialogTriggerProps['render']; + finalFocus?: DialogFocusTarget; + codes: readonly string[]; + onRetry: () => void; + onCopy: () => void; + onDownload: () => void; + pendingAction?: 'generate' | 'copy' | 'download'; + errorMessage?: string; +} + +export function UserProfileBackupCodesDialog({ + open, + onOpenChange, + trigger, + finalFocus, + codes, + onRetry, + onCopy, + onDownload, + pendingAction, + errorMessage, +}: UserProfileBackupCodesDialogProps) { + const hasCodes = codes.length > 0 && pendingAction !== 'generate'; + + return ( + + {trigger ? : null} + + + + {m.title} + {m.description} + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + {hasCodes ? ( +
    + {codes.map(code => ( +
  • + } + color='foreground-secondary' + xstyle={styles.code} + > + {code} + +
  • + ))} +
+ ) : pendingAction === 'generate' ? ( + + {m.generating} + + ) : null} +
+ + {hasCodes ? ( + <> + + + {m.download} + + + + {m.copyAndClose} + + + ) : ( + <> + + } + > + {m.cancel} + + + {m.retry} + + + )} + +
+
+
+ ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.messages.ts new file mode 100644 index 00000000000..1ed1f1961d2 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.messages.ts @@ -0,0 +1,12 @@ +export const userProfileBackupCodesMessages = { + title: 'Save your backup codes', + description: 'Save these somewhere safe. Each code can be used once if you lose access to your phone.', + codesLabel: 'Backup codes', + download: 'Download', + copyAndClose: 'Copy and close', + cancel: 'Cancel', + retry: 'Try again', + generating: 'Generating backup codes', + copying: 'Copying backup codes', + downloading: 'Downloading backup codes', +}; diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts new file mode 100644 index 00000000000..3db5c01604a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts @@ -0,0 +1,35 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + codes: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-background-subtle'], + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + listStyleType: 'none', + }, + cell: { + borderColor: colorVars['--cl-color-border'], + borderStyle: 'solid', + paddingBlock: space['2'], + paddingInline: space['3'], + alignItems: 'center', + borderBlockEndWidth: 0, + borderBlockStartWidth: { default: 0, ':nth-child(n + 3)': '1px' }, + borderInlineEndWidth: { default: 0, ':nth-child(odd)': '1px' }, + borderInlineStartWidth: 0, + display: 'flex', + justifyContent: 'center', + textAlign: 'center', + minWidth: 0, + }, + code: { + overflowWrap: 'anywhere', + }, +}); diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index ba32fd42afe..83fb1dc958b 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -212,3 +212,4 @@ export { mergeStyleProps, themeProps } from '../props'; export { UserProfileMfaSectionView } from '../features/user-profile/user-profile-mfa-section.view'; export { UserProfileAddSmsDialog } from '../features/user-profile/user-profile-add-sms.dialog'; export { UserProfileAddAuthenticatorDialog } from '../features/user-profile/user-profile-add-authenticator.dialog'; +export { UserProfileBackupCodesDialog } from '../features/user-profile/user-profile-backup-codes.dialog'; diff --git a/packages/swingset/package.json b/packages/swingset/package.json index 6d7c64cade8..983fe765a81 100644 --- a/packages/swingset/package.json +++ b/packages/swingset/package.json @@ -7,7 +7,8 @@ "build": "next build", "dev": "next dev --port 6006", "format": "node ../../scripts/format-package.mjs", - "format:check": "node ../../scripts/format-package.mjs --check" + "format:check": "node ../../scripts/format-package.mjs --check", + "test": "vitest run --config ../mosaic/vitest.config.mts --root ." }, "dependencies": { "@base-ui/react": "^1.5.0", diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index b4f64b9f975..726f79956fb 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -305,14 +305,10 @@ import { RequiresAction as UserProfileEnterpriseAccountsSectionRequiresAction, } from '../stories/user-profile-enterprise-accounts-section.stories'; import { - AddMethod as UserProfileMfaSectionAddMethod, - AuthenticatorSetup as UserProfileMfaSectionAuthenticatorSetup, Default as UserProfileMfaSectionDefault, Empty as UserProfileMfaSectionEmpty, meta as userProfileMfaSectionMeta, ReadOnly as UserProfileMfaSectionReadOnly, - Removal as UserProfileMfaSectionRemoval, - SmsSetup as UserProfileMfaSectionSmsSetup, } from '../stories/user-profile-mfa-section.stories'; import { CreationUnavailable as UserProfilePasskeysSectionCreationUnavailable, @@ -679,14 +675,10 @@ const userProfilePasskeysSectionModule: StoryModule = { RecoverableErrors: UserProfilePasskeysSectionRecoverableErrors, }; const userProfileMfaSectionModule: StoryModule = { - AddMethod: UserProfileMfaSectionAddMethod, - AuthenticatorSetup: UserProfileMfaSectionAuthenticatorSetup, - SmsSetup: UserProfileMfaSectionSmsSetup, meta: userProfileMfaSectionMeta, Default: UserProfileMfaSectionDefault, Empty: UserProfileMfaSectionEmpty, ReadOnly: UserProfileMfaSectionReadOnly, - Removal: UserProfileMfaSectionRemoval, }; const userProfileActiveDevicesSectionModule: StoryModule = { meta: userProfileActiveDevicesSectionMeta, diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts new file mode 100644 index 00000000000..53e8512ae46 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts @@ -0,0 +1,119 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useUserProfileMfaFixture } from './user-profile-mfa'; + +function setup() { + const onCopy = vi.fn<(codes: readonly string[]) => Promise>().mockResolvedValue(undefined); + const onDownload = vi.fn<(codes: readonly string[]) => Promise>().mockResolvedValue(undefined); + return { ...renderHook(() => useUserProfileMfaFixture({ onCopy, onDownload })), onCopy, onDownload }; +} + +async function complete(action: () => void) { + await act(async () => { + action(); + await vi.advanceTimersByTimeAsync(1500); + }); +} + +describe('MFA playground', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('enrolls an authenticator on the first attempt, saves backup codes, and regenerates them', async () => { + const { result, onCopy, onDownload } = setup(); + act(() => result.current.section.onAdd?.('authenticator')); + expect(result.current.authenticator.open).toBe(true); + await complete(() => result.current.authenticator.onSubmit('123456')); + expect(result.current.authenticator.open).toBe(false); + expect(result.current.backupCodes.open).toBe(true); + expect(result.current.section.methods.map(method => method.type)).toEqual(['authenticator', 'sms', 'backup-codes']); + expect(result.current.section.addableMethods).toEqual(['sms']); + const codes = result.current.backupCodes.codes; + expect(codes).toHaveLength(10); + await complete(() => result.current.backupCodes.onDownload()); + expect(onDownload).toHaveBeenCalledExactlyOnceWith(codes); + expect(result.current.backupCodes.open).toBe(true); + await complete(() => result.current.backupCodes.onCopy()); + expect(onCopy).toHaveBeenCalledExactlyOnceWith(codes); + expect(result.current.backupCodes.open).toBe(false); + await complete(() => result.current.section.onRegenerateBackupCodes?.()); + expect(result.current.backupCodes.open).toBe(true); + expect(result.current.backupCodes.codes).not.toEqual(codes); + expect(result.current.section.methods.filter(method => method.type === 'backup-codes')).toHaveLength(1); + }); + + it('verifies a new phone, updates the row, and keeps the phone available after removing SMS', async () => { + const { result } = setup(); + act(() => result.current.section.onAdd?.('sms')); + act(() => result.current.sms.onAddPhone()); + act(() => result.current.sms.onPhoneNumberChange('+18015550300')); + await complete(() => result.current.sms.onSubmit()); + expect(result.current.sms.step).toBe('verify'); + await complete(() => result.current.sms.onSubmit('123456')); + expect(result.current.backupCodes.open).toBe(true); + const method = result.current.section.methods.find(method => method.id === 'phone-+18015550300'); + expect(method).toBeDefined(); + if (!method) { + throw new Error('New SMS method missing'); + } + act(() => result.current.backupCodes.onOpenChange(false)); + await complete(() => void result.current.section.onSetDefault?.(method.id)); + expect(result.current.section.methods.find(item => item.isDefault)?.id).toBe(method.id); + await complete(() => void result.current.section.onRemove?.(method.id)); + expect(result.current.section.methods.some(item => item.id === method.id)).toBe(false); + act(() => result.current.section.onAdd?.('sms')); + expect(result.current.sms.phoneNumbers.some(phone => phone.phoneNumber === '+18015550300')).toBe(true); + expect(result.current.section.methods.filter(item => item.isDefault)).toHaveLength(1); + }); + + it('enables a verified existing number directly and verifies an unverified number', async () => { + const { result } = setup(); + act(() => result.current.section.onAdd?.('sms')); + expect(result.current.sms.phoneNumbers.some(phone => phone.id === 'personal')).toBe(false); + act(() => result.current.sms.onSelectedPhoneIdChange('other')); + await complete(() => result.current.sms.onSubmit()); + expect(result.current.sms.open).toBe(false); + expect(result.current.section.methods.some(method => method.id === 'other')).toBe(true); + act(() => result.current.backupCodes.onOpenChange(false)); + act(() => result.current.section.onAdd?.('sms')); + act(() => result.current.sms.onSelectedPhoneIdChange('work')); + await complete(() => result.current.sms.onSubmit()); + expect(result.current.sms.step).toBe('verify'); + await complete(() => result.current.sms.onSubmit('654321')); + expect(result.current.sms.open).toBe(false); + expect(result.current.backupCodes.open).toBe(false); + expect(result.current.section.methods.some(method => method.id === 'work')).toBe(true); + }); + + it('preserves codes on a real copy failure and closes after a successful retry', async () => { + const { result, onCopy } = setup(); + await complete(() => result.current.section.onAdd?.('backup-codes')); + const codes = result.current.backupCodes.codes; + onCopy.mockRejectedValueOnce(new Error('Clipboard unavailable')); + await complete(() => result.current.backupCodes.onCopy()); + expect(result.current.backupCodes.open).toBe(true); + expect(result.current.backupCodes.codes).toEqual(codes); + expect(result.current.backupCodes.errorMessage).toContain('Unable to copy'); + await complete(() => result.current.backupCodes.onCopy()); + expect(result.current.backupCodes.open).toBe(false); + expect(result.current.backupCodes.errorMessage).toBeUndefined(); + }); + + it('cancels enrollment without changing methods and clears backup codes when the last factor is removed', async () => { + const { result } = setup(); + const initialMethods = result.current.section.methods; + act(() => result.current.section.onAdd?.('authenticator')); + act(() => result.current.authenticator.onCodeChange('123')); + act(() => result.current.authenticator.onOpenChange(false)); + expect(result.current.section.methods).toEqual(initialMethods); + act(() => result.current.section.onAdd?.('authenticator')); + expect(result.current.authenticator.code).toBe(''); + act(() => result.current.authenticator.onOpenChange(false)); + await complete(() => result.current.section.onAdd?.('backup-codes')); + act(() => result.current.backupCodes.onOpenChange(false)); + await complete(() => void result.current.section.onRemove?.('personal')); + expect(result.current.section.methods).toEqual([]); + expect(result.current.section.addableMethods).toEqual(['sms', 'authenticator']); + }); +}); diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts new file mode 100644 index 00000000000..586d8aa4927 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts @@ -0,0 +1,316 @@ +import type { UserProfileAddAuthenticatorDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-add-authenticator.dialog'; +import type { UserProfileAddSmsDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-add-sms.dialog'; +import type { UserProfileBackupCodesDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-backup-codes.dialog'; +import type { + UserProfileMfaAddableMethod, + UserProfileMfaMethod, + UserProfileMfaSectionViewProps, +} from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; +import { stringToFormattedPhoneString } from '@clerk/shared/phone'; +import { useEffect, useState } from 'react'; + +interface FixtureOptions { + onCopy: (codes: readonly string[]) => Promise; + onDownload: (codes: readonly string[]) => void | Promise; +} + +const pause = () => new Promise(resolve => setTimeout(resolve, 600)); + +export function useUserProfileMfaFixture({ onCopy, onDownload }: FixtureOptions): { + section: UserProfileMfaSectionViewProps; + authenticator: UserProfileAddAuthenticatorDialogProps; + sms: UserProfileAddSmsDialogProps; + backupCodes: UserProfileBackupCodesDialogProps; +} { + const [account, setAccount] = useState({ + phones: [ + { id: 'personal', phoneNumber: '+18015550100', verified: true, enrolled: true }, + { id: 'work', phoneNumber: '+14165550100', verified: false, enrolled: false }, + { id: 'other', phoneNumber: '+18015550200', verified: true, enrolled: false }, + ], + authenticator: false, + defaultPhoneId: 'personal', + backupGeneration: 0, + }); + const [flow, setFlow] = useState(); + const [pending, setPending] = useState<'submit' | 'resend' | 'generate' | 'copy' | 'download'>(); + const [errorMessage, setErrorMessage] = useState(); + const [code, setCode] = useState(''); + const [codes, setCodes] = useState([]); + const [step, setStep] = useState('select'); + const [direction, setDirection] = useState<1 | -1>(1); + const [verifyFrom, setVerifyFrom] = useState<'select' | 'phone'>('select'); + const [selectedPhoneId, setSelectedPhoneId] = useState(''); + const [phoneNumber, setPhoneNumber] = useState(''); + const [resendSeconds, setResendSeconds] = useState(0); + + useEffect(() => { + if (resendSeconds <= 0) { + return; + } + const timeout = setTimeout(() => setResendSeconds(seconds => seconds - 1), 1000); + return () => clearTimeout(timeout); + }, [resendSeconds]); + + const eligiblePhones = account.phones.filter(phone => !phone.enrolled); + const enrolledPhones = account.phones.filter(phone => phone.enrolled); + const defaultPhoneId = enrolledPhones.some(phone => phone.id === account.defaultPhoneId) + ? account.defaultPhoneId + : enrolledPhones[0]?.id; + const methods: UserProfileMfaMethod[] = [ + ...(account.authenticator ? [{ id: 'authenticator', type: 'authenticator' as const, isDefault: true }] : []), + ...enrolledPhones.map(phone => ({ + id: phone.id, + type: 'sms' as const, + description: stringToFormattedPhoneString(phone.phoneNumber), + isDefault: !account.authenticator && phone.id === defaultPhoneId, + canSetDefault: !account.authenticator && phone.id !== defaultPhoneId, + })), + ...(account.backupGeneration > 0 ? [{ id: 'backup', type: 'backup-codes' as const }] : []), + ]; + const addableMethods: UserProfileMfaAddableMethod[] = ['sms']; + if (!account.authenticator) { + addableMethods.push('authenticator'); + } + if (account.backupGeneration === 0 && (account.authenticator || enrolledPhones.length > 0)) { + addableMethods.push('backup-codes'); + } + + const generate = async () => { + setFlow('backup-codes'); + setPending('generate'); + setErrorMessage(undefined); + setCodes([]); + await pause(); + const generation = account.backupGeneration + 1; + setCodes( + ['pwkkay', 'cvgunl', '4czio5', 'a38eew', 'qqnwzv', 'znq8j1', 'k4ro51', '1gjmkw', 'pnr8i0', 'ycga0j'].map( + value => `${value}${String(generation).padStart(2, '0')}`, + ), + ); + setAccount(current => ({ ...current, backupGeneration: generation })); + setPending(undefined); + }; + + const open = (type: UserProfileMfaAddableMethod) => { + if (pending) { + return; + } + setCode(''); + setErrorMessage(undefined); + setResendSeconds(0); + setPhoneNumber(''); + setSelectedPhoneId(eligiblePhones[0]?.id ?? ''); + setStep(eligiblePhones.length > 0 ? 'select' : 'phone'); + setDirection(1); + setFlow(type); + if (type === 'backup-codes') { + void generate(); + } + }; + + const close = (next: boolean) => { + if (!next && !pending) { + setFlow(undefined); + setResendSeconds(0); + } + }; + + const finishEnrollment = async () => { + setResendSeconds(0); + if (account.backupGeneration === 0) { + await generate(); + } else { + setFlow(undefined); + setPending(undefined); + } + }; + + const verifyAuthenticator = async (value: string) => { + if (pending || !/^\d{6}$/.test(value)) { + return; + } + setPending('submit'); + await pause(); + setAccount(current => ({ ...current, authenticator: true })); + await finishEnrollment(); + }; + + const submitSms = async (value = code) => { + if (pending) { + return; + } + if (step === 'verify' && !/^\d{6}$/.test(value)) { + setErrorMessage('Enter the six-digit verification code.'); + return; + } + const phone = + step === 'select' + ? eligiblePhones.find(item => item.id === selectedPhoneId) + : account.phones.find(item => item.phoneNumber === phoneNumber); + const number = phone?.phoneNumber ?? phoneNumber; + if (!/^\+[1-9]\d{6,14}$/.test(number)) { + setErrorMessage('Enter a valid phone number.'); + return; + } + if (phone?.enrolled) { + setErrorMessage('SMS verification is already enabled for this number.'); + return; + } + setErrorMessage(undefined); + setPending('submit'); + await pause(); + if (step === 'verify' || phone?.verified) { + const enrolled = { id: phone?.id ?? `phone-${number}`, phoneNumber: number, verified: true, enrolled: true }; + setAccount(current => ({ + ...current, + phones: phone + ? current.phones.map(item => (item.id === phone.id ? enrolled : item)) + : [...current.phones, enrolled], + })); + await finishEnrollment(); + return; + } + setPhoneNumber(number); + setVerifyFrom(step); + setCode(''); + setStep('verify'); + setDirection(1); + setResendSeconds(12); + setPending(undefined); + }; + + const resend = async () => { + if (pending || resendSeconds > 0) { + return; + } + setPending('resend'); + setCode(''); + setErrorMessage(undefined); + await pause(); + setPending(undefined); + setResendSeconds(12); + }; + + const save = async (action: 'copy' | 'download') => { + if (pending) { + return; + } + setPending(action); + setErrorMessage(undefined); + try { + await (action === 'copy' ? onCopy(codes) : onDownload(codes)); + if (action === 'copy') { + setFlow(undefined); + } + } catch { + setErrorMessage( + action === 'copy' + ? 'Unable to copy backup codes. Please try again or download them.' + : 'Unable to download backup codes. Please try again or copy them.', + ); + } finally { + setPending(undefined); + } + }; + + const onCodeChange = (value: string) => { + setCode(value); + setErrorMessage(undefined); + }; + + return { + section: { + methods, + addableMethods, + sectionTitle: 'Authentication', + onAdd: open, + onRegenerateBackupCodes: () => open('backup-codes'), + onSetDefault: async id => { + await pause(); + setAccount(current => ({ ...current, defaultPhoneId: id })); + }, + onRemove: async id => { + await pause(); + const phones = account.phones.map(phone => (phone.id === id ? { ...phone, enrolled: false } : phone)); + const authenticator = id === 'authenticator' ? false : account.authenticator; + const hasFactor = authenticator || phones.some(phone => phone.enrolled); + setAccount(current => ({ + ...current, + phones, + authenticator, + backupGeneration: hasFactor ? current.backupGeneration : 0, + })); + if (!hasFactor) { + setCodes([]); + } + }, + }, + authenticator: { + open: flow === 'authenticator', + onOpenChange: close, + secret: 'JBSWY3DPEHPK3PXP', + uri: 'otpauth://totp/Swingset:demo@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Swingset', + code, + onCodeChange, + onSubmit: value => void verifyAuthenticator(value), + isPending: pending === 'submit', + }, + sms: { + open: flow === 'sms', + onOpenChange: close, + step, + direction, + phoneNumbers: eligiblePhones, + selectedPhoneId, + onSelectedPhoneIdChange: id => { + setSelectedPhoneId(id); + setErrorMessage(undefined); + }, + onAddPhone: () => { + setPhoneNumber(''); + setErrorMessage(undefined); + setDirection(1); + setStep('phone'); + }, + onBack: () => { + if (step === 'phone' && eligiblePhones.length === 0) { + close(false); + return; + } + setStep(step === 'verify' ? verifyFrom : 'select'); + setDirection(-1); + setCode(''); + setErrorMessage(undefined); + setResendSeconds(0); + }, + phoneNumber, + onPhoneNumberChange: value => { + setPhoneNumber(value); + setErrorMessage(undefined); + }, + code, + onCodeChange, + onSubmit: value => void submitSms(value), + onResend: () => void resend(), + isPending: pending === 'submit', + isResending: pending === 'resend', + resendSeconds, + errorMessage, + }, + backupCodes: { + open: flow === 'backup-codes', + onOpenChange: close, + codes, + pendingAction: pending === 'generate' || pending === 'copy' || pending === 'download' ? pending : undefined, + errorMessage, + onRetry: () => { + if (!pending) { + void generate(); + } + }, + onCopy: () => void save('copy'), + onDownload: () => void save('download'), + }, + }; +} diff --git a/packages/swingset/src/stories/user-profile-mfa-section.mdx b/packages/swingset/src/stories/user-profile-mfa-section.mdx index ebf81d62680..9a85df610a4 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.mdx +++ b/packages/swingset/src/stories/user-profile-mfa-section.mdx @@ -2,11 +2,13 @@ import * as Stories from './user-profile-mfa-section.stories'; # UserProfileMfaSection -Display two-step verification methods, default badges, and permitted row actions. Add opens a method picker; removal uses shared Confirmation to await the callback and display errors. This view takes prepared data and callbacks; the examples use local state. +Display and manage two-step verification methods. The playground connects setup, backup codes, default selection, and removal using local account state. ## Playground -Choose Set as default on the second SMS number. Its menu shows progress while method actions are blocked. The first attempt fails with an error below that row; retry moves the badge after the update succeeds. +Choose Add to set up an authenticator, enable SMS for an existing or new phone number, or generate backup codes. Enter any six digits to complete verification. Successful enrollment updates the rows and opens backup codes when none exist. Use each row’s menu to change the default, remove a method, or regenerate backup codes. + +Account requests are simulated and succeed by default. Copy and Download save the demo codes to your clipboard or a text file. Reload the page to reset the account. @@ -30,7 +40,7 @@ Choose Set as default on the second SMS number. Its menu shows progress while me | Prop | Type | Default | Description | | ------------------------- | --------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- | | `methods` | `UserProfileMfaMethod[]` | — (required) | Prepared rows, in display order. All supplied rows render. | -| `addableMethods` | `readonly UserProfileMfaAddableMethod[]` | `[]` | Available choices, in display order. Add appears when this is nonempty and `onAdd` is supplied. | +| `addableMethods` | `readonly UserProfileMfaAddableMethod[]` | — | Available choices, in display order. Add appears when this is nonempty and `onAdd` is supplied. | | `sectionTitle` | `string` | — | Optional surrounding heading. | | `onAdd` | `(type: UserProfileMfaAddableMethod) => void` | — | Receives the chosen method immediately when its option is activated. | | `onSetDefault` | `(id: string) => void \| Promise` | — | Changes the default SMS method. Shows pending feedback and reports failures beside the selected row. | @@ -56,6 +66,7 @@ Each method has an `id`, a `type` (`sms`, `authenticator`, or `backup-codes`), a | `open` | `boolean` | — (required) | Whether the dialog is open. | | `onOpenChange` | `(open: boolean) => void` | — (required) | Receives open and dismiss requests. | | `trigger` | `DialogTriggerProps['render']` | — | Optional trigger that receives focus when the dialog closes. | +| `finalFocus` | `DialogFocusTarget` | — | Optional focus destination after a flow opened without its own trigger closes. | | `secret` | `string` | — (required) | Prepared authenticator setup key. | | `uri` | `string` | — (required) | Matching authenticator URI, encoded in the QR code and available for manual entry. | | `code` | `string` | — (required) | Current verification code. | @@ -73,6 +84,7 @@ Each method has an `id`, a `type` (`sms`, `authenticator`, or `backup-codes`), a | `open` | `boolean` | — (required) | Whether the dialog is open. | | `onOpenChange` | `(open: boolean) => void` | — (required) | Receives open and dismiss requests. | | `trigger` | `DialogTriggerProps['render']` | — | Trigger that receives focus after dismissal. | +| `finalFocus` | `DialogFocusTarget` | — | Optional focus destination after a flow opened without its own trigger closes. | | `step` | `'select' \| 'phone' \| 'verify'` | — (required) | Active screen. | | `direction` | `1 \| -1` | `1` | Forward or backward transition. | | `phoneNumbers` | `readonly { id: string; phoneNumber: string }[]` | — (required) | Eligible existing numbers, in display order. | @@ -91,67 +103,42 @@ Each method has an `id`, a `type` (`sms`, `authenticator`, or `backup-codes`), a | `isResending` | `boolean` | `false` | Blocks code entry, verification, resend, and Back while sending. | | `resendSeconds` | `number` | `0` | Seconds remaining before resend becomes available. | -## Usage - -The caller decides whether to mount the section from the instance's second-factor configuration, even when existing methods are present. When mounted, the section stays visible with no methods or callbacks. The caller also prepares the method order, default indicators, and removal permissions, including restrictions when MFA is required. Backup-code rows only offer regeneration. - -Callbacks report user intent; updated props determine the displayed result. Set as default awaits its callback, blocks overlapping method actions, and leaves the current badge in place until `methods` changes. Rejections appear below the selected row and are announced to assistive technology. Retrying clears the error. The section owns one removal confirmation, opened with the selected method. Confirmation owns its pending and error state; update `methods` after a successful removal. Removing SMS verification leaves the phone number on the account. - -### Choose a method - -Open Add and choose a method. Each option is a button with a chevron, matching the reverification picker. This example reports the choice below the section; account enrollment wiring is still being developed. Choosing an option reports it immediately and closes the picker. Closing without choosing a method leaves the selection unchanged; focus returns to Add. Omitting `onAdd` or supplying no choices hides Add while keeping the section visible. +### Backup codes dialog - +`UserProfileBackupCodesDialog` displays newly generated codes and their save actions. -### Set up and verify an authenticator +| Prop | Type | Default | Description | +| --------------- | ------------------------------------ | ------------ | -------------------------------------------------------------------------------- | +| `open` | `boolean` | — (required) | Whether the dialog is open. | +| `onOpenChange` | `(open: boolean) => void` | — (required) | Receives open and dismiss requests. | +| `trigger` | `DialogTriggerProps['render']` | — | Optional trigger that receives focus after dismissal. | +| `finalFocus` | `DialogFocusTarget` | — | Optional focus destination after a flow opened without its own trigger closes. | +| `codes` | `readonly string[]` | — (required) | Generated codes in display order; use an empty array before generation succeeds. | +| `onRetry` | `() => void` | — (required) | Requests another generation attempt. | +| `onCopy` | `() => void` | — (required) | Requests copying the codes. The caller closes the dialog after success. | +| `onDownload` | `() => void` | — (required) | Requests downloading the codes; the dialog remains open. | +| `pendingAction` | `'generate' \| 'copy' \| 'download'` | — | Displays progress and blocks overlapping actions. Generation hides old codes. | +| `errorMessage` | `string` | — | Announced error feedback; save failures leave the current codes visible. | -Open the dialog, scan the QR code or view the read-only setup key and URI, then enter six digits. Typing or pasting a complete code submits automatically. Verify is available for retrying the entered code. Authenticator apps generate their own codes, so there is no resend action. - -The first attempt in this example fails after a short delay. Retry to see the pending state and successful closure. Closing and reopening resets the example. The example uses fixed demo credentials and simulated verification; it does not enroll an authenticator. - -The caller owns the code, pending state, error feedback, and success closure. Clear the error on edit or retry and keep the same setup credentials throughout the attempt. The caller also decides whether to accept dismissal while pending; this example keeps the dialog open until verification finishes. The setup view owns only the QR/manual display toggle. +## Usage - +The caller decides whether to mount the section from the instance's second-factor configuration, even when existing methods are present. When mounted, the section stays visible with no methods or callbacks. The caller also prepares the method order, default indicators, and removal permissions, including restrictions when MFA is required. Backup-code rows only offer regeneration. -### Set up SMS verification +Callbacks report user intent; updated props determine the displayed result. Set as default awaits its callback, blocks overlapping method actions, and leaves the current badge in place until `methods` changes. Rejections appear below the selected row and are announced to assistive technology. Retrying clears the error. The section owns one removal confirmation, opened with the selected method. Confirmation owns its pending and error state; update `methods` after a successful removal. Removing SMS verification leaves the phone number on the account. -Choose an existing number with Select or add a new number. New numbers use the same phone-entry and code-verification steps as Add phone number. Back returns to the previous screen; returning from verification to phone entry keeps the entered number. +### Set up a method -In this example, the first existing number is already verified and enables SMS directly. The second existing number and newly entered numbers require a code. Enter any six digits to see an initial error, then choose Verify to retry successfully. Resend shows a countdown and pending feedback. Closing and reopening resets the example. All requests are simulated; no text messages are sent or account changes made. +The Add picker opens the selected setup dialog. Already enrolled authenticators and backup codes are excluded from the choices. SMS remains available for additional numbers; its select excludes numbers already enrolled. Verified numbers enable SMS directly. Unverified or new numbers require a six-digit code, with Back and Resend available. Cancelling setup leaves the account unchanged. -The caller supplies eligible numbers and owns selection, navigation, sending codes, enabling SMS, and success closure. It decides whether an existing number needs verification and may open directly on the phone step when there are no existing numbers. The view renders the supplied step and reports actions. Keep dismissal blocked during pending requests, clear errors on edits or retries, and preserve the phone number when going Back. +Authenticator setup supports both QR codes and a read-only setup key. Completing setup adds the method to the section. When backup codes have not been generated, enrollment continues to Save your backup codes. Subsequent enrollment preserves existing backup codes. - +### Save and regenerate backup codes -### Confirm removal and retry +Download saves a text file and keeps the dialog open. Copy and close closes after the clipboard write succeeds. A browser failure leaves the codes visible with retry feedback. Regenerate from the backup-code row replaces the displayed set. The caller owns generation, clipboard access, file creation, pending state, and error feedback. -Choose Remove method from either row. Cancel keeps the method and returns focus to its menu. The first confirmed removal in this example fails after a short delay; retry completes it. Removing both methods leaves the empty section visible. +### Manage methods - +Set as default updates the badge after completion. An authenticator takes precedence when enrolled; otherwise the selected SMS method is the default. Remove method opens a confirmation and updates the rows after success. Removing SMS keeps the phone available for re-enrollment. Removing the last authenticator or SMS method clears backup codes and restores the empty section with Add available. ### Read-only methods diff --git a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx index 2262f1c6904..28d2a7ce602 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -1,14 +1,13 @@ -import { Button } from '@clerk/mosaic/components/button'; -import { Text } from '@clerk/mosaic/components/text'; import { UserProfileAddAuthenticatorDialog } from '@clerk/mosaic/features/user-profile/user-profile-add-authenticator.dialog'; -import type { UserProfileAddSmsDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-add-sms.dialog'; import { UserProfileAddSmsDialog } from '@clerk/mosaic/features/user-profile/user-profile-add-sms.dialog'; -import type { UserProfileMfaMethod } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; +import { UserProfileBackupCodesDialog } from '@clerk/mosaic/features/user-profile/user-profile-backup-codes.dialog'; import { UserProfileMfaSectionView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; -import { useEffect, useRef, useState } from 'react'; +import { useRef } from 'react'; import type { StoryMeta } from '@/lib/types'; +import { useUserProfileMfaFixture } from './fixtures/user-profile-mfa'; + export { default as __source } from './user-profile-mfa-section.stories?raw'; export const meta: StoryMeta = { @@ -22,39 +21,45 @@ export const meta: StoryMeta = { }; export function Default() { - const [defaultId, setDefaultId] = useState('personal'); - const hasFailed = useRef(false); - const methods: UserProfileMfaMethod[] = [ - { - id: 'personal', - type: 'sms', - description: '+1 801-555-0100', - isDefault: defaultId === 'personal', - canSetDefault: defaultId !== 'personal', - }, - { - id: 'work', - type: 'sms', - description: '+1 801-555-0200', - isDefault: defaultId === 'work', - canSetDefault: defaultId !== 'work', + const sectionRef = useRef(null); + const fixture = useUserProfileMfaFixture({ + onCopy: codes => navigator.clipboard.writeText(codes.join('\n')), + onDownload: codes => { + const blob = new Blob(['Swingset demo backup codes\n\n', codes.join('\n')], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'swingset-backup-codes.txt'; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); }, - { id: 'backup', type: 'backup-codes' }, - ]; + }); + + const finalFocus = fixture.authenticator.open || fixture.sms.open || fixture.backupCodes.open ? false : sectionRef; return ( - { - await new Promise(resolve => setTimeout(resolve, 1000)); - if (!hasFailed.current) { - hasFailed.current = true; - throw new Error('Unable to update the default method. Please try again.'); - } - setDefaultId(id); - }} - /> + <> +
+ +
+ + + + ); } @@ -71,264 +76,6 @@ export function ReadOnly() { ); } -export function AddMethod() { - const [selection, setSelection] = useState('No method selected'); - const labels = { sms: 'SMS verification', authenticator: 'Authenticator app', 'backup-codes': 'Backup codes' }; - - return ( -
- setSelection(labels[type])} - sectionTitle='Authentication' - /> - {selection} -
- ); -} - -export function AuthenticatorSetup() { - const [open, setOpen] = useState(false); - const [code, setCode] = useState(''); - const [isPending, setIsPending] = useState(false); - const [errorMessage, setErrorMessage] = useState(); - const [verified, setVerified] = useState(false); - const hasFailed = useRef(false); - - const submit = async () => { - if (isPending) { - return; - } - setIsPending(true); - setErrorMessage(undefined); - await new Promise(resolve => setTimeout(resolve, 1000)); - setIsPending(false); - if (!hasFailed.current) { - hasFailed.current = true; - setErrorMessage('That code could not be verified. Please try again.'); - return; - } - setVerified(true); - setOpen(false); - }; - - return ( -
- { - if (isPending) { - return; - } - setOpen(next); - setCode(''); - setErrorMessage(undefined); - if (next) { - setVerified(false); - hasFailed.current = false; - } - }} - trigger={ - - } - secret='JBSWY3DPEHPK3PXP' - uri='otpauth://totp/Swingset:demo@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Swingset' - code={code} - onCodeChange={value => { - setCode(value); - setErrorMessage(undefined); - }} - isPending={isPending} - errorMessage={errorMessage} - onSubmit={() => void submit()} - /> - {verified ? Authenticator verified in this demo : null} -
- ); -} - -export function SmsSetup() { - const phoneNumbers = [ - { id: 'personal', phoneNumber: '+18015550100', verified: true }, - { id: 'work', phoneNumber: '+18015550200', verified: false }, - ]; - const [open, setOpen] = useState(false); - const [step, setStep] = useState('select'); - const [direction, setDirection] = useState<1 | -1>(1); - const [selectedPhoneId, setSelectedPhoneId] = useState('personal'); - const [phoneNumber, setPhoneNumber] = useState(''); - const [code, setCode] = useState(''); - const [verifyFrom, setVerifyFrom] = useState<'select' | 'phone'>('select'); - const [isPending, setIsPending] = useState(false); - const [isResending, setIsResending] = useState(false); - const [resendSeconds, setResendSeconds] = useState(0); - const [errorMessage, setErrorMessage] = useState(); - const [enabledPhone, setEnabledPhone] = useState(''); - const hasFailed = useRef(false); - - useEffect(() => { - if (resendSeconds <= 0) { - return; - } - const timeout = setTimeout(() => setResendSeconds(seconds => seconds - 1), 1000); - return () => clearTimeout(timeout); - }, [resendSeconds]); - - const submit = async () => { - if (isPending || isResending) { - return; - } - setIsPending(true); - setErrorMessage(undefined); - await new Promise(resolve => setTimeout(resolve, 800)); - setIsPending(false); - if (step === 'verify') { - if (!hasFailed.current) { - hasFailed.current = true; - setErrorMessage('That code could not be verified. Please try again.'); - return; - } - setEnabledPhone(phoneNumber); - setOpen(false); - setResendSeconds(0); - return; - } - if (step === 'select') { - const phone = phoneNumbers.find(number => number.id === selectedPhoneId); - if (!phone) { - return; - } - if (phone.verified) { - setEnabledPhone(phone.phoneNumber); - setOpen(false); - return; - } - setPhoneNumber(phone.phoneNumber); - } - setVerifyFrom(step); - setCode(''); - setResendSeconds(12); - setDirection(1); - setStep('verify'); - }; - - const resend = async () => { - if (isPending || isResending || resendSeconds > 0) { - return; - } - setIsResending(true); - setErrorMessage(undefined); - setCode(''); - await new Promise(resolve => setTimeout(resolve, 800)); - setIsResending(false); - setResendSeconds(12); - }; - - return ( -
- { - if (isPending || isResending) { - return; - } - setOpen(next); - setStep('select'); - setDirection(1); - setSelectedPhoneId('personal'); - setPhoneNumber(''); - setCode(''); - setErrorMessage(undefined); - setResendSeconds(0); - if (next) { - setEnabledPhone(''); - hasFailed.current = false; - } - }} - trigger={ - - } - step={step} - direction={direction} - phoneNumbers={phoneNumbers} - selectedPhoneId={selectedPhoneId} - onSelectedPhoneIdChange={id => { - setSelectedPhoneId(id); - setErrorMessage(undefined); - }} - onAddPhone={() => { - setPhoneNumber(''); - setErrorMessage(undefined); - setDirection(1); - setStep('phone'); - }} - onBack={() => { - setStep(step === 'verify' ? verifyFrom : 'select'); - setDirection(-1); - setCode(''); - setErrorMessage(undefined); - setResendSeconds(0); - }} - phoneNumber={phoneNumber} - onPhoneNumberChange={value => { - setPhoneNumber(value); - setErrorMessage(undefined); - }} - code={code} - onCodeChange={value => { - setCode(value); - setErrorMessage(undefined); - }} - onSubmit={() => void submit()} - onResend={() => void resend()} - isPending={isPending} - isResending={isResending} - resendSeconds={resendSeconds} - errorMessage={errorMessage} - /> - {enabledPhone ? SMS verification enabled for {enabledPhone} in this demo : null} -
- ); -} - -export function Removal() { - const [methods, setMethods] = useState([ - { id: 'authenticator', type: 'authenticator' }, - { id: 'sms', type: 'sms', description: '+1 801-555-0100' }, - ]); - const hasFailed = useRef(false); - const hasAuthenticator = methods.some(method => method.type === 'authenticator'); - - return ( - ({ - ...method, - isDefault: method.type === 'authenticator' || !hasAuthenticator, - }))} - sectionTitle='Authentication' - onRemove={async id => { - await new Promise(resolve => setTimeout(resolve, 600)); - if (!hasFailed.current) { - hasFailed.current = true; - throw new Error('Could not remove this method. Please try again.'); - } - setMethods(current => current.filter(method => method.id !== id)); - }} - /> - ); -} - export function Empty() { return ( Date: Thu, 17 Sep 2026 11:58:38 -0600 Subject: [PATCH 20/38] fix(mosaic): restore MFA setup focus to Add button --- .../user-profile-backup-codes.dialog.test.tsx | 17 +++++++++-------- .../user-profile-add-mfa.dialog.tsx | 6 +++++- .../user-profile-mfa-section.view.tsx | 5 ++++- .../src/stories/user-profile-mfa-section.mdx | 1 + .../user-profile-mfa-section.stories.tsx | 16 ++++++---------- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx index 3b9d9fa9457..49250f174bf 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; import type { UserProfileBackupCodesDialogProps } from '../user-profile-backup-codes.dialog'; import { UserProfileBackupCodesDialog } from '../user-profile-backup-codes.dialog'; +import { UserProfileMfaSectionView } from '../user-profile-mfa-section.view'; const codes = ['pwkkay19', 'cvgunlqs', '4czio578', 'a38eewtw', 'qqnwzvyr', 'znq8j16s']; @@ -30,19 +31,19 @@ function renderView(overrides: Partial = {}) } describe('UserProfileBackupCodesDialog', () => { - it('returns focus to the caller’s target after completing a flow without a dialog trigger', async () => { + it('returns focus to the section’s Add button after completing a flow without a dialog trigger', async () => { const user = userEvent.setup(); function Example() { const [open, setOpen] = useState(true); const target = useRef(null); return ( - + { } render(); await user.click(screen.getByRole('button', { name: 'Copy and close' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage verification methods' })).toHaveFocus()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Add verification method' })).toHaveFocus()); }); it('displays all supplied codes and delegates saving without closing before success', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx index a14ed9d0fba..8f982b27d87 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx @@ -1,3 +1,5 @@ +import type { Ref } from 'react'; + import { Button } from '../../components/button'; import { Card } from '../../components/card'; import { Dialog } from '../../components/dialog'; @@ -10,6 +12,7 @@ interface UserProfileAddMfaDialogProps { methods: readonly UserProfileMfaAddableMethod[]; onSelect: (type: UserProfileMfaAddableMethod) => void; disabled?: boolean; + triggerRef?: Ref; } const icons = { @@ -18,10 +21,11 @@ const icons = { 'backup-codes': 'numbers', } as const; -export function UserProfileAddMfaDialog({ methods, onSelect, disabled }: UserProfileAddMfaDialogProps) { +export function UserProfileAddMfaDialog({ methods, onSelect, disabled, triggerRef }: UserProfileAddMfaDialogProps) { return ( ; sectionTitle?: string; onAdd?: (type: UserProfileMfaAddableMethod) => void; onRegenerateBackupCodes?: () => void; @@ -33,6 +34,7 @@ export interface UserProfileMfaSectionViewProps { export function UserProfileMfaSectionView({ methods, addableMethods, + addButtonRef, sectionTitle, onAdd, onRegenerateBackupCodes, @@ -49,6 +51,7 @@ export function UserProfileMfaSectionView({ addControl={ onAdd && addableMethods?.length ? ( ` | — | Ref to the Add button for restoring focus after setup dialogs close. | | `onAdd` | `(type: UserProfileMfaAddableMethod) => void` | — | Receives the chosen method immediately when its option is activated. | | `onSetDefault` | `(id: string) => void \| Promise` | — | Changes the default SMS method. Shows pending feedback and reports failures beside the selected row. | | `onRemove` | `(id: string) => void \| Promise` | — | Called after confirmation. Resolve to close; reject with an Error to show the failure. | diff --git a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx index 28d2a7ce602..e2d73c9ccfa 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -21,7 +21,7 @@ export const meta: StoryMeta = { }; export function Default() { - const sectionRef = useRef(null); + const addButtonRef = useRef(null); const fixture = useUserProfileMfaFixture({ onCopy: codes => navigator.clipboard.writeText(codes.join('\n')), onDownload: codes => { @@ -35,18 +35,14 @@ export function Default() { }, }); - const finalFocus = fixture.authenticator.open || fixture.sms.open || fixture.backupCodes.open ? false : sectionRef; + const finalFocus = fixture.authenticator.open || fixture.sms.open || fixture.backupCodes.open ? false : addButtonRef; return ( <> -
- -
+ Date: Thu, 17 Sep 2026 13:55:03 -0600 Subject: [PATCH 21/38] fix(mosaic): refine MFA backup code setup and examples --- .changeset/quiet-mfa-setup.md | 2 + .../user-profile-backup-codes.dialog.test.tsx | 13 ++ .../user-profile-mfa-section.view.test.tsx | 36 ++--- .../user-profile-add-mfa.dialog.tsx | 1 - .../user-profile-backup-codes.dialog.tsx | 20 ++- .../user-profile-backup-codes.styles.ts | 17 ++ .../user-profile-mfa-section.messages.ts | 1 - .../user-profile-mfa-section.view.tsx | 2 +- .../stories/fixtures/user-profile-mfa.test.ts | 153 ++++++++++++++++-- .../src/stories/fixtures/user-profile-mfa.ts | 74 +++++---- .../src/stories/user-profile-mfa-section.mdx | 16 +- .../user-profile-mfa-section.stories.tsx | 25 +++ 12 files changed, 282 insertions(+), 78 deletions(-) diff --git a/.changeset/quiet-mfa-setup.md b/.changeset/quiet-mfa-setup.md index 6f5225c29fa..1d039674644 100644 --- a/.changeset/quiet-mfa-setup.md +++ b/.changeset/quiet-mfa-setup.md @@ -3,3 +3,5 @@ --- Add Mosaic two-step verification screens for setting up authenticator apps, SMS verification, and backup codes, with actions to manage verification methods and save or regenerate backup codes. + +When enrollment supplies backup codes, setup adds their row automatically and opens the Save your backup codes screen after SMS or authenticator verification. Backup codes are not offered in Add; existing codes can be regenerated from their row. diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx index 49250f174bf..b9f506e589a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx @@ -31,6 +31,17 @@ function renderView(overrides: Partial = {}) } describe('UserProfileBackupCodesDialog', () => { + it('shows the save-backup-codes step', () => { + renderView(); + expect(screen.getByRole('dialog', { name: 'Save your backup codes' })).toHaveAccessibleDescription( + 'Save these somewhere safe. Each code can be used once if you lose access to your phone.', + ); + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(codes); + expect(screen.getByRole('button', { name: 'Download', exact: true })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Copy and close' })).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Save backup codes' })).not.toBeInTheDocument(); + }); + it('returns focus to the section’s Add button after completing a flow without a dialog trigger', async () => { const user = userEvent.setup(); function Example() { @@ -88,6 +99,8 @@ describe('UserProfileBackupCodesDialog', () => { const user = userEvent.setup(); const { props, rerender } = renderView({ codes: [], pendingAction: 'generate' }); expect(screen.getByRole('progressbar', { name: 'Generating backup codes' })).toBeInTheDocument(); + const loading = screen.getByRole('status', { name: 'Generating backup codes' }); + expect(loading.textContent).toBe(''); expect(screen.queryByRole('list')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Copy and close' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Download', exact: true })).not.toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx index 3294620afd6..57fc904c111 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx @@ -29,27 +29,25 @@ function renderView(overrides: Partial = {}) { } describe('MFA section', () => { - it.each(['sms', 'authenticator', 'backup-codes'] as const)( - 'continues immediately when the %s option is activated', - async type => { - const user = userEvent.setup(); - const { props } = renderView({ - methods: [{ id: 'existing', type: 'sms', description: '+1 801-555-0100' }], - addableMethods: ['sms', 'authenticator', 'backup-codes'], - }); - const labels = { sms: 'SMS verification', authenticator: 'Authenticator app', 'backup-codes': 'Backup codes' }; + it.each(['sms', 'authenticator'] as const)('continues immediately when the %s option is activated', async type => { + const user = userEvent.setup(); + const { props } = renderView({ + methods: [{ id: 'existing', type: 'sms', description: '+1 801-555-0100' }], + addableMethods: ['sms', 'authenticator'], + }); + const labels = { sms: 'SMS verification', authenticator: 'Authenticator app' }; - await user.click(screen.getByRole('button', { name: 'Add verification method' })); - const dialog = screen.getByRole('dialog', { name: 'Add 2-step verification' }); - expect(dialog).toHaveAccessibleDescription('Choose a verification method'); - expect(within(dialog).queryByRole('button', { name: 'Continue' })).not.toBeInTheDocument(); - await user.click(within(dialog).getByRole('button', { name: new RegExp(labels[type]) })); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + const dialog = screen.getByRole('dialog', { name: 'Add 2-step verification' }); + expect(dialog).toHaveAccessibleDescription('Choose a verification method'); + expect(within(dialog).queryByRole('button', { name: /Backup codes/ })).not.toBeInTheDocument(); + expect(within(dialog).queryByRole('button', { name: 'Continue' })).not.toBeInTheDocument(); + await user.click(within(dialog).getByRole('button', { name: new RegExp(labels[type]) })); - expect(props.onAdd).toHaveBeenCalledExactlyOnceWith(type); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(screen.getByText('+1 801-555-0100')).toBeVisible(); - }, - ); + expect(props.onAdd).toHaveBeenCalledExactlyOnceWith(type); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(screen.getByText('+1 801-555-0100')).toBeVisible(); + }); it.each(['authenticator', 'sms'] as const)('displays the supplied default state for %s', type => { const { props, rerender } = renderView({ methods: [{ id: 'method_1', type, isDefault: true }] }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx index 8f982b27d87..1ccd25669ac 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx @@ -18,7 +18,6 @@ interface UserProfileAddMfaDialogProps { const icons = { sms: 'security-phone', authenticator: 'security-lock-square', - 'backup-codes': 'numbers', } as const; export function UserProfileAddMfaDialog({ methods, onSelect, disabled, triggerRef }: UserProfileAddMfaDialogProps) { diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.dialog.tsx index 103e3d28367..e39880ca88e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.dialog.tsx @@ -87,12 +87,24 @@ export function UserProfileBackupCodesDialog({ ))} ) : pendingAction === 'generate' ? ( - - {m.generating} - + {Array.from({ length: 10 }, (_, index) => ( + + ))} + ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts index 3db5c01604a..f1191d9f9c0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts @@ -2,7 +2,24 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, radiusVars, space } from '../../tokens.stylex'; +const pulse = stylex.keyframes({ + '50%': { opacity: 0.5 }, +}); + export const styles = stylex.create({ + skeleton: { + borderRadius: radiusVars['--cl-radius-sm'], + animationDuration: '2s', + animationIterationCount: 'infinite', + animationName: { + default: pulse, + '@media (prefers-reduced-motion: reduce)': 'none', + }, + animationTimingFunction: 'cubic-bezier(0.4, 0, 0.6, 1)', + backgroundColor: colorVars['--cl-color-neutral-alpha-200'], + height: '1lh', + width: space['16'], + }, codes: { borderColor: colorVars['--cl-color-border'], borderRadius: radiusVars['--cl-radius-md'], diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts index 58589732a9c..fb2ae003cff 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts @@ -21,7 +21,6 @@ export const userProfileMfaMessages = { methods: { sms: 'Get a code by text message', authenticator: 'Get codes from an authenticator app', - 'backup-codes': 'One-time codes to use if you lose access', }, }, removeDialog: { diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx index ce6f6f8fe48..ec0b2298eec 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx @@ -18,7 +18,7 @@ export interface UserProfileMfaMethod { canSetDefault?: boolean; } -export type UserProfileMfaAddableMethod = UserProfileMfaMethod['type']; +export type UserProfileMfaAddableMethod = 'sms' | 'authenticator'; export interface UserProfileMfaSectionViewProps { methods: UserProfileMfaMethod[]; diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts index 53e8512ae46..31805c8ccc6 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts @@ -1,12 +1,26 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, render, renderHook, screen } from '@testing-library/react'; +import { createElement } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { UserProfileBackupCodesDialog } from '../../../../mosaic/src/features/user-profile/user-profile-backup-codes.dialog'; +import { MosaicProvider } from '../../../../mosaic/src/MosaicProvider'; import { useUserProfileMfaFixture } from './user-profile-mfa'; -function setup() { +const enrollmentCodes = ['enrollment-code-1', 'enrollment-code-2']; +const regeneratedCodes = ['regenerated-code-1', 'regenerated-code-2']; + +function setup(enrollmentBackupCodes: readonly string[] = enrollmentCodes) { const onCopy = vi.fn<(codes: readonly string[]) => Promise>().mockResolvedValue(undefined); const onDownload = vi.fn<(codes: readonly string[]) => Promise>().mockResolvedValue(undefined); - return { ...renderHook(() => useUserProfileMfaFixture({ onCopy, onDownload })), onCopy, onDownload }; + const onRegenerateBackupCodes = vi.fn<() => Promise>().mockResolvedValue(regeneratedCodes); + return { + ...renderHook(() => + useUserProfileMfaFixture({ enrollmentBackupCodes, onRegenerateBackupCodes, onCopy, onDownload }), + ), + onCopy, + onDownload, + onRegenerateBackupCodes, + }; } async function complete(action: () => void) { @@ -20,17 +34,52 @@ describe('MFA playground', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); + it.each(['sms', 'authenticator'] as const)('opens Save your backup codes after %s enrollment', async type => { + const { result } = setup(); + act(() => result.current.section.onAdd?.(type)); + if (type === 'authenticator') { + await complete(() => result.current.authenticator.onSubmit('123456')); + } else { + act(() => result.current.sms.onSelectedPhoneIdChange('other')); + await complete(() => result.current.sms.onSubmit()); + } + expect(result.current.sms.open).toBe(false); + expect(result.current.authenticator.open).toBe(false); + expect(result.current.backupCodes.open).toBe(true); + render( + createElement(MosaicProvider, null, createElement(UserProfileBackupCodesDialog, result.current.backupCodes)), + ); + expect(screen.getByRole('dialog', { name: 'Save your backup codes' })).toBeVisible(); + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(enrollmentCodes); + expect(screen.getByRole('button', { name: 'Download', exact: true })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Copy and close' })).toBeVisible(); + }); + + it('automatically adds backup codes during enrollment without offering them in Add', async () => { + const { result } = setup(); + expect(result.current.section.addableMethods).toEqual(['sms', 'authenticator']); + act(() => result.current.section.onAdd?.('authenticator')); + await complete(() => result.current.authenticator.onSubmit('123456')); + expect(result.current.section.methods.map(method => method.type)).toEqual(['authenticator', 'sms', 'backup-codes']); + const codes = result.current.backupCodes.codes; + expect(codes).toEqual(enrollmentCodes); + expect(result.current.backupCodes.open).toBe(true); + expect(result.current.backupCodes.codes).toEqual(codes); + }); + it('enrolls an authenticator on the first attempt, saves backup codes, and regenerates them', async () => { - const { result, onCopy, onDownload } = setup(); + const { result, onCopy, onDownload, onRegenerateBackupCodes } = setup(); act(() => result.current.section.onAdd?.('authenticator')); expect(result.current.authenticator.open).toBe(true); await complete(() => result.current.authenticator.onSubmit('123456')); expect(result.current.authenticator.open).toBe(false); expect(result.current.backupCodes.open).toBe(true); + expect(result.current.backupCodes.codes).toEqual(enrollmentCodes); expect(result.current.section.methods.map(method => method.type)).toEqual(['authenticator', 'sms', 'backup-codes']); expect(result.current.section.addableMethods).toEqual(['sms']); const codes = result.current.backupCodes.codes; - expect(codes).toHaveLength(10); + expect(codes).toEqual(enrollmentCodes); + expect(onRegenerateBackupCodes).not.toHaveBeenCalled(); await complete(() => result.current.backupCodes.onDownload()); expect(onDownload).toHaveBeenCalledExactlyOnceWith(codes); expect(result.current.backupCodes.open).toBe(true); @@ -39,7 +88,8 @@ describe('MFA playground', () => { expect(result.current.backupCodes.open).toBe(false); await complete(() => result.current.section.onRegenerateBackupCodes?.()); expect(result.current.backupCodes.open).toBe(true); - expect(result.current.backupCodes.codes).not.toEqual(codes); + expect(result.current.backupCodes.codes).toEqual(regeneratedCodes); + expect(onRegenerateBackupCodes).toHaveBeenCalledOnce(); expect(result.current.section.methods.filter(method => method.type === 'backup-codes')).toHaveLength(1); }); @@ -88,7 +138,8 @@ describe('MFA playground', () => { it('preserves codes on a real copy failure and closes after a successful retry', async () => { const { result, onCopy } = setup(); - await complete(() => result.current.section.onAdd?.('backup-codes')); + act(() => result.current.section.onAdd?.('authenticator')); + await complete(() => result.current.authenticator.onSubmit('123456')); const codes = result.current.backupCodes.codes; onCopy.mockRejectedValueOnce(new Error('Clipboard unavailable')); await complete(() => result.current.backupCodes.onCopy()); @@ -100,7 +151,7 @@ describe('MFA playground', () => { expect(result.current.backupCodes.errorMessage).toBeUndefined(); }); - it('cancels enrollment without changing methods and clears backup codes when the last factor is removed', async () => { + it('cancels enrollment without changing methods and clears the code before reopening', () => { const { result } = setup(); const initialMethods = result.current.section.methods; act(() => result.current.section.onAdd?.('authenticator')); @@ -109,11 +160,91 @@ describe('MFA playground', () => { expect(result.current.section.methods).toEqual(initialMethods); act(() => result.current.section.onAdd?.('authenticator')); expect(result.current.authenticator.code).toBe(''); - act(() => result.current.authenticator.onOpenChange(false)); - await complete(() => result.current.section.onAdd?.('backup-codes')); + }); + + it.each(['authenticator', 'sms'] as const)('removes backup codes when the last %s method is removed', async type => { + const { result } = setup(); + act(() => result.current.section.onAdd?.('authenticator')); + await complete(() => result.current.authenticator.onSubmit('123456')); act(() => result.current.backupCodes.onOpenChange(false)); - await complete(() => void result.current.section.onRemove?.('personal')); + const firstId = type === 'authenticator' ? 'personal' : 'authenticator'; + const lastId = type === 'authenticator' ? 'authenticator' : 'personal'; + await complete(() => void result.current.section.onRemove?.(firstId)); + expect(result.current.section.methods.map(method => method.type)).toEqual([type, 'backup-codes']); + expect(result.current.backupCodes.codes).toEqual(enrollmentCodes); + + await complete(() => void result.current.section.onRemove?.(lastId)); expect(result.current.section.methods).toEqual([]); + expect(result.current.backupCodes.codes).toEqual([]); + expect(result.current.backupCodes.open).toBe(false); + expect(result.current.section.onRegenerateBackupCodes).toBeUndefined(); expect(result.current.section.addableMethods).toEqual(['sms', 'authenticator']); + + act(() => result.current.section.onAdd?.('authenticator')); + await complete(() => result.current.authenticator.onSubmit('123456')); + expect(result.current.section.methods.map(method => method.type)).toEqual(['authenticator', 'backup-codes']); + expect(result.current.backupCodes.open).toBe(true); + expect(result.current.backupCodes.codes).toEqual(enrollmentCodes); + }); + + it('keeps enrollment and backup codes when dismissed during regeneration', async () => { + const { result } = setup(); + act(() => result.current.section.onAdd?.('authenticator')); + await complete(() => result.current.authenticator.onSubmit('123456')); + expect(result.current.backupCodes.open).toBe(true); + expect(result.current.section.methods.some(method => method.type === 'authenticator')).toBe(true); + act(() => result.current.backupCodes.onOpenChange(false)); + expect(result.current.backupCodes.open).toBe(false); + expect(result.current.section.methods.some(method => method.type === 'authenticator')).toBe(true); + expect(result.current.section.addableMethods).toEqual(['sms']); + expect(result.current.section.methods.some(method => method.type === 'backup-codes')).toBe(true); + expect(result.current.backupCodes.codes).toEqual(enrollmentCodes); + await complete(() => result.current.section.onRegenerateBackupCodes?.()); + expect(result.current.section.methods.some(method => method.type === 'authenticator')).toBe(true); + expect(result.current.backupCodes.open).toBe(true); }); + + it.each(['authenticator', 'sms'] as const)( + 'finishes %s enrollment without backup codes when none are supplied', + async type => { + const { result } = setup([]); + act(() => result.current.section.onAdd?.(type)); + if (type === 'authenticator') { + await complete(() => result.current.authenticator.onSubmit('123456')); + } else { + act(() => result.current.sms.onSelectedPhoneIdChange('other')); + await complete(() => result.current.sms.onSubmit()); + } + expect(result.current.section.methods.some(method => method.type === type)).toBe(true); + expect(result.current.section.methods.some(method => method.type === 'backup-codes')).toBe(false); + expect(result.current.section.addableMethods).not.toContain('backup-codes'); + expect(result.current.backupCodes.open).toBe(false); + expect(result.current.backupCodes.codes).toEqual([]); + expect(result.current.section.onRegenerateBackupCodes).toBeUndefined(); + }, + ); + + it.each(['rejection', 'empty result'])( + 'retries backup-code regeneration after %s without presenting old codes as new', + async failure => { + const { result, onRegenerateBackupCodes } = setup(); + act(() => result.current.section.onAdd?.('authenticator')); + await complete(() => result.current.authenticator.onSubmit('123456')); + act(() => result.current.backupCodes.onOpenChange(false)); + if (failure === 'rejection') { + onRegenerateBackupCodes.mockRejectedValueOnce(new Error('Try again')); + } else { + onRegenerateBackupCodes.mockResolvedValueOnce([]); + } + await complete(() => result.current.section.onRegenerateBackupCodes?.()); + expect(result.current.backupCodes.open).toBe(true); + expect(result.current.backupCodes.codes).toEqual([]); + expect(result.current.backupCodes.errorMessage).toContain('Unable to regenerate'); + expect(result.current.section.methods.some(method => method.type === 'backup-codes')).toBe(true); + await complete(() => result.current.backupCodes.onRetry()); + expect(result.current.backupCodes.codes).toEqual(regeneratedCodes); + expect(result.current.backupCodes.errorMessage).toBeUndefined(); + expect(result.current.backupCodes.pendingAction).toBeUndefined(); + }, + ); }); diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts index 586d8aa4927..99c31efa107 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts @@ -10,13 +10,20 @@ import { stringToFormattedPhoneString } from '@clerk/shared/phone'; import { useEffect, useState } from 'react'; interface FixtureOptions { + enrollmentBackupCodes?: readonly string[]; + onRegenerateBackupCodes: () => Promise; onCopy: (codes: readonly string[]) => Promise; onDownload: (codes: readonly string[]) => void | Promise; } const pause = () => new Promise(resolve => setTimeout(resolve, 600)); -export function useUserProfileMfaFixture({ onCopy, onDownload }: FixtureOptions): { +export function useUserProfileMfaFixture({ + enrollmentBackupCodes, + onRegenerateBackupCodes, + onCopy, + onDownload, +}: FixtureOptions): { section: UserProfileMfaSectionViewProps; authenticator: UserProfileAddAuthenticatorDialogProps; sms: UserProfileAddSmsDialogProps; @@ -30,13 +37,13 @@ export function useUserProfileMfaFixture({ onCopy, onDownload }: FixtureOptions) ], authenticator: false, defaultPhoneId: 'personal', - backupGeneration: 0, + hasBackupCodes: false, }); - const [flow, setFlow] = useState(); + const [flow, setFlow] = useState(); const [pending, setPending] = useState<'submit' | 'resend' | 'generate' | 'copy' | 'download'>(); const [errorMessage, setErrorMessage] = useState(); const [code, setCode] = useState(''); - const [codes, setCodes] = useState([]); + const [codes, setCodes] = useState([]); const [step, setStep] = useState('select'); const [direction, setDirection] = useState<1 | -1>(1); const [verifyFrom, setVerifyFrom] = useState<'select' | 'phone'>('select'); @@ -66,30 +73,31 @@ export function useUserProfileMfaFixture({ onCopy, onDownload }: FixtureOptions) isDefault: !account.authenticator && phone.id === defaultPhoneId, canSetDefault: !account.authenticator && phone.id !== defaultPhoneId, })), - ...(account.backupGeneration > 0 ? [{ id: 'backup', type: 'backup-codes' as const }] : []), + ...(account.hasBackupCodes ? [{ id: 'backup', type: 'backup-codes' as const }] : []), ]; const addableMethods: UserProfileMfaAddableMethod[] = ['sms']; if (!account.authenticator) { addableMethods.push('authenticator'); } - if (account.backupGeneration === 0 && (account.authenticator || enrolledPhones.length > 0)) { - addableMethods.push('backup-codes'); - } - - const generate = async () => { + const regenerate = async () => { + if (pending || !account.hasBackupCodes) { + return; + } setFlow('backup-codes'); setPending('generate'); setErrorMessage(undefined); setCodes([]); - await pause(); - const generation = account.backupGeneration + 1; - setCodes( - ['pwkkay', 'cvgunl', '4czio5', 'a38eew', 'qqnwzv', 'znq8j1', 'k4ro51', '1gjmkw', 'pnr8i0', 'ycga0j'].map( - value => `${value}${String(generation).padStart(2, '0')}`, - ), - ); - setAccount(current => ({ ...current, backupGeneration: generation })); - setPending(undefined); + try { + const nextCodes = await onRegenerateBackupCodes(); + if (nextCodes.length === 0) { + throw new Error('No backup codes returned'); + } + setCodes(nextCodes); + } catch { + setErrorMessage('Unable to regenerate backup codes. Please try again.'); + } finally { + setPending(undefined); + } }; const open = (type: UserProfileMfaAddableMethod) => { @@ -104,9 +112,6 @@ export function useUserProfileMfaFixture({ onCopy, onDownload }: FixtureOptions) setStep(eligiblePhones.length > 0 ? 'select' : 'phone'); setDirection(1); setFlow(type); - if (type === 'backup-codes') { - void generate(); - } }; const close = (next: boolean) => { @@ -116,13 +121,15 @@ export function useUserProfileMfaFixture({ onCopy, onDownload }: FixtureOptions) } }; - const finishEnrollment = async () => { + const finishEnrollment = () => { setResendSeconds(0); - if (account.backupGeneration === 0) { - await generate(); + setPending(undefined); + if (!account.hasBackupCodes && enrollmentBackupCodes?.length) { + setCodes(enrollmentBackupCodes); + setAccount(current => ({ ...current, hasBackupCodes: true })); + setFlow('backup-codes'); } else { setFlow(undefined); - setPending(undefined); } }; @@ -133,7 +140,7 @@ export function useUserProfileMfaFixture({ onCopy, onDownload }: FixtureOptions) setPending('submit'); await pause(); setAccount(current => ({ ...current, authenticator: true })); - await finishEnrollment(); + finishEnrollment(); }; const submitSms = async (value = code) => { @@ -168,7 +175,7 @@ export function useUserProfileMfaFixture({ onCopy, onDownload }: FixtureOptions) ? current.phones.map(item => (item.id === phone.id ? enrolled : item)) : [...current.phones, enrolled], })); - await finishEnrollment(); + finishEnrollment(); return; } setPhoneNumber(number); @@ -225,7 +232,7 @@ export function useUserProfileMfaFixture({ onCopy, onDownload }: FixtureOptions) addableMethods, sectionTitle: 'Authentication', onAdd: open, - onRegenerateBackupCodes: () => open('backup-codes'), + onRegenerateBackupCodes: account.hasBackupCodes ? () => void regenerate() : undefined, onSetDefault: async id => { await pause(); setAccount(current => ({ ...current, defaultPhoneId: id })); @@ -234,15 +241,16 @@ export function useUserProfileMfaFixture({ onCopy, onDownload }: FixtureOptions) await pause(); const phones = account.phones.map(phone => (phone.id === id ? { ...phone, enrolled: false } : phone)); const authenticator = id === 'authenticator' ? false : account.authenticator; - const hasFactor = authenticator || phones.some(phone => phone.enrolled); + const hasSecondFactor = authenticator || phones.some(phone => phone.enrolled); setAccount(current => ({ ...current, phones, authenticator, - backupGeneration: hasFactor ? current.backupGeneration : 0, + hasBackupCodes: current.hasBackupCodes && hasSecondFactor, })); - if (!hasFactor) { + if (!hasSecondFactor) { setCodes([]); + setFlow(undefined); } }, }, @@ -306,7 +314,7 @@ export function useUserProfileMfaFixture({ onCopy, onDownload }: FixtureOptions) errorMessage, onRetry: () => { if (!pending) { - void generate(); + void regenerate(); } }, onCopy: () => void save('copy'), diff --git a/packages/swingset/src/stories/user-profile-mfa-section.mdx b/packages/swingset/src/stories/user-profile-mfa-section.mdx index 4f4a5874b5e..2423f669390 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.mdx +++ b/packages/swingset/src/stories/user-profile-mfa-section.mdx @@ -6,9 +6,9 @@ Display and manage two-step verification methods. The playground connects setup, ## Playground -Choose Add to set up an authenticator, enable SMS for an existing or new phone number, or generate backup codes. Enter any six digits to complete verification. Successful enrollment updates the rows and opens backup codes when none exist. Use each row’s menu to change the default, remove a method, or regenerate backup codes. +Choose Add, then click SMS verification or Authenticator app to start setup. Enter any six digits to complete verification. Successful enrollment updates the rows. When the instance enables backup codes, enrollment supplies them automatically; both methods open the Save your backup codes screen. Use each row’s menu to change the default, remove a method, or regenerate backup codes. -Account requests are simulated and succeed by default. Copy and Download save the demo codes to your clipboard or a text file. Reload the page to reset the account. +Account requests are simulated with fixed demo responses and succeed by default. Copy and Download save the demo codes to your clipboard or a text file. Reload the page to reset the account. (null); const fixture = useUserProfileMfaFixture({ + enrollmentBackupCodes: [ + 'pwkkay19', + 'cvgunlqs', + '4czio578', + 'a38eewtw', + 'qqnwzvyr', + 'znq8j16s', + 'k4ro51h1', + '1gjmkwdb', + 'pnr8i06f', + 'ycga0jge', + ], + onRegenerateBackupCodes: () => + Promise.resolve([ + 'demo-new-01', + 'demo-new-02', + 'demo-new-03', + 'demo-new-04', + 'demo-new-05', + 'demo-new-06', + 'demo-new-07', + 'demo-new-08', + 'demo-new-09', + 'demo-new-10', + ]), onCopy: codes => navigator.clipboard.writeText(codes.join('\n')), onDownload: codes => { const blob = new Blob(['Swingset demo backup codes\n\n', codes.join('\n')], { type: 'text/plain' }); From eae79ca223623da27b822cc9967ba6ee97a7f7c4 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 17 Sep 2026 15:57:14 -0600 Subject: [PATCH 22/38] refactor(mosaic): extract reusable MFA setup views and align behavior --- .changeset/quiet-mfa-setup.md | 5 - ...-profile-add-authenticator.dialog.test.tsx | 136 ++++++++++- .../user-profile-add-mfa.view.test.tsx | 67 ++++++ .../user-profile-mfa-cards.view.test.tsx | 154 ++++++++++++ .../user-profile-mfa-section.view.test.tsx | 80 +++++++ .../user-profile-security-panel.view.test.tsx | 17 ++ .../user-profile-add-authenticator.dialog.tsx | 92 +------ ...user-profile-add-authenticator.messages.ts | 2 + .../user-profile-add-authenticator.view.tsx | 145 +++++++++++ .../user-profile-add-mfa.dialog.tsx | 52 ++-- .../user-profile-add-mfa.view.tsx | 56 +++++ .../user-profile-add-sms.dialog.tsx | 196 ++------------- .../user-profile-add-sms.view.tsx | 179 ++++++++++++++ ...er-profile-authenticator-setup.messages.ts | 5 + .../user-profile-authenticator-setup.view.tsx | 71 ++++-- .../user-profile-backup-codes.dialog.tsx | 147 +----------- .../user-profile-backup-codes.view.tsx | 154 ++++++++++++ .../user-profile-mfa-section.messages.ts | 1 + .../user-profile-mfa-section.view.tsx | 11 +- .../user-profile-security-panel.view.tsx | 5 +- .../user-profile-authenticator.test.ts | 30 +++ .../fixtures/user-profile-authenticator.ts | 66 +++++ .../stories/fixtures/user-profile-mfa.test.ts | 226 ++++++++++++++++-- .../src/stories/fixtures/user-profile-mfa.ts | 131 ++++++++-- .../user-profile-mfa-section.stories.tsx | 2 +- 25 files changed, 1527 insertions(+), 503 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-add-mfa.view.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-add-mfa.view.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-add-sms.view.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx create mode 100644 packages/swingset/src/stories/fixtures/user-profile-authenticator.test.ts create mode 100644 packages/swingset/src/stories/fixtures/user-profile-authenticator.ts diff --git a/.changeset/quiet-mfa-setup.md b/.changeset/quiet-mfa-setup.md index 1d039674644..a845151cc84 100644 --- a/.changeset/quiet-mfa-setup.md +++ b/.changeset/quiet-mfa-setup.md @@ -1,7 +1,2 @@ --- -'@clerk/mosaic': patch --- - -Add Mosaic two-step verification screens for setting up authenticator apps, SMS verification, and backup codes, with actions to manage verification methods and save or regenerate backup codes. - -When enrollment supplies backup codes, setup adds their row automatically and opens the Save your backup codes screen after SMS or authenticator verification. Backup codes are not offered in Add; existing codes can be regenerated from their row. diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx index 90eedff1c18..471acdb42ce 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx @@ -14,7 +14,8 @@ const setup = { function renderView(overrides: Partial = {}) { const props: UserProfileAddAuthenticatorDialogProps = { - ...setup, + setup, + onRetry: vi.fn(), open: true, onOpenChange: vi.fn(), code: '', @@ -37,7 +38,8 @@ function VerificationExample({ onSubmit }: Pick undefined} open onOpenChange={() => undefined} code={code} @@ -49,6 +51,132 @@ function VerificationExample({ onSubmit }: Pick { + it.each([undefined, 'Unable to prepare your authenticator.'])( + 'allows cancellation during preparation: %s', + async setupErrorMessage => { + const user = userEvent.setup(); + const { props } = renderView({ setup: undefined, setupErrorMessage }); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false); + expect(props.onRetry).not.toHaveBeenCalled(); + expect(props.onSubmit).not.toHaveBeenCalled(); + }, + ); + + it('copies either manual credential through the caller and displays controlled copy feedback', async () => { + const user = userEvent.setup(); + const onCopy = vi.fn(); + const { props, rerender } = renderView({ onCopy }); + await user.click(screen.getByRole('button', { name: 'Can’t scan? View setup key' })); + const copyKey = screen.getByRole('button', { name: 'Copy setup key' }); + const copyUri = screen.getByRole('button', { name: 'Copy setup URI' }); + await user.click(copyKey); + expect(onCopy).toHaveBeenCalledExactlyOnceWith(setup.secret); + expect(props.onSubmit).not.toHaveBeenCalled(); + + rerender( + + + , + ); + expect(copyKey).toHaveFocus(); + expect(copyKey).toHaveAttribute('aria-disabled', 'true'); + expect(copyUri).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('status', { name: 'Copy feedback' })).toHaveTextContent('Copying…'); + await user.click(copyUri); + await user.keyboard('{Enter}'); + expect(onCopy).toHaveBeenCalledOnce(); + + rerender( + + + , + ); + expect(screen.getByRole('alert')).toHaveTextContent('Could not copy. Please try again.'); + expect(screen.getByRole('textbox', { name: 'Setup key' })).toHaveValue(setup.secret); + await user.click(copyUri); + expect(onCopy).toHaveBeenLastCalledWith(setup.uri); + + rerender( + + + , + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('status', { name: 'Copy feedback' })).toHaveTextContent('Copied'); + expect(screen.getByRole('dialog')).toBeVisible(); + expect(props.onOpenChange).not.toHaveBeenCalled(); + }); + + it('shows preparation, offers retry on failure, and waits for setup data before verification', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ setup: undefined }); + const dialog = screen.getByRole('dialog', { name: 'Add an authenticator app' }); + expect(screen.getByRole('status', { name: 'Preparing authenticator…' })).toBeVisible(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Verify', exact: true })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled(); + + rerender( + + + , + ); + expect(screen.getByRole('alert')).toHaveTextContent('Unable to prepare your authenticator.'); + expect(screen.queryByRole('status', { name: 'Preparing authenticator…' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Try again' })); + expect(props.onRetry).toHaveBeenCalledOnce(); + expect(props.onSubmit).not.toHaveBeenCalled(); + + rerender( + + + , + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('status', { name: 'Preparing authenticator…' })).toBeVisible(); + expect(screen.getByRole('button', { name: /Preparing authenticator/ })).toHaveFocus(); + + rerender( + + + , + ); + expect(screen.getByRole('dialog')).toBe(dialog); + expect(screen.queryByRole('status', { name: 'Preparing authenticator…' })).not.toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Verify', exact: true })).toHaveFocus(); + expect(screen.getByRole('button', { name: 'Verify', exact: true })).toHaveAttribute('aria-disabled', 'true'); + await user.keyboard('{Enter}'); + expect(props.onSubmit).not.toHaveBeenCalled(); + rerender( + + + , + ); + expect(props.onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); + }); + it.each(['typing', 'pasting'] as const)('submits a complete authenticator code after %s', async method => { const user = userEvent.setup(); const onSubmit = vi.fn(); @@ -86,7 +214,7 @@ describe('UserProfileAddAuthenticatorDialog', () => { expect(props.onSubmit).toHaveBeenLastCalledWith('654321'); await user.click(screen.getByRole('button', { name: 'Cancel' })); - expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + expect(props.onOpenChange).toHaveBeenCalledWith(false); expect(props.onSubmit).toHaveBeenCalledTimes(2); }); @@ -94,7 +222,7 @@ describe('UserProfileAddAuthenticatorDialog', () => { const user = userEvent.setup(); const { props, rerender } = renderView({ code: '123' }); const verify = screen.getByRole('button', { name: 'Verify', exact: true }); - expect(verify).toBeDisabled(); + expect(verify).toHaveAttribute('aria-disabled', 'true'); await user.click(screen.getByRole('textbox', { name: 'Verification code' })); await user.keyboard('{Enter}'); expect(props.onSubmit).not.toHaveBeenCalled(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-mfa.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-mfa.view.test.tsx new file mode 100644 index 00000000000..7a0669dfae7 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-mfa.view.test.tsx @@ -0,0 +1,67 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Card } from '../../../components/card'; +import { Dialog } from '../../../components/dialog'; +import { Flow } from '../../../components/flow'; +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileAddAuthenticatorView } from '../user-profile-add-authenticator.view'; +import { UserProfileAddMfaView } from '../user-profile-add-mfa.view'; + +describe('MFA selection', () => { + it('continues into setup within the same dialog', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + function Example() { + const [step, setStep] = useState('select'); + return ( + + + + + + {() => ( + <> + + + + + + + + )} + + + + + + ); + } + render(); + const dialog = screen.getByRole('dialog', { name: 'Add 2-step verification' }); + expect(within(dialog).queryByRole('button', { name: /SMS verification/ })).not.toBeInTheDocument(); + await user.click(within(dialog).getByRole('button', { name: /Authenticator app/ })); + await waitFor(() => expect(screen.getByRole('dialog', { name: 'Add an authenticator app' })).toBe(dialog)); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(within(dialog).getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx new file mode 100644 index 00000000000..7b4ac1ca41f --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx @@ -0,0 +1,154 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Card } from '../../../components/card'; +import { Flow } from '../../../components/flow'; +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileAddAuthenticatorView } from '../user-profile-add-authenticator.view'; +import { UserProfileAddSmsView } from '../user-profile-add-sms.view'; +import { UserProfileBackupCodesView } from '../user-profile-backup-codes.view'; + +describe('MFA cards', () => { + it.each(['select', 'phone'] as const)('focuses the %s field when entering SMS from another card', async step => { + function Example() { + const [active, setActive] = useState('start'); + return ( + + + + {() => ( + <> + + + + + + + + )} + + + + ); + } + render(); + await userEvent.click(screen.getByRole('button', { name: 'Start SMS' })); + await waitFor(() => { + expect(screen.getByRole(step === 'select' ? 'combobox' : 'textbox', { name: /Phone/ })).toHaveFocus(); + }); + }); + + it('saves backup codes on a card and supports cancelling a failed generation', async () => { + const user = userEvent.setup(); + const props = { codes: ['demo-code'], onCopy: vi.fn(), onDownload: vi.fn(), onRetry: vi.fn(), onCancel: vi.fn() }; + const { rerender } = render( + + + + + , + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Download', exact: true })); + expect(props.onDownload).toHaveBeenCalledOnce(); + await user.click(screen.getByRole('button', { name: 'Copy and close', exact: true })); + expect(props.onCopy).toHaveBeenCalledOnce(); + rerender( + + + + + , + ); + await user.click(screen.getByRole('button', { name: 'Try again', exact: true })); + expect(props.onRetry).toHaveBeenCalledOnce(); + await user.click(screen.getByRole('button', { name: 'Cancel', exact: true })); + expect(props.onCancel).toHaveBeenCalledOnce(); + }); + + it('renders authenticator setup on a card and delegates verification and cancellation', async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + const onSubmit = vi.fn(); + render( + + + + + , + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Verify', exact: true })); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); + await user.click(screen.getByRole('button', { name: 'Cancel', exact: true })); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('renders SMS selection without a dialog and delegates cancellation', async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + const onSubmit = vi.fn(); + render( + + + + + , + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Continue', exact: true })); + expect(onSubmit).toHaveBeenCalledOnce(); + await user.click(screen.getByRole('button', { name: 'Cancel', exact: true })); + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx index 57fc904c111..e6971dca118 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx @@ -29,6 +29,58 @@ function renderView(overrides: Partial = {}) { } describe('MFA section', () => { + it('uses a supplied setup trigger without opening a separate selection dialog', async () => { + const user = userEvent.setup(); + const onOpen = vi.fn(); + const { props } = renderView({ + addControl: ( + + ), + }); + await user.click(screen.getByRole('button', { name: 'Set up verification' })); + expect(onOpen).toHaveBeenCalledOnce(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add verification method' })).not.toBeInTheDocument(); + expect(props.onAdd).not.toHaveBeenCalled(); + }); + + it('hides removal for protected SMS while keeping backup regeneration available', async () => { + const user = userEvent.setup(); + const { props } = renderView({ + methods: [ + { id: 'phone', type: 'sms', description: '+1 801-555-0100', isDefault: true, canRemove: false }, + { id: 'backup', type: 'backup-codes' }, + ], + }); + expect(screen.getByText('+1 801-555-0100')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Manage SMS verification +1 801-555-0100' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); + expect(screen.getAllByRole('menuitem')).toHaveLength(1); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + expect(props.onRegenerateBackupCodes).toHaveBeenCalledOnce(); + expect(props.onRemove).not.toHaveBeenCalled(); + }); + + it('allows default selection for protected SMS without offering removal', async () => { + const user = userEvent.setup(); + const { props } = renderView({ + methods: [ + { id: 'totp', type: 'authenticator', isDefault: true }, + { id: 'phone', type: 'sms', description: '+1 801-555-0100', canSetDefault: true, canRemove: false }, + ], + }); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0100' })); + expect(screen.getAllByRole('menuitem')).toHaveLength(1); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + expect(props.onSetDefault).toHaveBeenCalledExactlyOnceWith('phone'); + expect(props.onRemove).not.toHaveBeenCalled(); + }); + it.each(['sms', 'authenticator'] as const)('continues immediately when the %s option is activated', async type => { const user = userEvent.setup(); const { props } = renderView({ @@ -118,6 +170,34 @@ describe('MFA section', () => { expect(screen.queryByRole('button', { name: 'Add verification method' })).not.toBeInTheDocument(); }); + it('adds caller-enabled backup codes and offers only regeneration after the row is supplied', async () => { + const user = userEvent.setup(); + const methods: UserProfileMfaMethod[] = [{ id: 'personal', type: 'sms', isDefault: true }]; + const { props, rerender } = renderView({ methods, addableMethods: ['backup-codes'] }); + + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + const picker = screen.getByRole('dialog', { name: 'Add 2-step verification' }); + await user.click(within(picker).getByRole('button', { name: /Backup codes One-time codes/ })); + expect(props.onAdd).toHaveBeenCalledExactlyOnceWith('backup-codes'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + rerender( + + + , + ); + expect(screen.queryByRole('button', { name: 'Add verification method' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); + expect(screen.getAllByRole('menuitem')).toHaveLength(1); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + expect(props.onRegenerateBackupCodes).toHaveBeenCalledOnce(); + expect(props.onRemove).not.toHaveBeenCalled(); + }); + it('confirms the selected SMS method and restores focus when removal is cancelled', async () => { const user = userEvent.setup(); const { props } = renderView({ diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 323f68208e2..257800c1a11 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -57,6 +57,23 @@ function renderView(overrides: Partial = {}) } describe('UserProfileSecurityPanelView', () => { + it('passes a shared MFA setup control into the section', async () => { + const onOpen = vi.fn(); + renderView({ + mfaAddControl: ( + + ), + }); + await userEvent.click(screen.getByRole('button', { name: 'Set up MFA' })); + expect(onOpen).toHaveBeenCalledOnce(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + it('composes authentication, active devices, and the danger zone', () => { renderView({ onDeleteAccount: vi.fn(() => Promise.resolve()) }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx index 1979842d337..2441e52182f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx @@ -1,26 +1,14 @@ -import { useId } from 'react'; - -import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; import type { DialogFocusTarget, DialogTriggerProps } from '../../components/dialog'; import { Dialog } from '../../components/dialog'; -import { Field } from '../../components/field'; -import { Otp } from '../../components/otp'; -import { userProfileAddAuthenticatorMessages as m } from './user-profile-add-authenticator.messages'; -import { styles } from './user-profile-add-authenticator.styles'; -import type { UserProfileAuthenticatorSetupViewProps } from './user-profile-authenticator-setup.view'; -import { UserProfileAuthenticatorSetupView } from './user-profile-authenticator-setup.view'; +import type { UserProfileAddAuthenticatorViewProps } from './user-profile-add-authenticator.view'; +import { UserProfileAddAuthenticatorView } from './user-profile-add-authenticator.view'; -export interface UserProfileAddAuthenticatorDialogProps extends UserProfileAuthenticatorSetupViewProps { +export interface UserProfileAddAuthenticatorDialogProps extends Omit { open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; finalFocus?: DialogFocusTarget; - code: string; - onCodeChange: (value: string) => void; - onSubmit: (code: string) => void; - isPending?: boolean; - errorMessage?: string; } export function UserProfileAddAuthenticatorDialog({ @@ -28,21 +16,8 @@ export function UserProfileAddAuthenticatorDialog({ onOpenChange, trigger, finalFocus, - secret, - uri, - code, - onCodeChange, - onSubmit, - isPending = false, - errorMessage, + ...props }: UserProfileAddAuthenticatorDialogProps) { - const formId = useId(); - const submitCode = (value: string) => { - if (!isPending && value.length === 6) { - onSubmit(value); - } - }; - return ( - onOpenChange(false)} /> - { - event.preventDefault(); - submitCode(code); - }} - /> - } - > - - {m.codeLabel} - - - {errorMessage} - - - - - - } - > - {m.cancel} - - - {m.verify} - -
diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts index 53199532806..c819f312f6f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts @@ -3,4 +3,6 @@ export const userProfileAddAuthenticatorMessages = { cancel: 'Cancel', verify: 'Verify', pending: 'Verifying code', + preparing: 'Preparing authenticator…', + retry: 'Try again', }; diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx new file mode 100644 index 00000000000..92da21a39c1 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx @@ -0,0 +1,145 @@ +import { useId } from 'react'; + +import { Banner } from '../../components/banner'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { Field } from '../../components/field'; +import { useFlowAutoFocus } from '../../components/flow'; +import { Otp } from '../../components/otp'; +import { Text } from '../../components/text'; +import { userProfileAddAuthenticatorMessages as m } from './user-profile-add-authenticator.messages'; +import { styles } from './user-profile-add-authenticator.styles'; +import { userProfileAuthenticatorSetupMessages as setupMessages } from './user-profile-authenticator-setup.messages'; +import type { UserProfileAuthenticatorSetupViewProps } from './user-profile-authenticator-setup.view'; +import { UserProfileAuthenticatorSetupView } from './user-profile-authenticator-setup.view'; + +export interface UserProfileAddAuthenticatorViewProps extends Omit< + UserProfileAuthenticatorSetupViewProps, + 'secret' | 'uri' +> { + setup?: { secret: string; uri: string }; + setupErrorMessage?: string; + onRetry: () => void; + onCancel: () => void; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code: string) => void; + isPending?: boolean; + errorMessage?: string; +} + +export function UserProfileAddAuthenticatorView({ + onCancel, + setup, + setupErrorMessage, + onRetry, + onCopy, + copyStatus, + copyErrorMessage, + code, + onCodeChange, + onSubmit, + isPending = false, + errorMessage, +}: UserProfileAddAuthenticatorViewProps) { + const formId = useId(); + const actionRef = useFlowAutoFocus(); + const submitCode = (value: string) => { + if (setup && !isPending && value.length === 6) { + onSubmit(value); + } + }; + + return ( + <> + {setup ? ( + + ) : ( + <> + + {setupMessages.title} + + + {setupErrorMessage ? ( + + {setupErrorMessage} + + ) : ( + + {m.preparing} + + )} + + + )} + {setup ? ( + { + event.preventDefault(); + submitCode(code); + }} + /> + } + > + + {m.codeLabel} + + + {errorMessage} + + + + ) : null} + + + + {setup ? m.verify : setupErrorMessage ? m.retry : m.preparing} + + + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx index 1ccd25669ac..deacc6ccb8d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx @@ -1,10 +1,10 @@ -import type { Ref } from 'react'; +import { type Ref, useState } from 'react'; import { Button } from '../../components/button'; import { Card } from '../../components/card'; import { Dialog } from '../../components/dialog'; -import { Icon, IconFrame } from '../../components/icon'; -import { Item } from '../../components/item'; +import { Icon } from '../../components/icon'; +import { UserProfileAddMfaView } from './user-profile-add-mfa.view'; import { userProfileMfaMessages as m } from './user-profile-mfa-section.messages'; import type { UserProfileMfaAddableMethod } from './user-profile-mfa-section.view'; @@ -15,14 +15,13 @@ interface UserProfileAddMfaDialogProps { triggerRef?: Ref; } -const icons = { - sms: 'security-phone', - authenticator: 'security-lock-square', -} as const; - export function UserProfileAddMfaDialog({ methods, onSelect, disabled, triggerRef }: UserProfileAddMfaDialogProps) { + const [open, setOpen] = useState(false); return ( - + - - {m.addDialog.title} - {m.addDialog.description} - - - - {methods.map(type => ( - onSelect(type)} />} - > - - - - - - - {m.methods[type]} - {m.addDialog.methods[type]} - - - - - - ))} - - + { + onSelect(type); + setOpen(false); + }} + />
diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-mfa.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-mfa.view.tsx new file mode 100644 index 00000000000..aff17516e2d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-mfa.view.tsx @@ -0,0 +1,56 @@ +import { Card } from '../../components/card'; +import { Icon, IconFrame } from '../../components/icon'; +import { Item } from '../../components/item'; +import { userProfileMfaMessages as m } from './user-profile-mfa-section.messages'; +import type { UserProfileMfaAddableMethod } from './user-profile-mfa-section.view'; + +export interface UserProfileAddMfaViewProps { + methods: readonly UserProfileMfaAddableMethod[]; + onSelect: (type: UserProfileMfaAddableMethod) => void; +} + +const icons = { + sms: 'security-phone', + authenticator: 'security-lock-square', + 'backup-codes': 'numbers', +} as const; + +export function UserProfileAddMfaView({ methods, onSelect }: UserProfileAddMfaViewProps) { + return ( + <> + + {m.addDialog.title} + {m.addDialog.description} + + + + {methods.map(type => ( + onSelect(type)} + /> + } + > + + + + + + + {m.methods[type]} + {m.addDialog.methods[type]} + + + + + + ))} + + + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx index a80391dd8e1..0c6c7322989 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx @@ -1,202 +1,54 @@ -import { stringToFormattedPhoneString } from '@clerk/shared/phone'; -import { useMergeRefs } from '@floating-ui/react'; -import type { Ref } from 'react'; -import { useId, useRef } from 'react'; +import { useRef } from 'react'; -import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; import type { DialogFocusTarget, DialogTriggerProps } from '../../components/dialog'; import { Dialog } from '../../components/dialog'; -import { Field } from '../../components/field'; -import { Flow, type FlowDirection, useFlowAutoFocus } from '../../components/flow'; -import { Select } from '../../components/select'; -import { userProfileAddSmsMessages as m } from './user-profile-add-sms.messages'; -import { EnterPhoneStep, VerifyPhoneStep } from './user-profile-phone.steps'; +import type { UserProfileAddSmsViewProps } from './user-profile-add-sms.view'; +import { UserProfileAddSmsView } from './user-profile-add-sms.view'; -export interface UserProfileAddSmsDialogProps { +export interface UserProfileAddSmsDialogProps extends Omit< + UserProfileAddSmsViewProps, + 'onCancel' | 'selectRef' | 'phoneRef' +> { open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; finalFocus?: DialogFocusTarget; - step: 'select' | 'phone' | 'verify'; - direction?: FlowDirection; - phoneNumbers: readonly { id: string; phoneNumber: string }[]; - selectedPhoneId: string; - onSelectedPhoneIdChange: (id: string) => void; - onAddPhone: () => void; - onBack: () => void; - phoneNumber: string; - onPhoneNumberChange: (value: string) => void; - code: string; - onCodeChange: (value: string) => void; - onSubmit: (code?: string) => void; - onResend: () => void; - isPending?: boolean; - errorMessage?: string; - isResending?: boolean; - resendSeconds?: number; } -export function UserProfileAddSmsDialog(props: UserProfileAddSmsDialogProps) { +export function UserProfileAddSmsDialog({ + open, + onOpenChange, + trigger, + finalFocus, + ...props +}: UserProfileAddSmsDialogProps) { const selectRef = useRef(null); const phoneRef = useRef(null); return ( - {props.trigger ? : null} + {trigger ? : null} - - {current => { - const backAction = ( - - ); - return ( - <> - - - - - - - - - - - ); - }} - + onOpenChange(false)} + selectRef={selectRef} + phoneRef={phoneRef} + /> ); } - -function SelectPhoneStep(props: UserProfileAddSmsDialogProps & { inputRef: Ref }) { - const formId = useId(); - const inputRef = useMergeRefs([props.inputRef, useFlowAutoFocus()]); - return ( - <> - - {m.title} - {m.description} - - { - event.preventDefault(); - if (!props.isPending && props.selectedPhoneId) { - props.onSubmit(); - } - }} - /> - } - > - - {m.phoneLabel} - ({ - value: phone.id, - label: stringToFormattedPhoneString(phone.phoneNumber), - }))} - value={props.selectedPhoneId} - onValueChange={props.onSelectedPhoneIdChange} - > - - - - - {props.errorMessage} - - - - - - - } - > - {m.cancel} - - - {m.continue} - - - - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-sms.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-sms.view.tsx new file mode 100644 index 00000000000..69088d3942e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-sms.view.tsx @@ -0,0 +1,179 @@ +import { stringToFormattedPhoneString } from '@clerk/shared/phone'; +import { useMergeRefs } from '@floating-ui/react'; +import type { Ref } from 'react'; +import { useId } from 'react'; + +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { Field } from '../../components/field'; +import { Flow, type FlowDirection, useFlowAutoFocus } from '../../components/flow'; +import { Select } from '../../components/select'; +import { userProfileAddSmsMessages as m } from './user-profile-add-sms.messages'; +import { EnterPhoneStep, VerifyPhoneStep } from './user-profile-phone.steps'; + +export interface UserProfileAddSmsViewProps { + onCancel: () => void; + selectRef?: Ref; + phoneRef?: Ref; + step: 'select' | 'phone' | 'verify'; + direction?: FlowDirection; + phoneNumbers: readonly { id: string; phoneNumber: string }[]; + selectedPhoneId: string; + onSelectedPhoneIdChange: (id: string) => void; + onAddPhone: () => void; + onBack: () => void; + phoneNumber: string; + onPhoneNumberChange: (value: string) => void; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code?: string) => void; + onResend: () => void; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + resendSeconds?: number; +} + +export function UserProfileAddSmsView(props: UserProfileAddSmsViewProps) { + const selectRef = useMergeRefs([props.selectRef, useFlowAutoFocus()]); + const phoneRef = useMergeRefs([props.phoneRef, useFlowAutoFocus()]); + return ( + + {current => { + const backAction = ( + + ); + return ( + <> + + + + + + + + + + + ); + }} + + ); +} + +function SelectPhoneStep(props: UserProfileAddSmsViewProps & { inputRef?: Ref }) { + const formId = useId(); + const inputRef = useMergeRefs([props.inputRef, useFlowAutoFocus()]); + return ( + <> + + {m.title} + {m.description} + + { + event.preventDefault(); + if (!props.isPending && props.selectedPhoneId) { + props.onSubmit(); + } + }} + /> + } + > + + {m.phoneLabel} + ({ + value: phone.id, + label: stringToFormattedPhoneString(phone.phoneNumber), + }))} + value={props.selectedPhoneId} + onValueChange={props.onSelectedPhoneIdChange} + > + + + + + {props.errorMessage} + + + + + + + + {m.continue} + + + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.messages.ts index a6590d8ddce..0f047012855 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.messages.ts @@ -5,6 +5,11 @@ export const userProfileAuthenticatorSetupMessages = { qrCodeLabel: 'Authenticator setup QR code', setupKey: 'Setup key', setupUri: 'Setup URI', + copyKey: 'Copy setup key', + copyUri: 'Copy setup URI', + copyFeedback: 'Copy feedback', + copying: 'Copying…', + copied: 'Copied', viewSetupKey: 'Can’t scan? View setup key', scanQrCode: 'Scan QR code instead', }; diff --git a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.view.tsx index 5b0ce86b4dc..0d8eb01fe58 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.view.tsx @@ -2,19 +2,31 @@ import * as stylex from '@stylexjs/stylex'; import { QRCodeSVG } from 'qrcode.react'; import { useState } from 'react'; +import { Banner } from '../../components/banner'; import { Button } from '../../components/button'; import { Card } from '../../components/card'; import { Field } from '../../components/field'; -import { Input } from '../../components/input'; +import { Icon } from '../../components/icon'; +import { InputGroup } from '../../components/input-group'; +import { Text } from '../../components/text'; import { userProfileAuthenticatorSetupMessages as m } from './user-profile-authenticator-setup.messages'; import { styles } from './user-profile-authenticator-setup.styles'; export interface UserProfileAuthenticatorSetupViewProps { secret: string; uri: string; + onCopy?: (value: string) => void; + copyStatus?: 'pending' | 'success'; + copyErrorMessage?: string; } -export function UserProfileAuthenticatorSetupView({ secret, uri }: UserProfileAuthenticatorSetupViewProps) { +export function UserProfileAuthenticatorSetupView({ + secret, + uri, + onCopy, + copyStatus, + copyErrorMessage, +}: UserProfileAuthenticatorSetupViewProps) { const [showSetupKey, setShowSetupKey] = useState(false); return ( @@ -26,20 +38,47 @@ export function UserProfileAuthenticatorSetupView({ secret, uri }: UserProfileAu {showSetupKey ? ( <> - - {m.setupKey} - - - - {m.setupUri} - - + {[ + { label: m.setupKey, value: secret, copyLabel: m.copyKey }, + { label: m.setupUri, value: uri, copyLabel: m.copyUri }, + ].map(({ label, value, copyLabel }) => ( + + {label} + + + {onCopy ? ( + + + + ) : null} + + + ))} + {copyErrorMessage ? ( + + {copyErrorMessage} + + ) : null} + + {copyStatus === 'pending' ? m.copying : copyStatus === 'success' ? m.copied : null} + ) : (
diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.dialog.tsx index e39880ca88e..6a95600ba03 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.dialog.tsx @@ -1,28 +1,14 @@ -import * as stylex from '@stylexjs/stylex'; - -import { Banner } from '../../components/banner'; -import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; import type { DialogFocusTarget, DialogTriggerProps } from '../../components/dialog'; import { Dialog } from '../../components/dialog'; -import { Icon } from '../../components/icon'; -import { Text } from '../../components/text'; -import { mergeStyleProps, themeProps } from '../../props'; -import { reset } from '../../utils/reset.styles'; -import { userProfileBackupCodesMessages as m } from './user-profile-backup-codes.messages'; -import { styles } from './user-profile-backup-codes.styles'; +import type { UserProfileBackupCodesViewProps } from './user-profile-backup-codes.view'; +import { UserProfileBackupCodesView } from './user-profile-backup-codes.view'; -export interface UserProfileBackupCodesDialogProps { +export interface UserProfileBackupCodesDialogProps extends Omit { open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; finalFocus?: DialogFocusTarget; - codes: readonly string[]; - onRetry: () => void; - onCopy: () => void; - onDownload: () => void; - pendingAction?: 'generate' | 'copy' | 'download'; - errorMessage?: string; } export function UserProfileBackupCodesDialog({ @@ -30,15 +16,8 @@ export function UserProfileBackupCodesDialog({ onOpenChange, trigger, finalFocus, - codes, - onRetry, - onCopy, - onDownload, - pendingAction, - errorMessage, + ...props }: UserProfileBackupCodesDialogProps) { - const hasCodes = codes.length > 0 && pendingAction !== 'generate'; - return ( - - {m.title} - {m.description} - - - {errorMessage ? ( - - {errorMessage} - - ) : null} - {hasCodes ? ( -
    - {codes.map(code => ( -
  • - } - color='foreground-secondary' - xstyle={styles.code} - > - {code} - -
  • - ))} -
- ) : pendingAction === 'generate' ? ( -
- {Array.from({ length: 10 }, (_, index) => ( - - ))} -
- ) : null} -
- - {hasCodes ? ( - <> - - - {m.download} - - - - {m.copyAndClose} - - - ) : ( - <> - - } - > - {m.cancel} - - - {m.retry} - - - )} - + onOpenChange(false)} + />
diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx new file mode 100644 index 00000000000..98be0cf2588 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx @@ -0,0 +1,154 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Banner } from '../../components/banner'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { useFlowAutoFocus } from '../../components/flow'; +import { Icon } from '../../components/icon'; +import { Text } from '../../components/text'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { userProfileBackupCodesMessages as m } from './user-profile-backup-codes.messages'; +import { styles } from './user-profile-backup-codes.styles'; + +export interface UserProfileBackupCodesViewProps { + onCancel: () => void; + codes: readonly string[]; + onRetry: () => void; + onCopy: () => void; + onDownload: () => void; + pendingAction?: 'generate' | 'copy' | 'download'; + errorMessage?: string; +} + +export function UserProfileBackupCodesView({ + onCancel, + codes, + onRetry, + onCopy, + onDownload, + pendingAction, + errorMessage, +}: UserProfileBackupCodesViewProps) { + const actionRef = useFlowAutoFocus(); + const hasCodes = codes.length > 0 && pendingAction !== 'generate'; + + return ( + <> + + {m.title} + {m.description} + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + {hasCodes ? ( +
    + {codes.map(code => ( +
  • + } + color='foreground-secondary' + xstyle={styles.code} + > + {code} + +
  • + ))} +
+ ) : pendingAction === 'generate' ? ( +
+ {Array.from({ length: 10 }, (_, index) => ( + + ))} +
+ ) : null} +
+ + {hasCodes ? ( + <> + + + {m.download} + + + + {m.copyAndClose} + + + ) : ( + <> + + + {m.retry} + + + )} + + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts index fb2ae003cff..58589732a9c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts @@ -21,6 +21,7 @@ export const userProfileMfaMessages = { methods: { sms: 'Get a code by text message', authenticator: 'Get codes from an authenticator app', + 'backup-codes': 'One-time codes to use if you lose access', }, }, removeDialog: { diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx index ec0b2298eec..db888a6eb27 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx @@ -1,4 +1,4 @@ -import { type Ref, useMemo } from 'react'; +import { type ReactNode, type Ref, useMemo } from 'react'; import { Confirmation } from '../../blocks/confirmation'; import { fill } from '../../localization'; @@ -18,12 +18,13 @@ export interface UserProfileMfaMethod { canSetDefault?: boolean; } -export type UserProfileMfaAddableMethod = 'sms' | 'authenticator'; +export type UserProfileMfaAddableMethod = 'sms' | 'authenticator' | 'backup-codes'; export interface UserProfileMfaSectionViewProps { methods: UserProfileMfaMethod[]; addableMethods?: readonly UserProfileMfaAddableMethod[]; addButtonRef?: Ref; + addControl?: ReactNode; sectionTitle?: string; onAdd?: (type: UserProfileMfaAddableMethod) => void; onRegenerateBackupCodes?: () => void; @@ -35,6 +36,7 @@ export function UserProfileMfaSectionView({ methods, addableMethods, addButtonRef, + addControl, sectionTitle, onAdd, onRegenerateBackupCodes, @@ -49,14 +51,15 @@ export function UserProfileMfaSectionView({ <> - ) : null + ) : null) } addLabel={m.addLabel} emptyLabel={m.empty} diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 8d0bbe2b51b..5f432029909 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -1,5 +1,5 @@ import * as stylex from '@stylexjs/stylex'; -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import { Profile } from '../../components/profile'; import { mergeStyleProps, themeProps } from '../../props'; @@ -41,6 +41,7 @@ export interface UserProfileSecurityPanelViewProps passkeysVisible?: boolean; mfaMethods?: UserProfileMfaMethod[]; addableMfaMethods?: readonly UserProfileMfaAddableMethod[]; + mfaAddControl?: ReactNode; devices?: UserProfileDevice[]; onAddPasskey?: () => void; addPasskeyError?: string; @@ -62,6 +63,7 @@ export function UserProfileSecurityPanelView({ passkeysVisible = true, mfaMethods, addableMfaMethods, + mfaAddControl, devices, onSubmitPassword, onAddPasskey, @@ -107,6 +109,7 @@ export function UserProfileSecurityPanelView({ { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('starts with retry feedback and cancels pending preparation when dismissed', async () => { + const { result } = renderHook(() => useUserProfileAuthenticatorPreparationFixture({ initialOpen: true })); + expect(result.current.open).toBe(true); + expect(result.current.setupErrorMessage).toContain('Unable to prepare'); + act(() => result.current.onRetry()); + expect(result.current.setupErrorMessage).toBeUndefined(); + expect(result.current.setup).toBeUndefined(); + act(() => result.current.onOpenChange(false)); + await act(() => vi.advanceTimersByTimeAsync(1000)); + expect(result.current.open).toBe(false); + expect(result.current.setup).toBeUndefined(); + + act(() => result.current.onOpenChange(true)); + await act(() => vi.advanceTimersByTimeAsync(1000)); + expect(result.current.setupErrorMessage).toContain('Unable to prepare'); + act(() => result.current.onRetry()); + await act(() => vi.advanceTimersByTimeAsync(1000)); + expect(result.current.setup?.secret).toBe('JBSWY3DPEHPK3PXP'); + expect(result.current.setupErrorMessage).toBeUndefined(); + }); +}); diff --git a/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts b/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts new file mode 100644 index 00000000000..e6f5faa3688 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts @@ -0,0 +1,66 @@ +import type { UserProfileAddAuthenticatorDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-add-authenticator.dialog'; +import { useEffect, useRef, useState } from 'react'; + +export const authenticatorSetup = { + secret: 'JBSWY3DPEHPK3PXP', + uri: 'otpauth://totp/Swingset:demo@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Swingset', +}; + +export function useAuthenticatorCopy() { + const [copyStatus, setCopyStatus] = useState(); + const [copyErrorMessage, setCopyErrorMessage] = useState(); + + return { + copyStatus, + copyErrorMessage, + onCopy: async (value: string) => { + setCopyStatus('pending'); + setCopyErrorMessage(undefined); + try { + await navigator.clipboard.writeText(value); + setCopyStatus('success'); + } catch { + setCopyStatus(undefined); + setCopyErrorMessage('Could not copy. Please try again.'); + } + }, + }; +} + +export function useUserProfileAuthenticatorPreparationFixture({ + initialOpen = false, +} = {}): UserProfileAddAuthenticatorDialogProps { + const [open, setOpen] = useState(initialOpen); + const [preparation, setPreparation] = useState<'loading' | 'error' | 'ready'>(initialOpen ? 'error' : 'loading'); + const [code, setCode] = useState(''); + const preparationTimer = useRef | undefined>(undefined); + const copy = useAuthenticatorCopy(); + + useEffect(() => () => clearTimeout(preparationTimer.current), []); + + const prepare = (result: 'error' | 'ready') => { + clearTimeout(preparationTimer.current); + setPreparation('loading'); + preparationTimer.current = setTimeout(() => setPreparation(result), 1000); + }; + + return { + ...copy, + open, + onOpenChange: nextOpen => { + setOpen(nextOpen); + if (nextOpen) { + setCode(''); + prepare('error'); + } else { + clearTimeout(preparationTimer.current); + } + }, + setup: preparation === 'ready' ? authenticatorSetup : undefined, + setupErrorMessage: preparation === 'error' ? 'Unable to prepare your authenticator. Please try again.' : undefined, + onRetry: () => prepare('ready'), + code, + onCodeChange: setCode, + onSubmit: () => setOpen(false), + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts index 31805c8ccc6..1694c9820bd 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts @@ -3,23 +3,31 @@ import { createElement } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { UserProfileBackupCodesDialog } from '../../../../mosaic/src/features/user-profile/user-profile-backup-codes.dialog'; +import { deferred } from '../../../../mosaic/src/machines/__tests__/test-utils'; import { MosaicProvider } from '../../../../mosaic/src/MosaicProvider'; import { useUserProfileMfaFixture } from './user-profile-mfa'; const enrollmentCodes = ['enrollment-code-1', 'enrollment-code-2']; const regeneratedCodes = ['regenerated-code-1', 'regenerated-code-2']; -function setup(enrollmentBackupCodes: readonly string[] = enrollmentCodes) { +function setup(enrollmentBackupCodes: readonly string[] = enrollmentCodes, backupCodesEnabled = true) { const onCopy = vi.fn<(codes: readonly string[]) => Promise>().mockResolvedValue(undefined); const onDownload = vi.fn<(codes: readonly string[]) => Promise>().mockResolvedValue(undefined); - const onRegenerateBackupCodes = vi.fn<() => Promise>().mockResolvedValue(regeneratedCodes); + const onGenerateBackupCodes = vi.fn<() => Promise>().mockResolvedValue(regeneratedCodes); return { - ...renderHook(() => - useUserProfileMfaFixture({ enrollmentBackupCodes, onRegenerateBackupCodes, onCopy, onDownload }), + ...renderHook( + ({ backupCodesEnabled }) => + useUserProfileMfaFixture({ + enrollmentBackupCodes, + onGenerateBackupCodes: backupCodesEnabled ? onGenerateBackupCodes : undefined, + onCopy, + onDownload, + }), + { initialProps: { backupCodesEnabled } }, ), onCopy, onDownload, - onRegenerateBackupCodes, + onGenerateBackupCodes, }; } @@ -34,6 +42,90 @@ describe('MFA playground', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); + it.each(['sms', 'authenticator'] as const)('rejects 000000 for %s and accepts a corrected code', async type => { + const { result } = setup(); + act(() => result.current.section.onAdd?.(type)); + if (type === 'sms') { + await complete(() => result.current.sms.onSubmit()); + expect(result.current.sms.step).toBe('verify'); + } + const methods = result.current.section.methods; + act(() => result.current[type].onCodeChange('000000')); + act(() => result.current[type].onSubmit('000000')); + expect(result.current[type].isPending).toBe(true); + await complete(() => undefined); + expect(result.current[type].open).toBe(true); + expect(result.current[type].isPending).toBe(false); + expect(result.current[type].errorMessage).toBe('That code is incorrect. Try again.'); + expect(result.current.section.methods).toEqual(methods); + expect(result.current.backupCodes.open).toBe(false); + + act(() => result.current[type].onCodeChange('123456')); + expect(result.current[type].errorMessage).toBeUndefined(); + await complete(() => result.current[type].onSubmit('123456')); + expect(result.current[type].open).toBe(false); + expect(result.current.backupCodes.open).toBe(true); + }); + + it('keeps the setup dialog open from method selection through enrollment and backup codes', async () => { + const { result } = setup(); + act(() => result.current.setup.onOpenChange(true)); + expect(result.current.setup).toMatchObject({ open: true, step: 'select' }); + act(() => result.current.section.onAdd?.('sms')); + expect(result.current.setup).toMatchObject({ open: true, step: 'sms' }); + act(() => result.current.sms.onSelectedPhoneIdChange('other')); + await complete(() => result.current.sms.onSubmit()); + expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); + await complete(() => result.current.backupCodes.onCopy()); + expect(result.current.setup.open).toBe(false); + act(() => result.current.setup.onOpenChange(true)); + expect(result.current.setup).toMatchObject({ open: true, step: 'select' }); + }); + + it.each(['sms', 'backup-codes'] as const)('starts an inline %s example at its first screen', initialFlow => { + const { result } = renderHook(() => + useUserProfileMfaFixture({ + initialFlow, + enrollmentBackupCodes: enrollmentCodes, + onCopy: vi.fn(), + onDownload: vi.fn(), + }), + ); + expect(result.current.sms.open).toBe(initialFlow === 'sms'); + expect(result.current.backupCodes.open).toBe(initialFlow === 'backup-codes'); + expect(result.current.sms.step).toBe('select'); + expect(result.current.sms.selectedPhoneId).toBe('work'); + expect(result.current.backupCodes.codes).toEqual(initialFlow === 'backup-codes' ? enrollmentCodes : []); + }); + + it('creates backup codes for existing SMS enrollment when the instance enables them later', async () => { + const { result, rerender, onGenerateBackupCodes } = setup([], false); + expect(result.current.section.methods.map(method => method.type)).toEqual(['sms']); + expect(result.current.section.addableMethods).not.toContain('backup-codes'); + + rerender({ backupCodesEnabled: true }); + expect(result.current.section.addableMethods).toContain('backup-codes'); + const request = deferred(); + onGenerateBackupCodes.mockReturnValueOnce(request.promise); + act(() => result.current.section.onAdd?.('backup-codes')); + expect(result.current.backupCodes.open).toBe(true); + expect(result.current.backupCodes.pendingAction).toBe('generate'); + expect(result.current.section.methods.map(method => method.type)).toEqual(['sms']); + expect(result.current.section.onRegenerateBackupCodes).toBeUndefined(); + + await act(async () => { + request.resolve(regeneratedCodes); + await request.promise; + }); + + expect(result.current.section.methods.map(method => method.type)).toEqual(['sms', 'backup-codes']); + expect(result.current.backupCodes.codes).toEqual(regeneratedCodes); + expect(result.current.backupCodes.pendingAction).toBeUndefined(); + expect(result.current.section.addableMethods).not.toContain('backup-codes'); + expect(result.current.section.onRegenerateBackupCodes).toBeDefined(); + expect(onGenerateBackupCodes).toHaveBeenCalledOnce(); + }); + it.each(['sms', 'authenticator'] as const)('opens Save your backup codes after %s enrollment', async type => { const { result } = setup(); act(() => result.current.section.onAdd?.(type)); @@ -51,13 +143,64 @@ describe('MFA playground', () => { ); expect(screen.getByRole('dialog', { name: 'Save your backup codes' })).toBeVisible(); expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(enrollmentCodes); - expect(screen.getByRole('button', { name: 'Download', exact: true })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Download' })).toBeVisible(); expect(screen.getByRole('button', { name: 'Copy and close' })).toBeVisible(); }); - it('automatically adds backup codes during enrollment without offering them in Add', async () => { + it('withholds backup-code creation until the instance enables codes and the user has MFA', async () => { + const { result, rerender, onGenerateBackupCodes } = setup([], false); + act(() => result.current.section.onAdd?.('backup-codes')); + expect(result.current.backupCodes.open).toBe(false); + expect(onGenerateBackupCodes).not.toHaveBeenCalled(); + + await complete(() => void result.current.section.onRemove?.('personal')); + rerender({ backupCodesEnabled: true }); + expect(result.current.section.addableMethods).not.toContain('backup-codes'); + act(() => result.current.section.onAdd?.('backup-codes')); + expect(result.current.backupCodes.open).toBe(false); + expect(onGenerateBackupCodes).not.toHaveBeenCalled(); + + act(() => result.current.section.onAdd?.('authenticator')); + await complete(() => result.current.authenticator.onSubmit('123456')); + expect(result.current.section.addableMethods).toContain('backup-codes'); + }); + + it.each(['rejection', 'empty result'])( + 'retries backup-code creation after %s without adding a row early', + async failure => { + const { result, onGenerateBackupCodes } = setup([]); + if (failure === 'rejection') { + onGenerateBackupCodes.mockRejectedValueOnce(new Error('Try again')); + } else { + onGenerateBackupCodes.mockResolvedValueOnce([]); + } + + await complete(() => result.current.section.onAdd?.('backup-codes')); + expect(result.current.backupCodes.open).toBe(true); + expect(result.current.backupCodes.errorMessage).toContain('Unable to generate'); + expect(result.current.backupCodes.codes).toEqual([]); + expect(result.current.section.methods.map(method => method.type)).toEqual(['sms']); + expect(result.current.section.addableMethods).toContain('backup-codes'); + expect(result.current.section.onRegenerateBackupCodes).toBeUndefined(); + + await complete(() => result.current.backupCodes.onRetry()); + expect(result.current.backupCodes.codes).toEqual(regeneratedCodes); + expect(result.current.backupCodes.errorMessage).toBeUndefined(); + expect(result.current.section.methods.map(method => method.type)).toEqual(['sms', 'backup-codes']); + expect(result.current.section.addableMethods).not.toContain('backup-codes'); + await complete(() => result.current.backupCodes.onCopy()); + expect(result.current.backupCodes.open).toBe(false); + + await complete(() => result.current.section.onRegenerateBackupCodes?.()); + expect(result.current.backupCodes.open).toBe(true); + expect(result.current.section.methods.filter(method => method.type === 'backup-codes')).toHaveLength(1); + expect(onGenerateBackupCodes).toHaveBeenCalledTimes(3); + }, + ); + + it('automatically adds supplied backup codes during enrollment and removes their Add choice', async () => { const { result } = setup(); - expect(result.current.section.addableMethods).toEqual(['sms', 'authenticator']); + expect(result.current.section.addableMethods).toEqual(['sms', 'authenticator', 'backup-codes']); act(() => result.current.section.onAdd?.('authenticator')); await complete(() => result.current.authenticator.onSubmit('123456')); expect(result.current.section.methods.map(method => method.type)).toEqual(['authenticator', 'sms', 'backup-codes']); @@ -68,7 +211,7 @@ describe('MFA playground', () => { }); it('enrolls an authenticator on the first attempt, saves backup codes, and regenerates them', async () => { - const { result, onCopy, onDownload, onRegenerateBackupCodes } = setup(); + const { result, onCopy, onDownload, onGenerateBackupCodes } = setup(); act(() => result.current.section.onAdd?.('authenticator')); expect(result.current.authenticator.open).toBe(true); await complete(() => result.current.authenticator.onSubmit('123456')); @@ -79,7 +222,7 @@ describe('MFA playground', () => { expect(result.current.section.addableMethods).toEqual(['sms']); const codes = result.current.backupCodes.codes; expect(codes).toEqual(enrollmentCodes); - expect(onRegenerateBackupCodes).not.toHaveBeenCalled(); + expect(onGenerateBackupCodes).not.toHaveBeenCalled(); await complete(() => result.current.backupCodes.onDownload()); expect(onDownload).toHaveBeenCalledExactlyOnceWith(codes); expect(result.current.backupCodes.open).toBe(true); @@ -89,7 +232,7 @@ describe('MFA playground', () => { await complete(() => result.current.section.onRegenerateBackupCodes?.()); expect(result.current.backupCodes.open).toBe(true); expect(result.current.backupCodes.codes).toEqual(regeneratedCodes); - expect(onRegenerateBackupCodes).toHaveBeenCalledOnce(); + expect(onGenerateBackupCodes).toHaveBeenCalledOnce(); expect(result.current.section.methods.filter(method => method.type === 'backup-codes')).toHaveLength(1); }); @@ -136,6 +279,56 @@ describe('MFA playground', () => { expect(result.current.section.methods.some(method => method.id === 'work')).toBe(true); }); + it('allows changing the default SMS number while an authenticator keeps the Default badge', async () => { + const { result } = setup([]); + act(() => result.current.section.onAdd?.('sms')); + act(() => result.current.sms.onSelectedPhoneIdChange('other')); + await complete(() => result.current.sms.onSubmit()); + act(() => result.current.section.onAdd?.('authenticator')); + await complete(() => result.current.authenticator.onSubmit('123456')); + + expect(result.current.section.methods.filter(method => method.canSetDefault).map(method => method.id)).toEqual([ + 'personal', + 'other', + ]); + await complete(() => void result.current.section.onSetDefault?.('other')); + expect(result.current.section.methods.filter(method => method.isDefault).map(method => method.id)).toEqual([ + 'authenticator', + ]); + + await complete(() => void result.current.section.onRemove?.('authenticator')); + expect(result.current.section.methods.find(method => method.id === 'other')).toMatchObject({ + isDefault: true, + canSetDefault: false, + }); + expect(result.current.section.methods.find(method => method.id === 'personal')).toMatchObject({ + isDefault: false, + canSetDefault: true, + }); + }); + + it.each([false, true])('orders the default SMS number first with authenticator enabled: %s', async authenticator => { + const { result } = setup([]); + if (authenticator) { + act(() => result.current.section.onAdd?.('authenticator')); + await complete(() => result.current.authenticator.onSubmit('123456')); + } + act(() => result.current.section.onAdd?.('sms')); + act(() => result.current.sms.onSelectedPhoneIdChange('other')); + await complete(() => result.current.sms.onSubmit()); + expect(result.current.section.methods.filter(method => method.type === 'sms').map(method => method.id)).toEqual([ + 'personal', + 'other', + ]); + + await complete(() => void result.current.section.onSetDefault?.('other')); + + expect(result.current.section.methods.filter(method => method.type === 'sms').map(method => method.id)).toEqual([ + 'other', + 'personal', + ]); + }); + it('preserves codes on a real copy failure and closes after a successful retry', async () => { const { result, onCopy } = setup(); act(() => result.current.section.onAdd?.('authenticator')); @@ -217,7 +410,7 @@ describe('MFA playground', () => { } expect(result.current.section.methods.some(method => method.type === type)).toBe(true); expect(result.current.section.methods.some(method => method.type === 'backup-codes')).toBe(false); - expect(result.current.section.addableMethods).not.toContain('backup-codes'); + expect(result.current.section.addableMethods).toContain('backup-codes'); expect(result.current.backupCodes.open).toBe(false); expect(result.current.backupCodes.codes).toEqual([]); expect(result.current.section.onRegenerateBackupCodes).toBeUndefined(); @@ -227,19 +420,19 @@ describe('MFA playground', () => { it.each(['rejection', 'empty result'])( 'retries backup-code regeneration after %s without presenting old codes as new', async failure => { - const { result, onRegenerateBackupCodes } = setup(); + const { result, onGenerateBackupCodes } = setup(); act(() => result.current.section.onAdd?.('authenticator')); await complete(() => result.current.authenticator.onSubmit('123456')); act(() => result.current.backupCodes.onOpenChange(false)); if (failure === 'rejection') { - onRegenerateBackupCodes.mockRejectedValueOnce(new Error('Try again')); + onGenerateBackupCodes.mockRejectedValueOnce(new Error('Try again')); } else { - onRegenerateBackupCodes.mockResolvedValueOnce([]); + onGenerateBackupCodes.mockResolvedValueOnce([]); } await complete(() => result.current.section.onRegenerateBackupCodes?.()); expect(result.current.backupCodes.open).toBe(true); expect(result.current.backupCodes.codes).toEqual([]); - expect(result.current.backupCodes.errorMessage).toContain('Unable to regenerate'); + expect(result.current.backupCodes.errorMessage).toContain('Unable to generate'); expect(result.current.section.methods.some(method => method.type === 'backup-codes')).toBe(true); await complete(() => result.current.backupCodes.onRetry()); expect(result.current.backupCodes.codes).toEqual(regeneratedCodes); @@ -248,3 +441,4 @@ describe('MFA playground', () => { }, ); }); +import '@testing-library/jest-dom/vitest'; diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts index 99c31efa107..963704c9a5e 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts @@ -9,21 +9,68 @@ import type { import { stringToFormattedPhoneString } from '@clerk/shared/phone'; import { useEffect, useState } from 'react'; +import { authenticatorSetup } from './user-profile-authenticator'; + interface FixtureOptions { + initialFlow?: UserProfileMfaAddableMethod; enrollmentBackupCodes?: readonly string[]; - onRegenerateBackupCodes: () => Promise; + onGenerateBackupCodes?: () => Promise; onCopy: (codes: readonly string[]) => Promise; onDownload: (codes: readonly string[]) => void | Promise; } +export const mfaDemoOptions: FixtureOptions = { + enrollmentBackupCodes: [ + 'pwkkay19', + 'cvgunlqs', + '4czio578', + 'a38eewtw', + 'qqnwzvyr', + 'znq8j16s', + 'k4ro51h1', + '1gjmkwdb', + 'pnr8i06f', + 'ycga0jge', + ], + onGenerateBackupCodes: () => + Promise.resolve([ + 'demo-new-01', + 'demo-new-02', + 'demo-new-03', + 'demo-new-04', + 'demo-new-05', + 'demo-new-06', + 'demo-new-07', + 'demo-new-08', + 'demo-new-09', + 'demo-new-10', + ]), + onCopy: codes => navigator.clipboard.writeText(codes.join('\n')), + onDownload: codes => { + const blob = new Blob(['Swingset demo backup codes\n\n', codes.join('\n')], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'swingset-backup-codes.txt'; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); + }, +}; + const pause = () => new Promise(resolve => setTimeout(resolve, 600)); export function useUserProfileMfaFixture({ + initialFlow, enrollmentBackupCodes, - onRegenerateBackupCodes, + onGenerateBackupCodes, onCopy, onDownload, -}: FixtureOptions): { +}: FixtureOptions = mfaDemoOptions): { + setup: { + open: boolean; + step: UserProfileMfaAddableMethod | 'select'; + onOpenChange: (open: boolean) => void; + }; section: UserProfileMfaSectionViewProps; authenticator: UserProfileAddAuthenticatorDialogProps; sms: UserProfileAddSmsDialogProps; @@ -37,17 +84,19 @@ export function useUserProfileMfaFixture({ ], authenticator: false, defaultPhoneId: 'personal', - hasBackupCodes: false, + hasBackupCodes: initialFlow === 'backup-codes' && Boolean(enrollmentBackupCodes?.length), }); - const [flow, setFlow] = useState(); + const [flow, setFlow] = useState(initialFlow); const [pending, setPending] = useState<'submit' | 'resend' | 'generate' | 'copy' | 'download'>(); const [errorMessage, setErrorMessage] = useState(); const [code, setCode] = useState(''); - const [codes, setCodes] = useState([]); + const [codes, setCodes] = useState( + initialFlow === 'backup-codes' ? (enrollmentBackupCodes ?? []) : [], + ); const [step, setStep] = useState('select'); const [direction, setDirection] = useState<1 | -1>(1); const [verifyFrom, setVerifyFrom] = useState<'select' | 'phone'>('select'); - const [selectedPhoneId, setSelectedPhoneId] = useState(''); + const [selectedPhoneId, setSelectedPhoneId] = useState(() => account.phones.find(phone => !phone.enrolled)?.id ?? ''); const [phoneNumber, setPhoneNumber] = useState(''); const [resendSeconds, setResendSeconds] = useState(0); @@ -64,23 +113,30 @@ export function useUserProfileMfaFixture({ const defaultPhoneId = enrolledPhones.some(phone => phone.id === account.defaultPhoneId) ? account.defaultPhoneId : enrolledPhones[0]?.id; + enrolledPhones.sort((left, right) => Number(right.id === defaultPhoneId) - Number(left.id === defaultPhoneId)); const methods: UserProfileMfaMethod[] = [ ...(account.authenticator ? [{ id: 'authenticator', type: 'authenticator' as const, isDefault: true }] : []), - ...enrolledPhones.map(phone => ({ - id: phone.id, - type: 'sms' as const, - description: stringToFormattedPhoneString(phone.phoneNumber), - isDefault: !account.authenticator && phone.id === defaultPhoneId, - canSetDefault: !account.authenticator && phone.id !== defaultPhoneId, - })), + ...enrolledPhones.map(phone => { + const isDefault = !account.authenticator && phone.id === defaultPhoneId; + return { + id: phone.id, + type: 'sms' as const, + description: stringToFormattedPhoneString(phone.phoneNumber), + isDefault, + canSetDefault: !isDefault, + }; + }), ...(account.hasBackupCodes ? [{ id: 'backup', type: 'backup-codes' as const }] : []), ]; const addableMethods: UserProfileMfaAddableMethod[] = ['sms']; if (!account.authenticator) { addableMethods.push('authenticator'); } - const regenerate = async () => { - if (pending || !account.hasBackupCodes) { + if (onGenerateBackupCodes && !account.hasBackupCodes && (account.authenticator || enrolledPhones.length > 0)) { + addableMethods.push('backup-codes'); + } + const generateBackupCodes = async () => { + if (pending || !onGenerateBackupCodes) { return; } setFlow('backup-codes'); @@ -88,20 +144,25 @@ export function useUserProfileMfaFixture({ setErrorMessage(undefined); setCodes([]); try { - const nextCodes = await onRegenerateBackupCodes(); + const nextCodes = await onGenerateBackupCodes(); if (nextCodes.length === 0) { throw new Error('No backup codes returned'); } setCodes(nextCodes); + setAccount(current => ({ ...current, hasBackupCodes: true })); } catch { - setErrorMessage('Unable to regenerate backup codes. Please try again.'); + setErrorMessage('Unable to generate backup codes. Please try again.'); } finally { setPending(undefined); } }; const open = (type: UserProfileMfaAddableMethod) => { - if (pending) { + if (pending || !addableMethods.includes(type)) { + return; + } + if (type === 'backup-codes') { + void generateBackupCodes(); return; } setCode(''); @@ -137,8 +198,14 @@ export function useUserProfileMfaFixture({ if (pending || !/^\d{6}$/.test(value)) { return; } + setErrorMessage(undefined); setPending('submit'); await pause(); + if (value === '000000') { + setErrorMessage('That code is incorrect. Try again.'); + setPending(undefined); + return; + } setAccount(current => ({ ...current, authenticator: true })); finishEnrollment(); }; @@ -167,6 +234,11 @@ export function useUserProfileMfaFixture({ setErrorMessage(undefined); setPending('submit'); await pause(); + if (step === 'verify' && value === '000000') { + setErrorMessage('That code is incorrect. Try again.'); + setPending(undefined); + return; + } if (step === 'verify' || phone?.verified) { const enrolled = { id: phone?.id ?? `phone-${number}`, phoneNumber: number, verified: true, enrolled: true }; setAccount(current => ({ @@ -227,12 +299,24 @@ export function useUserProfileMfaFixture({ }; return { + setup: { + open: flow !== undefined, + step: flow ?? 'select', + onOpenChange: next => { + if (next && !pending) { + setFlow('select'); + } else { + close(next); + } + }, + }, section: { methods, addableMethods, sectionTitle: 'Authentication', onAdd: open, - onRegenerateBackupCodes: account.hasBackupCodes ? () => void regenerate() : undefined, + onRegenerateBackupCodes: + account.hasBackupCodes && onGenerateBackupCodes ? () => void generateBackupCodes() : undefined, onSetDefault: async id => { await pause(); setAccount(current => ({ ...current, defaultPhoneId: id })); @@ -257,12 +341,13 @@ export function useUserProfileMfaFixture({ authenticator: { open: flow === 'authenticator', onOpenChange: close, - secret: 'JBSWY3DPEHPK3PXP', - uri: 'otpauth://totp/Swingset:demo@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Swingset', + setup: authenticatorSetup, + onRetry: () => undefined, code, onCodeChange, onSubmit: value => void verifyAuthenticator(value), isPending: pending === 'submit', + errorMessage, }, sms: { open: flow === 'sms', @@ -314,7 +399,7 @@ export function useUserProfileMfaFixture({ errorMessage, onRetry: () => { if (!pending) { - void regenerate(); + void generateBackupCodes(); } }, onCopy: () => void save('copy'), diff --git a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx index bc2b0f5c781..e294f4e096a 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -35,7 +35,7 @@ export function Default() { 'pnr8i06f', 'ycga0jge', ], - onRegenerateBackupCodes: () => + onGenerateBackupCodes: () => Promise.resolve([ 'demo-new-01', 'demo-new-02', From 0ac70878102ce9896cf2ca071d84d8a35535ee31 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 17 Sep 2026 15:57:33 -0600 Subject: [PATCH 23/38] refactor(swingset): share MFA flows and simplify examples --- packages/swingset/src/lib/registry.ts | 10 +- .../fixtures/user-profile-mfa-example.tsx | 113 +++++++++++++ .../src/stories/user-profile-mfa-section.mdx | 123 ++------------ .../user-profile-mfa-section.stories.tsx | 153 ++++++++++-------- 4 files changed, 217 insertions(+), 182 deletions(-) create mode 100644 packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 726f79956fb..c8c9e84acc9 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -305,10 +305,11 @@ import { RequiresAction as UserProfileEnterpriseAccountsSectionRequiresAction, } from '../stories/user-profile-enterprise-accounts-section.stories'; import { + Authenticator as UserProfileMfaSectionAuthenticator, + BackupCodes as UserProfileMfaSectionBackupCodes, Default as UserProfileMfaSectionDefault, - Empty as UserProfileMfaSectionEmpty, meta as userProfileMfaSectionMeta, - ReadOnly as UserProfileMfaSectionReadOnly, + Sms as UserProfileMfaSectionSms, } from '../stories/user-profile-mfa-section.stories'; import { CreationUnavailable as UserProfilePasskeysSectionCreationUnavailable, @@ -677,8 +678,9 @@ const userProfilePasskeysSectionModule: StoryModule = { const userProfileMfaSectionModule: StoryModule = { meta: userProfileMfaSectionMeta, Default: UserProfileMfaSectionDefault, - Empty: UserProfileMfaSectionEmpty, - ReadOnly: UserProfileMfaSectionReadOnly, + Authenticator: UserProfileMfaSectionAuthenticator, + BackupCodes: UserProfileMfaSectionBackupCodes, + Sms: UserProfileMfaSectionSms, }; const userProfileActiveDevicesSectionModule: StoryModule = { meta: userProfileActiveDevicesSectionMeta, diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx new file mode 100644 index 00000000000..4fdc6a62048 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx @@ -0,0 +1,113 @@ +import { Button } from '@clerk/mosaic/components/button'; +import { Card } from '@clerk/mosaic/components/card'; +import { Dialog } from '@clerk/mosaic/components/dialog'; +import { Flow } from '@clerk/mosaic/components/flow'; +import { Icon } from '@clerk/mosaic/components/icon'; +import { UserProfileAddAuthenticatorView } from '@clerk/mosaic/features/user-profile/user-profile-add-authenticator.view'; +import { UserProfileAddMfaView } from '@clerk/mosaic/features/user-profile/user-profile-add-mfa.view'; +import { UserProfileAddSmsView } from '@clerk/mosaic/features/user-profile/user-profile-add-sms.view'; +import { UserProfileBackupCodesView } from '@clerk/mosaic/features/user-profile/user-profile-backup-codes.view'; +import type { UserProfileSecurityPanelViewProps } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; +import { useRef } from 'react'; + +import { useAuthenticatorCopy } from './user-profile-authenticator'; +import { useUserProfileMfaFixture } from './user-profile-mfa'; + +export function useUserProfileMfaExample() { + const fixture = useUserProfileMfaFixture(); + const addControl = ; + const section = { ...fixture.section, addControl }; + const security: Pick< + UserProfileSecurityPanelViewProps, + | 'mfaMethods' + | 'addableMfaMethods' + | 'mfaAddControl' + | 'onAddMfaMethod' + | 'onSetDefaultMfaMethod' + | 'onRemoveMfaMethod' + | 'onRegenerateBackupCodes' + > = { + mfaMethods: section.methods, + addableMfaMethods: section.addableMethods, + mfaAddControl: addControl, + onAddMfaMethod: section.onAdd, + onSetDefaultMfaMethod: section.onSetDefault, + onRemoveMfaMethod: section.onRemove, + onRegenerateBackupCodes: section.onRegenerateBackupCodes, + }; + return { section, security }; +} + +function UserProfileMfaSetupExample({ fixture }: { fixture: ReturnType }) { + const addButtonRef = useRef(null); + const authenticatorCopy = useAuthenticatorCopy(); + return ( + + + } + > + + Add + + + + + {current => ( + <> + + current.section.onAdd?.(type)} + /> + + + current.setup.onOpenChange(false)} + /> + + + current.setup.onOpenChange(false)} + /> + + + current.setup.onOpenChange(false)} + /> + + + )} + + + + + ); +} diff --git a/packages/swingset/src/stories/user-profile-mfa-section.mdx b/packages/swingset/src/stories/user-profile-mfa-section.mdx index 2423f669390..471a96c5c76 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.mdx +++ b/packages/swingset/src/stories/user-profile-mfa-section.mdx @@ -2,13 +2,13 @@ import * as Stories from './user-profile-mfa-section.stories'; # UserProfileMfaSection -Display and manage two-step verification methods. The playground connects setup, backup codes, default selection, and removal using local account state. +Display and manage two-step verification methods. ## Playground -Choose Add, then click SMS verification or Authenticator app to start setup. Enter any six digits to complete verification. Successful enrollment updates the rows. When the instance enables backup codes, enrollment supplies them automatically; both methods open the Save your backup codes screen. Use each row’s menu to change the default, remove a method, or regenerate backup codes. +Choose Add to set up a method. Use `000000` to see an incorrect-code error; other six-digit codes succeed. Row menus let you change the default, remove a method, or regenerate backup codes. -Account requests are simulated with fixed demo responses and succeed by default. Copy and Download save the demo codes to your clipboard or a text file. Reload the page to reset the account. +Account changes are simulated. Copy and Download use your browser’s clipboard and file downloads. -## Props - -| Prop | Type | Default | Description | -| ------------------------- | --------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- | -| `methods` | `UserProfileMfaMethod[]` | — (required) | Prepared rows, in display order. All supplied rows render. | -| `addableMethods` | `readonly UserProfileMfaAddableMethod[]` | — | Available choices, in display order. Add appears when this is nonempty and `onAdd` is supplied. | -| `sectionTitle` | `string` | — | Optional surrounding heading. | -| `addButtonRef` | `Ref` | — | Ref to the Add button for restoring focus after setup dialogs close. | -| `onAdd` | `(type: UserProfileMfaAddableMethod) => void` | — | Receives the chosen method immediately when its option is activated. | -| `onSetDefault` | `(id: string) => void \| Promise` | — | Changes the default SMS method. Shows pending feedback and reports failures beside the selected row. | -| `onRemove` | `(id: string) => void \| Promise` | — | Called after confirmation. Resolve to close; reject with an Error to show the failure. | -| `onRegenerateBackupCodes` | `() => void` | — | Requests backup-code regeneration. | - -Each method has an `id`, a `type` (`sms`, `authenticator`, or `backup-codes`), and optional `label` and `description`. Supply the formatted phone number as the SMS description so its menu has a distinct accessible name. - -`UserProfileMfaAddableMethod` is `sms` or `authenticator`. The caller supplies available choices independently of existing rows, so another SMS number can be added. The Security panel forwards these choices through `addableMfaMethods`. Backup codes are supplied automatically during enrollment when enabled for the instance; they are never an Add choice. - -| Method field | Type | Default | Description | -| --------------- | --------- | ------- | ----------------------------------------------------------------------------- | -| `isDefault` | `boolean` | `false` | Displays the Default badge. | -| `canRemove` | `boolean` | `true` | Allows removal of an SMS or authenticator method when `onRemove` is supplied. | -| `canSetDefault` | `boolean` | `false` | Allows Set as default on an SMS method when `onSetDefault` is supplied. | - -### Authenticator dialog - -`UserProfileAddAuthenticatorDialog` combines setup and code verification in one controlled dialog. - -| Prop | Type | Default | Description | -| -------------- | ------------------------------ | ------------ | ------------------------------------------------------------------------------------ | -| `open` | `boolean` | — (required) | Whether the dialog is open. | -| `onOpenChange` | `(open: boolean) => void` | — (required) | Receives open and dismiss requests. | -| `trigger` | `DialogTriggerProps['render']` | — | Optional trigger that receives focus when the dialog closes. | -| `finalFocus` | `DialogFocusTarget` | — | Optional focus destination after a flow opened without its own trigger closes. | -| `secret` | `string` | — (required) | Prepared authenticator setup key. | -| `uri` | `string` | — (required) | Matching authenticator URI, encoded in the QR code and available for manual entry. | -| `code` | `string` | — (required) | Current verification code. | -| `onCodeChange` | `(value: string) => void` | — (required) | Receives code edits. | -| `onSubmit` | `(code: string) => void` | — (required) | Receives six digits after completion or a Verify submission. | -| `isPending` | `boolean` | `false` | Disables code entry and Cancel; Verify shows progress and blocks repeat submissions. | -| `errorMessage` | `string` | — | Feedback associated with the verification field. | - -### SMS dialog - -`UserProfileAddSmsDialog` combines number selection with the shared phone-entry and verification steps. - -| Prop | Type | Default | Description | -| ------------------------- | ------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------- | -| `open` | `boolean` | — (required) | Whether the dialog is open. | -| `onOpenChange` | `(open: boolean) => void` | — (required) | Receives open and dismiss requests. | -| `trigger` | `DialogTriggerProps['render']` | — | Trigger that receives focus after dismissal. | -| `finalFocus` | `DialogFocusTarget` | — | Optional focus destination after a flow opened without its own trigger closes. | -| `step` | `'select' \| 'phone' \| 'verify'` | — (required) | Active screen. | -| `direction` | `1 \| -1` | `1` | Forward or backward transition. | -| `phoneNumbers` | `readonly { id: string; phoneNumber: string }[]` | — (required) | Eligible existing numbers, in display order. | -| `selectedPhoneId` | `string` | — (required) | Selected number ID; an empty string disables Continue. | -| `onSelectedPhoneIdChange` | `(id: string) => void` | — (required) | Receives a Select choice. | -| `onAddPhone` | `() => void` | — (required) | Requests phone entry. | -| `onBack` | `() => void` | — (required) | Requests the previous screen. | -| `phoneNumber` | `string` | — (required) | Phone-entry value or the number being verified. | -| `onPhoneNumberChange` | `(value: string) => void` | — (required) | Receives phone edits. | -| `code` | `string` | — (required) | Current verification code. | -| `onCodeChange` | `(value: string) => void` | — (required) | Receives code edits. | -| `onSubmit` | `(code?: string) => void` | — (required) | Submits the active step. OTP completion supplies the completed code; form submission uses caller state. | -| `onResend` | `() => void` | — (required) | Requests another verification code. | -| `isPending` | `boolean` | `false` | Blocks edits and navigation and shows submit progress. | -| `errorMessage` | `string` | — | Error associated with the active field. | -| `isResending` | `boolean` | `false` | Blocks code entry, verification, resend, and Back while sending. | -| `resendSeconds` | `number` | `0` | Seconds remaining before resend becomes available. | - -### Backup codes dialog - -`UserProfileBackupCodesDialog` displays supplied codes with Download and Copy and close actions. SMS enrollment, authenticator enrollment, and regeneration use the same Save your backup codes screen. - -| Prop | Type | Default | Description | -| --------------- | ------------------------------------ | ------------ | -------------------------------------------------------------------------------- | -| `open` | `boolean` | — (required) | Whether the dialog is open. | -| `onOpenChange` | `(open: boolean) => void` | — (required) | Receives open and dismiss requests. | -| `trigger` | `DialogTriggerProps['render']` | — | Optional trigger that receives focus after dismissal. | -| `finalFocus` | `DialogFocusTarget` | — | Optional focus destination after a flow opened without its own trigger closes. | -| `codes` | `readonly string[]` | — (required) | Generated codes in display order; use an empty array before generation succeeds. | -| `onRetry` | `() => void` | — (required) | Requests another generation attempt. | -| `onCopy` | `() => void` | — (required) | Requests copying the codes. The caller closes the dialog after success. | -| `onDownload` | `() => void` | — (required) | Requests downloading the codes; the dialog remains open. | -| `pendingAction` | `'generate' \| 'copy' \| 'download'` | — | Displays progress and blocks overlapping actions. Generation hides old codes. | -| `errorMessage` | `string` | — | Announced error feedback; save failures leave the current codes visible. | - ## Usage -The caller decides whether to mount the section from the instance's second-factor configuration, even when existing methods are present. When mounted, the section stays visible with no methods or callbacks. The caller also prepares the method order, default indicators, and removal permissions, including restrictions when MFA is required. Backup-code rows only offer regeneration. - -Callbacks report user intent; updated props determine the displayed result. Set as default awaits its callback, blocks overlapping method actions, and leaves the current badge in place until `methods` changes. Rejections appear below the selected row and are announced to assistive technology. Retrying clears the error. The section owns one removal confirmation, opened with the selected method. Confirmation owns its pending and error state; update `methods` after a successful removal. Removing SMS verification leaves the phone number on the account. - -### Set up a method - -The Add picker opens the selected setup dialog. Already enrolled authenticators are excluded from the choices. SMS remains available for additional numbers; its select excludes numbers already enrolled. Verified numbers enable SMS directly. Unverified or new numbers require a six-digit code, with Back and Resend available. Cancelling setup leaves the account unchanged. - -Authenticator setup supports both QR codes and a read-only setup key. Completing setup adds the method to the section. When enrollment supplies new backup codes, their row is added immediately and the Save your backup codes dialog displays the supplied codes without generating another set. If enrollment supplies no codes, setup finishes without opening the save flow or adding a backup-code row. Subsequent enrollment preserves existing backup codes. - -### Save and regenerate backup codes +The views receive data and callbacks from the caller, which owns enrollment requests, progress, errors, and clipboard/download actions. -Download saves a text file and keeps the dialog open. Copy and close closes after the clipboard write succeeds. A browser failure leaves the codes visible with retry feedback. Regenerate from the backup-code row replaces the displayed set only after a successful response containing codes. Failed or empty regeneration responses show retry feedback. The caller owns enrollment results, regeneration, clipboard access, file creation, pending state, and error feedback. +## Authenticator -### Manage methods +Scan the QR code or choose “Can’t scan?” to view and copy the setup key, then enter a verification code. -Set as default updates the badge after completion. An authenticator takes precedence when enrolled; otherwise the selected SMS method is the default. Remove method opens a confirmation and updates the rows after success. Removing SMS keeps the phone available for re-enrollment. Removing the last authenticator or SMS method also removes backup codes, matching legacy behavior. Backup codes remain while another verification method is enrolled. The caller supplies the remaining rows from the updated account state. + -### Read-only methods +## SMS -Omitting callbacks hides their actions without hiding the rows. +Select a number or add a new one, then verify it if required. - + -### No methods +## Backup codes -An empty section remains visible when Add is unavailable. +Download the demo codes or choose Copy and close to finish. - + diff --git a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx index e294f4e096a..d7699649739 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -1,12 +1,16 @@ -import { UserProfileAddAuthenticatorDialog } from '@clerk/mosaic/features/user-profile/user-profile-add-authenticator.dialog'; -import { UserProfileAddSmsDialog } from '@clerk/mosaic/features/user-profile/user-profile-add-sms.dialog'; -import { UserProfileBackupCodesDialog } from '@clerk/mosaic/features/user-profile/user-profile-backup-codes.dialog'; +import { Button } from '@clerk/mosaic/components/button'; +import { Card } from '@clerk/mosaic/components/card'; +import { UserProfileAddAuthenticatorView } from '@clerk/mosaic/features/user-profile/user-profile-add-authenticator.view'; +import { UserProfileAddSmsView } from '@clerk/mosaic/features/user-profile/user-profile-add-sms.view'; +import { UserProfileBackupCodesView } from '@clerk/mosaic/features/user-profile/user-profile-backup-codes.view'; import { UserProfileMfaSectionView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; -import { useRef } from 'react'; +import { type ComponentType, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; -import { useUserProfileMfaFixture } from './fixtures/user-profile-mfa'; +import { useAuthenticatorCopy } from './fixtures/user-profile-authenticator'; +import { mfaDemoOptions, useUserProfileMfaFixture } from './fixtures/user-profile-mfa'; +import { useUserProfileMfaExample } from './fixtures/user-profile-mfa-example'; export { default as __source } from './user-profile-mfa-section.stories?raw'; @@ -21,87 +25,98 @@ export const meta: StoryMeta = { }; export function Default() { - const addButtonRef = useRef(null); + const mfa = useUserProfileMfaExample(); + return ; +} + +export function Sms() { + return ; +} + +function SmsExample({ onRestart }: { onRestart: () => void }) { + const fixture = useUserProfileMfaFixture({ ...mfaDemoOptions, initialFlow: 'sms', enrollmentBackupCodes: [] }); + + if (!fixture.sms.open) { + return ; + } + + return ( + + fixture.sms.onOpenChange(false)} + /> + + ); +} + +export function Authenticator() { + return ; +} + +function AuthenticatorExample({ onRestart }: { onRestart: () => void }) { + const copy = useAuthenticatorCopy(); const fixture = useUserProfileMfaFixture({ - enrollmentBackupCodes: [ - 'pwkkay19', - 'cvgunlqs', - '4czio578', - 'a38eewtw', - 'qqnwzvyr', - 'znq8j16s', - 'k4ro51h1', - '1gjmkwdb', - 'pnr8i06f', - 'ycga0jge', - ], - onGenerateBackupCodes: () => - Promise.resolve([ - 'demo-new-01', - 'demo-new-02', - 'demo-new-03', - 'demo-new-04', - 'demo-new-05', - 'demo-new-06', - 'demo-new-07', - 'demo-new-08', - 'demo-new-09', - 'demo-new-10', - ]), - onCopy: codes => navigator.clipboard.writeText(codes.join('\n')), - onDownload: codes => { - const blob = new Blob(['Swingset demo backup codes\n\n', codes.join('\n')], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = 'swingset-backup-codes.txt'; - link.click(); - setTimeout(() => URL.revokeObjectURL(url), 0); - }, + ...mfaDemoOptions, + initialFlow: 'authenticator', + enrollmentBackupCodes: [], }); - const finalFocus = fixture.authenticator.open || fixture.sms.open || fixture.backupCodes.open ? false : addButtonRef; + if (!fixture.authenticator.open) { + return ; + } return ( - <> - - + - fixture.authenticator.onOpenChange(false)} /> - + ); +} + +export function BackupCodes() { + return ; +} + +function BackupCodesExample({ onRestart }: { onRestart: () => void }) { + const fixture = useUserProfileMfaFixture({ ...mfaDemoOptions, initialFlow: 'backup-codes' }); + + if (!fixture.backupCodes.open) { + return ; + } + + return ( + + fixture.backupCodes.onOpenChange(false)} /> - + ); } -export function ReadOnly() { +function RestartableExample({ component: Component }: { component: ComponentType<{ onRestart: () => void }> }) { + const [run, setRun] = useState(0); return ( - setRun(current => current + 1)} /> ); } -export function Empty() { +function ExampleComplete({ onRestart }: { onRestart: () => void }) { return ( - +
+ +
); } From 051a41f45d6d993fd414996578eb9b492eda13c0 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 17 Sep 2026 15:57:53 -0600 Subject: [PATCH 24/38] fix(swingset): reuse MFA flows in Security and Profile examples --- packages/swingset/package.json | 2 +- .../user-profile-mfa-example.test.tsx | 97 +++++++++++++++++++ .../src/stories/fixtures/user-profile.ts | 16 +-- .../user-profile-security-panel.stories.tsx | 56 +---------- packages/swingset/tsconfig.json | 3 +- packages/swingset/vitest.config.mts | 17 ++++ 6 files changed, 123 insertions(+), 68 deletions(-) create mode 100644 packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx create mode 100644 packages/swingset/vitest.config.mts diff --git a/packages/swingset/package.json b/packages/swingset/package.json index 983fe765a81..ee27015de47 100644 --- a/packages/swingset/package.json +++ b/packages/swingset/package.json @@ -8,7 +8,7 @@ "dev": "next dev --port 6006", "format": "node ../../scripts/format-package.mjs", "format:check": "node ../../scripts/format-package.mjs --check", - "test": "vitest run --config ../mosaic/vitest.config.mts --root ." + "test": "vitest run" }, "dependencies": { "@base-ui/react": "^1.5.0", diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx new file mode 100644 index 00000000000..6fe46e8c2dd --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx @@ -0,0 +1,97 @@ +import '@testing-library/jest-dom/vitest'; + +import { Dialog } from '@clerk/mosaic/components/dialog'; +import { UserProfileView } from '@clerk/mosaic/features/user-profile/user-profile.view'; +import { UserProfileMfaSectionView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; +import { UserProfileSecurityPanelView } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { useUserProfileFixture } from './user-profile'; +import { useUserProfileMfaExample } from './user-profile-mfa-example'; + +describe('shared MFA examples', () => { + it('finishes authenticator setup inside the Profile overlay without closing the Profile', async () => { + const user = userEvent.setup(); + function Example() { + const { pages } = useUserProfileFixture(); + return ( + + + + + + + + ); + } + render(); + const profile = screen.getByRole('dialog'); + const add = within(profile).getByRole('button', { name: 'Add verification method' }); + await user.click(add); + const setup = screen.getByRole('dialog', { name: 'Add 2-step verification' }); + await user.click(within(setup).getByRole('button', { name: /Authenticator app/ })); + await user.type(await within(setup).findByRole('textbox', { name: 'Verification code' }), '123456'); + await within(setup).findByRole('list', { name: 'Backup codes' }); + await user.click(within(setup).getByRole('button', { name: 'Copy and close' })); + await waitFor(() => expect(setup).not.toBeInTheDocument()); + expect(screen.getByRole('dialog')).toBe(profile); + expect(add).toHaveFocus(); + expect(within(profile).getByRole('button', { name: 'Manage Authenticator app' })).toBeVisible(); + }); + + it.each(['section', 'security', 'profile'] as const)( + 'uses the enrollment and backup-code flow in %s', + async surface => { + const user = userEvent.setup(); + const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + function Example() { + const mfa = useUserProfileMfaExample(); + const { pages } = useUserProfileFixture(); + return ( + + {surface === 'section' ? ( + + ) : surface === 'security' ? ( + + ) : ( + + )} + + ); + } + render(); + const add = screen.getByRole('button', { name: 'Add verification method' }); + await user.click(add); + const dialog = screen.getByRole('dialog', { name: 'Add 2-step verification' }); + await user.click(within(dialog).getByRole('button', { name: /SMS verification/ })); + await user.click(within(dialog).getByRole('button', { name: 'Continue' })); + const code = await screen.findByRole('textbox', { name: 'Verification code' }); + await user.type(code, '000000'); + expect(await screen.findByText('That code is incorrect. Try again.')).toBeVisible(); + expect(screen.getByRole('dialog')).toBe(dialog); + await user.clear(code); + await user.type(code, '123456'); + await screen.findByRole('list', { name: 'Backup codes' }); + expect(screen.getByRole('dialog', { name: 'Save your backup codes' })).toBe(dialog); + await user.click(screen.getByRole('button', { name: 'Copy and close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(copy).toHaveBeenCalledOnce(); + expect(add).toHaveFocus(); + await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); + expect(screen.getAllByRole('menuitem')).toHaveLength(1); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + expect(await screen.findByText('demo-new-01')).toBeVisible(); + }, + ); +}); diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index 24edb2b66ff..82f709376ca 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -8,7 +8,6 @@ import type { UserProfileEmail, UserProfilePhone, } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; -import type { UserProfileMfaMethod } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; import { useMemo, useState } from 'react'; import { usePreviewImage } from './use-preview-image'; @@ -19,6 +18,7 @@ import { useConnectedAccountsFixture } from './user-profile-connected-accounts'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; import { useUserProfileEditPasswordFixture } from './user-profile-edit-password'; import { useUserProfileEditUsernameFixture } from './user-profile-edit-username'; +import { useUserProfileMfaExample } from './user-profile-mfa-example'; import { usePasskeysFixture } from './user-profile-passkeys'; import { useWeb3WalletsFixture } from './user-profile-web3-wallets'; @@ -55,6 +55,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const editName = useUserProfileEditNameFixture(); const editUsername = useUserProfileEditUsernameFixture(); const editPassword = useUserProfileEditPasswordFixture(); + const mfa = useUserProfileMfaExample(); const [activePage, setActivePage] = useState('account'); const [emails, setEmails] = useState([ { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, @@ -64,10 +65,6 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, ]); const passkeys = usePasskeysFixture(); - const [mfaMethods, setMfaMethods] = useState([ - { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, - { id: 'backup', type: 'backup-codes' }, - ]); const activeDevices = useUserProfileActiveDevicesFixture(); const [subscription, setSubscription] = useState({ @@ -140,17 +137,10 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions passkeys: passkeys.passkeys, addPasskeyError: passkeys.addError, onRenamePasskey: passkeys.onRename, - mfaMethods, + ...mfa.security, devices: activeDevices.devices, - onAddMfaMethod: type => - setMfaMethods(current => [ - ...current, - { id: `${type}-${Date.now()}`, type, description: type === 'sms' ? '+1 801-555-0100' : undefined }, - ]), onAddPasskey: passkeys.onAdd, onDeleteAccount: () => Promise.resolve(), - onRegenerateBackupCodes: () => undefined, - onRemoveMfaMethod: id => setMfaMethods(current => current.filter(method => method.id !== id)), onRemovePasskey: passkeys.onRemove, onSignOutAllOtherDevices: activeDevices.onSignOutAllOtherDevices, onSignOutDevice: activeDevices.onSignOutDevice, diff --git a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx index 14768f4cb0a..71bdf4dca40 100644 --- a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx @@ -1,12 +1,8 @@ -import type { UserProfileMfaMethod } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; import { UserProfileSecurityPanelView } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; -import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; -import { useUserProfileActiveDevicesFixture } from './fixtures/user-profile-active-devices'; -import { useUserProfileEditPasswordFixture } from './fixtures/user-profile-edit-password'; -import { usePasskeysFixture } from './fixtures/user-profile-passkeys'; +import { useUserProfileFixture } from './fixtures/user-profile'; export { default as __source } from './user-profile-security-panel.stories?raw'; @@ -20,52 +16,6 @@ export const meta: StoryMeta = { }; export function Default() { - const editPassword = useUserProfileEditPasswordFixture(); - const passkeys = usePasskeysFixture(); - const [mfaMethods, setMfaMethods] = useState([ - { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, - { id: 'backup', type: 'backup-codes' }, - ]); - const devices = useUserProfileActiveDevicesFixture(); - - return ( - method.type === 'authenticator') ? ['sms'] : ['sms', 'authenticator'] - } - onAddMfaMethod={type => - setMfaMethods(current => { - const timestamp = Date.now(); - return [ - ...current, - { - id: `${type}-${timestamp}`, - type, - description: type === 'sms' ? '+1 801-555-0100' : undefined, - }, - ...(current.some(method => method.type === 'backup-codes') - ? [] - : [{ id: `backup-${timestamp}`, type: 'backup-codes' as const }]), - ]; - }) - } - onAddPasskey={passkeys.onAdd} - onDeleteAccount={() => Promise.resolve()} - onRegenerateBackupCodes={() => - setMfaMethods(current => - current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), - ) - } - onRemoveMfaMethod={id => setMfaMethods(current => current.filter(method => method.id !== id))} - onRemovePasskey={passkeys.onRemove} - onSignOutAllOtherDevices={devices.onSignOutAllOtherDevices} - onSignOutDevice={devices.onSignOutDevice} - /> - ); + const { pages } = useUserProfileFixture(); + return ; } diff --git a/packages/swingset/tsconfig.json b/packages/swingset/tsconfig.json index ee71077ef99..9e53cd2017d 100644 --- a/packages/swingset/tsconfig.json +++ b/packages/swingset/tsconfig.json @@ -22,9 +22,10 @@ } ], "allowJs": true, + "allowImportingTsExtensions": true, "noEmit": true, "incremental": true }, - "include": ["mdx-components.tsx", "next.config.mjs", "src", ".next/types/**/*.ts"], + "include": ["mdx-components.tsx", "next.config.mjs", "vitest.config.mts", "src", ".next/types/**/*.ts"], "exclude": ["node_modules"] } diff --git a/packages/swingset/vitest.config.mts b/packages/swingset/vitest.config.mts new file mode 100644 index 00000000000..3c01e110930 --- /dev/null +++ b/packages/swingset/vitest.config.mts @@ -0,0 +1,17 @@ +import { resolve } from 'node:path'; + +import { mergeConfig } from 'vitest/config'; + +import mosaicConfig from '../mosaic/vitest.config.mts'; + +export default mergeConfig(mosaicConfig, { + oxc: { + jsx: { runtime: 'automatic' }, + }, + resolve: { + alias: { + '@clerk/mosaic': resolve(import.meta.dirname, '../mosaic/src'), + '@': resolve(import.meta.dirname, 'src'), + }, + }, +}); From fb3ba18fd0b88776c7ad190f3d6688cef8498e90 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 17 Sep 2026 16:16:03 -0600 Subject: [PATCH 25/38] test(mosaic): remove redundant MFA coverage and unused fixture --- ...-profile-add-authenticator.dialog.test.tsx | 145 +------- .../user-profile-add-mfa.view.test.tsx | 67 ---- .../user-profile-add-sms.dialog.test.tsx | 18 - ...-profile-authenticator-setup.view.test.tsx | 25 +- .../user-profile-backup-codes.dialog.test.tsx | 94 +----- .../user-profile-mfa-cards.view.test.tsx | 92 ------ .../user-profile-mfa-section.view.test.tsx | 312 +----------------- .../user-profile-security-panel.view.test.tsx | 35 -- .../user-profile-authenticator.test.ts | 30 -- .../fixtures/user-profile-authenticator.ts | 40 +-- .../user-profile-mfa-example.test.tsx | 136 ++++---- .../stories/fixtures/user-profile-mfa.test.ts | 138 +------- 12 files changed, 75 insertions(+), 1057 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-add-mfa.view.test.tsx delete mode 100644 packages/swingset/src/stories/fixtures/user-profile-authenticator.test.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx index 471acdb42ce..300ae007865 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; @@ -51,72 +51,6 @@ function VerificationExample({ onSubmit }: Pick { - it.each([undefined, 'Unable to prepare your authenticator.'])( - 'allows cancellation during preparation: %s', - async setupErrorMessage => { - const user = userEvent.setup(); - const { props } = renderView({ setup: undefined, setupErrorMessage }); - await user.click(screen.getByRole('button', { name: 'Cancel' })); - expect(props.onOpenChange).toHaveBeenCalledWith(false); - expect(props.onRetry).not.toHaveBeenCalled(); - expect(props.onSubmit).not.toHaveBeenCalled(); - }, - ); - - it('copies either manual credential through the caller and displays controlled copy feedback', async () => { - const user = userEvent.setup(); - const onCopy = vi.fn(); - const { props, rerender } = renderView({ onCopy }); - await user.click(screen.getByRole('button', { name: 'Can’t scan? View setup key' })); - const copyKey = screen.getByRole('button', { name: 'Copy setup key' }); - const copyUri = screen.getByRole('button', { name: 'Copy setup URI' }); - await user.click(copyKey); - expect(onCopy).toHaveBeenCalledExactlyOnceWith(setup.secret); - expect(props.onSubmit).not.toHaveBeenCalled(); - - rerender( - - - , - ); - expect(copyKey).toHaveFocus(); - expect(copyKey).toHaveAttribute('aria-disabled', 'true'); - expect(copyUri).toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByRole('status', { name: 'Copy feedback' })).toHaveTextContent('Copying…'); - await user.click(copyUri); - await user.keyboard('{Enter}'); - expect(onCopy).toHaveBeenCalledOnce(); - - rerender( - - - , - ); - expect(screen.getByRole('alert')).toHaveTextContent('Could not copy. Please try again.'); - expect(screen.getByRole('textbox', { name: 'Setup key' })).toHaveValue(setup.secret); - await user.click(copyUri); - expect(onCopy).toHaveBeenLastCalledWith(setup.uri); - - rerender( - - - , - ); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(screen.getByRole('status', { name: 'Copy feedback' })).toHaveTextContent('Copied'); - expect(screen.getByRole('dialog')).toBeVisible(); - expect(props.onOpenChange).not.toHaveBeenCalled(); - }); - it('shows preparation, offers retry on failure, and waits for setup data before verification', async () => { const user = userEvent.setup(); const { props, rerender } = renderView({ setup: undefined }); @@ -177,47 +111,6 @@ describe('UserProfileAddAuthenticatorDialog', () => { expect(props.onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); }); - it.each(['typing', 'pasting'] as const)('submits a complete authenticator code after %s', async method => { - const user = userEvent.setup(); - const onSubmit = vi.fn(); - render(); - const dialog = screen.getByRole('dialog', { name: 'Add an authenticator app' }); - expect(screen.getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible(); - expect(screen.queryByRole('button', { name: /Resend/ })).not.toBeInTheDocument(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus()); - await user.click(screen.getByRole('textbox', { name: 'Verification code' })); - - if (method === 'typing') { - await user.keyboard('12345'); - expect(onSubmit).not.toHaveBeenCalled(); - await user.keyboard('6'); - } else { - await user.paste('123456'); - } - - expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); - expect(screen.getByRole('dialog')).toBe(dialog); - }); - - it('submits the current code through Verify or the form and does not submit on Cancel', async () => { - const user = userEvent.setup(); - const { props } = renderView({ code: '654321' }); - await user.click(screen.getByRole('button', { name: 'Verify', exact: true })); - expect(props.onSubmit).toHaveBeenCalledExactlyOnceWith('654321'); - - const form = screen.getByRole('textbox', { name: 'Verification code' }).closest('form'); - if (!form) { - throw new Error('Verification form missing'); - } - form.requestSubmit(); - expect(props.onSubmit).toHaveBeenCalledTimes(2); - expect(props.onSubmit).toHaveBeenLastCalledWith('654321'); - - await user.click(screen.getByRole('button', { name: 'Cancel' })); - expect(props.onOpenChange).toHaveBeenCalledWith(false); - expect(props.onSubmit).toHaveBeenCalledTimes(2); - }); - it('blocks incomplete and pending submissions, including native form submission', async () => { const user = userEvent.setup(); const { props, rerender } = renderView({ code: '123' }); @@ -251,42 +144,6 @@ describe('UserProfileAddAuthenticatorDialog', () => { expect(props.onSubmit).not.toHaveBeenCalled(); }); - it('preserves the setup mode and code on failure, then clears feedback when retry begins', async () => { - const user = userEvent.setup(); - const { props, rerender } = renderView({ code: '123456' }); - const dialog = screen.getByRole('dialog'); - await user.click(screen.getByRole('button', { name: 'Can’t scan? View setup key' })); - - rerender( - - - , - ); - expect(screen.getByRole('dialog')).toBe(dialog); - expect(screen.getByRole('textbox', { name: 'Setup key' })).toHaveValue(setup.secret); - expect(screen.getByRole('textbox', { name: 'Setup URI' })).toHaveValue(setup.uri); - const group = screen.getByRole('group', { name: 'Verification code' }); - expect(group).toHaveAccessibleDescription('That code has expired. Please try again.'); - expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveAttribute('aria-invalid', 'true'); - await user.click(screen.getByRole('button', { name: 'Verify', exact: true })); - expect(props.onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); - - rerender( - - - , - ); - expect(group).not.toHaveAccessibleDescription(); - expect(screen.getByRole('textbox', { name: 'Verification code' })).not.toHaveAttribute('aria-invalid'); - expect(screen.getByRole('textbox', { name: 'Setup key' })).toHaveValue(setup.secret); - }); - it('keeps the entered code when switching between QR and manual setup', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-mfa.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-mfa.view.test.tsx deleted file mode 100644 index 7a0669dfae7..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-mfa.view.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; - -import { Card } from '../../../components/card'; -import { Dialog } from '../../../components/dialog'; -import { Flow } from '../../../components/flow'; -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileAddAuthenticatorView } from '../user-profile-add-authenticator.view'; -import { UserProfileAddMfaView } from '../user-profile-add-mfa.view'; - -describe('MFA selection', () => { - it('continues into setup within the same dialog', async () => { - const user = userEvent.setup(); - const onOpenChange = vi.fn(); - function Example() { - const [step, setStep] = useState('select'); - return ( - - - - - - {() => ( - <> - - - - - - - - )} - - - - - - ); - } - render(); - const dialog = screen.getByRole('dialog', { name: 'Add 2-step verification' }); - expect(within(dialog).queryByRole('button', { name: /SMS verification/ })).not.toBeInTheDocument(); - await user.click(within(dialog).getByRole('button', { name: /Authenticator app/ })); - await waitFor(() => expect(screen.getByRole('dialog', { name: 'Add an authenticator app' })).toBe(dialog)); - expect(screen.getAllByRole('dialog')).toHaveLength(1); - expect(onOpenChange).not.toHaveBeenCalled(); - expect(within(dialog).getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.dialog.test.tsx index 7232ecc7e2a..241b1752130 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.dialog.test.tsx @@ -39,24 +39,6 @@ function renderView(overrides: Partial = {}) { } describe('UserProfileAddSmsDialog', () => { - it('chooses an existing number with Select or requests a new number', async () => { - const user = userEvent.setup(); - const { props } = renderView(); - const select = screen.getByRole('combobox', { name: 'Phone number +1 (801) 555-0100' }); - await waitFor(() => expect(select).toHaveFocus()); - - await user.click(select); - await user.click(screen.getByRole('option', { name: '+1 (801) 555-0200' })); - expect(props.onSelectedPhoneIdChange).toHaveBeenCalledWith('work'); - expect(props.onSubmit).not.toHaveBeenCalled(); - - await user.click(screen.getByRole('button', { name: 'Continue' })); - expect(props.onSubmit).toHaveBeenCalledOnce(); - - await user.click(screen.getByRole('button', { name: 'Add a new phone number' })); - expect(props.onAddPhone).toHaveBeenCalledOnce(); - }); - it('adds and verifies a new number in the same dialog, preserving the number on Back', async () => { const user = userEvent.setup(); const onVerify = vi.fn(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-authenticator-setup.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-authenticator-setup.view.test.tsx index 94041064b29..4439cc4bf4c 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-authenticator-setup.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-authenticator-setup.view.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; @@ -53,27 +53,4 @@ describe('Authenticator setup', () => { expect(screen.getByRole('textbox', { name: 'Setup key' })).toHaveValue(setup.secret); expect(screen.getByRole('textbox', { name: 'Setup URI' })).toHaveValue(setup.uri); }); - - it('supports keyboard toggling and starts with the QR code again after dismissal', async () => { - const user = userEvent.setup(); - renderView(); - const trigger = screen.getByRole('button', { name: 'Set up authenticator' }); - await user.click(trigger); - await user.tab(); - const toggle = screen.getByRole('button', { name: 'Can’t scan? View setup key' }); - expect(toggle).toHaveFocus(); - await user.keyboard('{Enter}'); - expect(screen.getByRole('textbox', { name: 'Setup key' })).toBeVisible(); - expect(screen.getByRole('button', { name: 'Scan QR code instead' })).toHaveFocus(); - await user.keyboard(' '); - expect(screen.getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible(); - await user.keyboard('{Enter}'); - await user.keyboard('{Escape}'); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(trigger).toHaveFocus(); - await user.click(trigger); - expect(screen.getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible(); - expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); - }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx index b9f506e589a..8b133c419c7 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx @@ -1,12 +1,10 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useRef, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; import type { UserProfileBackupCodesDialogProps } from '../user-profile-backup-codes.dialog'; import { UserProfileBackupCodesDialog } from '../user-profile-backup-codes.dialog'; -import { UserProfileMfaSectionView } from '../user-profile-mfa-section.view'; const codes = ['pwkkay19', 'cvgunlqs', '4czio578', 'a38eewtw', 'qqnwzvyr', 'znq8j16s']; @@ -31,70 +29,6 @@ function renderView(overrides: Partial = {}) } describe('UserProfileBackupCodesDialog', () => { - it('shows the save-backup-codes step', () => { - renderView(); - expect(screen.getByRole('dialog', { name: 'Save your backup codes' })).toHaveAccessibleDescription( - 'Save these somewhere safe. Each code can be used once if you lose access to your phone.', - ); - expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(codes); - expect(screen.getByRole('button', { name: 'Download', exact: true })).toBeVisible(); - expect(screen.getByRole('button', { name: 'Copy and close' })).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Save backup codes' })).not.toBeInTheDocument(); - }); - - it('returns focus to the section’s Add button after completing a flow without a dialog trigger', async () => { - const user = userEvent.setup(); - function Example() { - const [open, setOpen] = useState(true); - const target = useRef(null); - return ( - - - setOpen(false)} - /> - - ); - } - render(); - await user.click(screen.getByRole('button', { name: 'Copy and close' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Add verification method' })).toHaveFocus()); - }); - - it('displays all supplied codes and delegates saving without closing before success', async () => { - const user = userEvent.setup(); - const { props } = renderView(); - expect(screen.getByRole('dialog', { name: 'Save your backup codes' })).toHaveAccessibleDescription( - 'Save these somewhere safe. Each code can be used once if you lose access to your phone.', - ); - const list = screen.getByRole('list', { name: 'Backup codes' }); - expect( - within(list) - .getAllByRole('listitem') - .map(item => item.textContent), - ).toEqual(codes); - await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus()); - expect(props.onRetry).not.toHaveBeenCalled(); - - await user.click(screen.getByRole('button', { name: 'Download', exact: true })); - expect(props.onDownload).toHaveBeenCalledTimes(1); - expect(props.onOpenChange).not.toHaveBeenCalled(); - await user.click(screen.getByRole('button', { name: 'Copy and close' })); - expect(props.onCopy).toHaveBeenCalledTimes(1); - expect(props.onOpenChange).not.toHaveBeenCalled(); - }); - it('retries failed generation without offering empty codes to save', async () => { const user = userEvent.setup(); const { props, rerender } = renderView({ codes: [], pendingAction: 'generate' }); @@ -155,30 +89,4 @@ describe('UserProfileBackupCodesDialog', () => { expect(action === 'copy' ? props.onCopy : props.onDownload).toHaveBeenCalledTimes(1); }, ); - - it('replaces old codes during regeneration and renders the newly supplied set', () => { - const { props, rerender } = renderView(); - rerender( - - - , - ); - expect(screen.queryByRole('list')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Download', exact: true })).not.toBeInTheDocument(); - - const replacementCodes = ['newcode1', 'newcode2']; - rerender( - - - , - ); - expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(replacementCodes); - expect(screen.queryByText(codes[0])).not.toBeInTheDocument(); - }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx index 7b4ac1ca41f..76a3a531a9b 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx @@ -6,9 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { Card } from '../../../components/card'; import { Flow } from '../../../components/flow'; import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileAddAuthenticatorView } from '../user-profile-add-authenticator.view'; import { UserProfileAddSmsView } from '../user-profile-add-sms.view'; -import { UserProfileBackupCodesView } from '../user-profile-backup-codes.view'; describe('MFA cards', () => { it.each(['select', 'phone'] as const)('focuses the %s field when entering SMS from another card', async step => { @@ -61,94 +59,4 @@ describe('MFA cards', () => { expect(screen.getByRole(step === 'select' ? 'combobox' : 'textbox', { name: /Phone/ })).toHaveFocus(); }); }); - - it('saves backup codes on a card and supports cancelling a failed generation', async () => { - const user = userEvent.setup(); - const props = { codes: ['demo-code'], onCopy: vi.fn(), onDownload: vi.fn(), onRetry: vi.fn(), onCancel: vi.fn() }; - const { rerender } = render( - - - - - , - ); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Download', exact: true })); - expect(props.onDownload).toHaveBeenCalledOnce(); - await user.click(screen.getByRole('button', { name: 'Copy and close', exact: true })); - expect(props.onCopy).toHaveBeenCalledOnce(); - rerender( - - - - - , - ); - await user.click(screen.getByRole('button', { name: 'Try again', exact: true })); - expect(props.onRetry).toHaveBeenCalledOnce(); - await user.click(screen.getByRole('button', { name: 'Cancel', exact: true })); - expect(props.onCancel).toHaveBeenCalledOnce(); - }); - - it('renders authenticator setup on a card and delegates verification and cancellation', async () => { - const user = userEvent.setup(); - const onCancel = vi.fn(); - const onSubmit = vi.fn(); - render( - - - - - , - ); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Verify', exact: true })); - expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); - await user.click(screen.getByRole('button', { name: 'Cancel', exact: true })); - expect(onCancel).toHaveBeenCalledOnce(); - }); - - it('renders SMS selection without a dialog and delegates cancellation', async () => { - const user = userEvent.setup(); - const onCancel = vi.fn(); - const onSubmit = vi.fn(); - render( - - - - - , - ); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Continue', exact: true })); - expect(onSubmit).toHaveBeenCalledOnce(); - await user.click(screen.getByRole('button', { name: 'Cancel', exact: true })); - expect(onCancel).toHaveBeenCalledOnce(); - }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx index e6971dca118..3a6ea882261 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx @@ -1,11 +1,10 @@ import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { deferred } from '../../../machines/__tests__/test-utils'; import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserProfileMfaMethod, UserProfileMfaSectionViewProps } from '../user-profile-mfa-section.view'; +import type { UserProfileMfaSectionViewProps } from '../user-profile-mfa-section.view'; import { UserProfileMfaSectionView } from '../user-profile-mfa-section.view'; function renderView(overrides: Partial = {}) { @@ -29,58 +28,6 @@ function renderView(overrides: Partial = {}) { } describe('MFA section', () => { - it('uses a supplied setup trigger without opening a separate selection dialog', async () => { - const user = userEvent.setup(); - const onOpen = vi.fn(); - const { props } = renderView({ - addControl: ( - - ), - }); - await user.click(screen.getByRole('button', { name: 'Set up verification' })); - expect(onOpen).toHaveBeenCalledOnce(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Add verification method' })).not.toBeInTheDocument(); - expect(props.onAdd).not.toHaveBeenCalled(); - }); - - it('hides removal for protected SMS while keeping backup regeneration available', async () => { - const user = userEvent.setup(); - const { props } = renderView({ - methods: [ - { id: 'phone', type: 'sms', description: '+1 801-555-0100', isDefault: true, canRemove: false }, - { id: 'backup', type: 'backup-codes' }, - ], - }); - expect(screen.getByText('+1 801-555-0100')).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Manage SMS verification +1 801-555-0100' })).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); - expect(screen.getAllByRole('menuitem')).toHaveLength(1); - await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); - expect(props.onRegenerateBackupCodes).toHaveBeenCalledOnce(); - expect(props.onRemove).not.toHaveBeenCalled(); - }); - - it('allows default selection for protected SMS without offering removal', async () => { - const user = userEvent.setup(); - const { props } = renderView({ - methods: [ - { id: 'totp', type: 'authenticator', isDefault: true }, - { id: 'phone', type: 'sms', description: '+1 801-555-0100', canSetDefault: true, canRemove: false }, - ], - }); - await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0100' })); - expect(screen.getAllByRole('menuitem')).toHaveLength(1); - await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); - expect(props.onSetDefault).toHaveBeenCalledExactlyOnceWith('phone'); - expect(props.onRemove).not.toHaveBeenCalled(); - }); - it.each(['sms', 'authenticator'] as const)('continues immediately when the %s option is activated', async type => { const user = userEvent.setup(); const { props } = renderView({ @@ -101,23 +48,6 @@ describe('MFA section', () => { expect(screen.getByText('+1 801-555-0100')).toBeVisible(); }); - it.each(['authenticator', 'sms'] as const)('displays the supplied default state for %s', type => { - const { props, rerender } = renderView({ methods: [{ id: 'method_1', type, isDefault: true }] }); - - expect(screen.getByText('Default')).toBeVisible(); - - rerender( - - - , - ); - - expect(screen.queryByText('Default')).not.toBeInTheDocument(); - }); - it('cancels without selecting a method and restores focus to Add', async () => { const user = userEvent.setup(); const { props } = renderView(); @@ -136,68 +66,6 @@ describe('MFA section', () => { expect(add).toHaveFocus(); }); - it.each(['{Enter}', ' '])('activates a method with %s', async key => { - const user = userEvent.setup(); - const { props } = renderView(); - const add = screen.getByRole('button', { name: 'Add verification method' }); - await user.click(add); - await user.tab(); - await user.tab(); - expect(screen.getByRole('button', { name: /Authenticator app Get codes/ })).toHaveFocus(); - await user.keyboard(key); - - expect(props.onAdd).toHaveBeenCalledExactlyOnceWith('authenticator'); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(add).toHaveFocus(); - }); - - it('offers only caller-supplied methods, including a second SMS method', async () => { - const user = userEvent.setup(); - const { props } = renderView({ - methods: [{ id: 'existing', type: 'sms' }], - addableMethods: ['sms'], - }); - await user.click(screen.getByRole('button', { name: 'Add verification method' })); - expect(screen.queryByRole('button', { name: /Authenticator app/ })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /Backup codes/ })).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /SMS verification Get a code/ })); - expect(props.onAdd).toHaveBeenCalledExactlyOnceWith('sms'); - }); - - it.each([[], undefined])('keeps the section visible when addable methods are %s', addableMethods => { - renderView({ addableMethods }); - expect(screen.getByText('No verification methods added')).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Add verification method' })).not.toBeInTheDocument(); - }); - - it('adds caller-enabled backup codes and offers only regeneration after the row is supplied', async () => { - const user = userEvent.setup(); - const methods: UserProfileMfaMethod[] = [{ id: 'personal', type: 'sms', isDefault: true }]; - const { props, rerender } = renderView({ methods, addableMethods: ['backup-codes'] }); - - await user.click(screen.getByRole('button', { name: 'Add verification method' })); - const picker = screen.getByRole('dialog', { name: 'Add 2-step verification' }); - await user.click(within(picker).getByRole('button', { name: /Backup codes One-time codes/ })); - expect(props.onAdd).toHaveBeenCalledExactlyOnceWith('backup-codes'); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - - rerender( - - - , - ); - expect(screen.queryByRole('button', { name: 'Add verification method' })).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); - expect(screen.getAllByRole('menuitem')).toHaveLength(1); - await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); - expect(props.onRegenerateBackupCodes).toHaveBeenCalledOnce(); - expect(props.onRemove).not.toHaveBeenCalled(); - }); - it('confirms the selected SMS method and restores focus when removal is cancelled', async () => { const user = userEvent.setup(); const { props } = renderView({ @@ -232,40 +100,6 @@ describe('MFA section', () => { await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); }); - it('offers Set as default only for an eligible SMS row and reflects updated props', async () => { - const user = userEvent.setup(); - const { props, rerender } = renderView({ - methods: [ - { id: 'personal', type: 'sms', description: '+1 801-555-0100', isDefault: true }, - { id: 'work', type: 'sms', description: '+1 801-555-0200', canSetDefault: true }, - ], - }); - - await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0100' })); - expect(screen.queryByRole('menuitem', { name: 'Set as default' })).not.toBeInTheDocument(); - await user.keyboard('{Escape}'); - await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' })); - await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); - - expect(props.onSetDefault).toHaveBeenCalledExactlyOnceWith('work'); - - rerender( - - - , - ); - - expect(screen.getAllByText('Default')).toHaveLength(1); - await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' })); - expect(screen.queryByRole('menuitem', { name: 'Set as default' })).not.toBeInTheDocument(); - }); - it('marks the selected default change pending and blocks overlapping method actions', async () => { const user = userEvent.setup(); const pending = deferred(); @@ -357,119 +191,6 @@ describe('MFA section', () => { }, ); - it('explains authenticator removal without referring to a phone number', async () => { - const user = userEvent.setup(); - const { props } = renderView({ methods: [{ id: 'totp', type: 'authenticator', isDefault: true }] }); - - await user.click(screen.getByRole('button', { name: 'Manage Authenticator app' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); - const dialog = screen.getByRole('alertdialog', { name: 'Remove authenticator app' }); - expect(dialog).toHaveAccessibleDescription( - 'Verification codes from this authenticator will no longer be required when signing in. Your account may not be as secure.', - ); - expect(props.onRemove).not.toHaveBeenCalled(); - await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); - - expect(props.onRemove).toHaveBeenCalledExactlyOnceWith('totp'); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('waits for removal of the final method before showing the empty section', async () => { - const user = userEvent.setup(); - const pending = deferred(); - const onRemove = vi.fn(() => pending.promise); - const onAdd = vi.fn(); - - function Example() { - const [methods, setMethods] = useState([{ id: 'totp', type: 'authenticator' }]); - return ( - - { - await onRemove(); - setMethods(current => current.filter(method => method.id !== id)); - }} - /> - - ); - } - - render(); - await user.click(screen.getByRole('button', { name: 'Manage Authenticator app' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); - await user.click(screen.getByRole('button', { name: 'Remove', exact: true })); - - expect(screen.getByRole('button', { name: 'Remove', exact: true })).toHaveAttribute('aria-busy', 'true'); - expect(screen.getByText('Authenticator app')).toBeInTheDocument(); - expect(screen.queryByText('No verification methods added')).not.toBeInTheDocument(); - await user.keyboard('{Escape}'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - - await act(async () => { - pending.resolve(); - await pending.promise; - }); - - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(screen.getByRole('region', { name: '2-step verification' })).toBeVisible(); - expect(screen.queryByText('Authenticator app')).not.toBeInTheDocument(); - expect(screen.getByText('No verification methods added')).toBeVisible(); - expect(onRemove).toHaveBeenCalledOnce(); - await user.click(screen.getByRole('button', { name: 'Add verification method' })); - await user.click(screen.getByRole('button', { name: /Authenticator app Get codes/ })); - expect(onAdd).toHaveBeenCalledExactlyOnceWith('authenticator'); - }); - - it('keeps a failed SMS removal open and retries the same method', async () => { - const user = userEvent.setup(); - const onRemove = vi - .fn<(id: string) => Promise>() - .mockRejectedValueOnce(new Error('Could not remove this method. Please try again.')) - .mockResolvedValueOnce(undefined); - - function Example() { - const [methods, setMethods] = useState([ - { id: 'personal', type: 'sms', description: '+1 801-555-0100' }, - { id: 'work', type: 'sms', description: '+1 801-555-0200' }, - { id: 'backup', type: 'backup-codes' }, - ]); - return ( - - { - await onRemove(id); - setMethods(current => current.filter(method => method.id !== id)); - }} - /> - - ); - } - - render(); - await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); - await user.click(screen.getByRole('button', { name: 'Remove', exact: true })); - const dialog = screen.getByRole('alertdialog'); - expect(await within(dialog).findByRole('alert')).toHaveTextContent( - 'Could not remove this method. Please try again.', - ); - expect(dialog).toHaveAccessibleDescription( - 'You will no longer receive sign-in verification codes at +1 801-555-0200. The phone number will remain on your account.', - ); - expect(screen.getByText('+1 801-555-0200')).toBeInTheDocument(); - await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); - - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onRemove.mock.calls).toEqual([['work'], ['work']]); - expect(screen.queryByText('+1 801-555-0200')).not.toBeInTheDocument(); - expect(screen.getByText('+1 801-555-0100')).toBeVisible(); - expect(screen.getByText('Backup codes')).toBeVisible(); - }); - it('renders supplied backup codes without other methods and only offers regeneration', async () => { const user = userEvent.setup(); const { props } = renderView({ methods: [{ id: 'backup', type: 'backup-codes' }] }); @@ -484,35 +205,4 @@ describe('MFA section', () => { expect(props.onRemove).not.toHaveBeenCalled(); expect(props.onSetDefault).not.toHaveBeenCalled(); }); - - it('keeps rows visible without action callbacks', () => { - renderView({ - methods: [ - { id: 'totp', type: 'authenticator', isDefault: true }, - { id: 'phone', type: 'sms', description: '+1 801-555-0100', canSetDefault: true }, - { id: 'backup', type: 'backup-codes' }, - ], - onAdd: undefined, - onRemove: undefined, - onSetDefault: undefined, - onRegenerateBackupCodes: undefined, - }); - - expect(screen.getByText('Authenticator app')).toBeVisible(); - expect(screen.getByText('+1 801-555-0100')).toBeVisible(); - expect(screen.getByText('Backup codes')).toBeVisible(); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - }); - - it.each([true, false])('keeps the empty section visible with Add available: %s', canAdd => { - renderView({ onAdd: canAdd ? vi.fn() : undefined }); - - expect(screen.getByRole('region', { name: '2-step verification' })).toBeVisible(); - expect(screen.getByText('No verification methods added')).toBeVisible(); - if (canAdd) { - expect(screen.getByRole('button', { name: 'Add verification method' })).toBeVisible(); - } else { - expect(screen.queryByRole('button', { name: 'Add verification method' })).not.toBeInTheDocument(); - } - }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 257800c1a11..8de5ea7b1c1 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -57,23 +57,6 @@ function renderView(overrides: Partial = {}) } describe('UserProfileSecurityPanelView', () => { - it('passes a shared MFA setup control into the section', async () => { - const onOpen = vi.fn(); - renderView({ - mfaAddControl: ( - - ), - }); - await userEvent.click(screen.getByRole('button', { name: 'Set up MFA' })); - expect(onOpen).toHaveBeenCalledOnce(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - it('composes authentication, active devices, and the danger zone', () => { renderView({ onDeleteAccount: vi.fn(() => Promise.resolve()) }); @@ -249,24 +232,6 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); }); - it('forwards default changes and shows their errors in the MFA section', async () => { - const user = userEvent.setup(); - const onSetDefaultMfaMethod = vi.fn(() => { - throw new Error('Unable to change the default method.'); - }); - renderView({ - mfaMethods: [{ id: 'sms_1', type: 'sms', canSetDefault: true }], - onSetDefaultMfaMethod, - }); - - await user.click(screen.getByRole('button', { name: 'Manage SMS verification' })); - await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); - - expect(onSetDefaultMfaMethod).toHaveBeenCalledExactlyOnceWith('sms_1'); - expect(await screen.findByRole('alert')).toHaveTextContent('Unable to change the default method.'); - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); - }); - it('shows supplied backup codes independently and only allows regeneration', async () => { const onRegenerateBackupCodes = vi.fn(); const onRemoveMfaMethod = vi.fn(); diff --git a/packages/swingset/src/stories/fixtures/user-profile-authenticator.test.ts b/packages/swingset/src/stories/fixtures/user-profile-authenticator.test.ts deleted file mode 100644 index 53ef5953fd7..00000000000 --- a/packages/swingset/src/stories/fixtures/user-profile-authenticator.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { useUserProfileAuthenticatorPreparationFixture } from './user-profile-authenticator'; - -describe('Authenticator card fixture', () => { - beforeEach(() => vi.useFakeTimers()); - afterEach(() => vi.useRealTimers()); - - it('starts with retry feedback and cancels pending preparation when dismissed', async () => { - const { result } = renderHook(() => useUserProfileAuthenticatorPreparationFixture({ initialOpen: true })); - expect(result.current.open).toBe(true); - expect(result.current.setupErrorMessage).toContain('Unable to prepare'); - act(() => result.current.onRetry()); - expect(result.current.setupErrorMessage).toBeUndefined(); - expect(result.current.setup).toBeUndefined(); - act(() => result.current.onOpenChange(false)); - await act(() => vi.advanceTimersByTimeAsync(1000)); - expect(result.current.open).toBe(false); - expect(result.current.setup).toBeUndefined(); - - act(() => result.current.onOpenChange(true)); - await act(() => vi.advanceTimersByTimeAsync(1000)); - expect(result.current.setupErrorMessage).toContain('Unable to prepare'); - act(() => result.current.onRetry()); - await act(() => vi.advanceTimersByTimeAsync(1000)); - expect(result.current.setup?.secret).toBe('JBSWY3DPEHPK3PXP'); - expect(result.current.setupErrorMessage).toBeUndefined(); - }); -}); diff --git a/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts b/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts index e6f5faa3688..fc0cbea02b1 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts @@ -1,5 +1,5 @@ import type { UserProfileAddAuthenticatorDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-add-authenticator.dialog'; -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; export const authenticatorSetup = { secret: 'JBSWY3DPEHPK3PXP', @@ -26,41 +26,3 @@ export function useAuthenticatorCopy() { }, }; } - -export function useUserProfileAuthenticatorPreparationFixture({ - initialOpen = false, -} = {}): UserProfileAddAuthenticatorDialogProps { - const [open, setOpen] = useState(initialOpen); - const [preparation, setPreparation] = useState<'loading' | 'error' | 'ready'>(initialOpen ? 'error' : 'loading'); - const [code, setCode] = useState(''); - const preparationTimer = useRef | undefined>(undefined); - const copy = useAuthenticatorCopy(); - - useEffect(() => () => clearTimeout(preparationTimer.current), []); - - const prepare = (result: 'error' | 'ready') => { - clearTimeout(preparationTimer.current); - setPreparation('loading'); - preparationTimer.current = setTimeout(() => setPreparation(result), 1000); - }; - - return { - ...copy, - open, - onOpenChange: nextOpen => { - setOpen(nextOpen); - if (nextOpen) { - setCode(''); - prepare('error'); - } else { - clearTimeout(preparationTimer.current); - } - }, - setup: preparation === 'ready' ? authenticatorSetup : undefined, - setupErrorMessage: preparation === 'error' ? 'Unable to prepare your authenticator. Please try again.' : undefined, - onRetry: () => prepare('ready'), - code, - onCodeChange: setCode, - onSubmit: () => setOpen(false), - }; -} diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx index 6fe46e8c2dd..367b382c00e 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx @@ -2,42 +2,58 @@ import '@testing-library/jest-dom/vitest'; import { Dialog } from '@clerk/mosaic/components/dialog'; import { UserProfileView } from '@clerk/mosaic/features/user-profile/user-profile.view'; -import { UserProfileMfaSectionView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; -import { UserProfileSecurityPanelView } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { useUserProfileFixture } from './user-profile'; -import { useUserProfileMfaExample } from './user-profile-mfa-example'; -describe('shared MFA examples', () => { - it('finishes authenticator setup inside the Profile overlay without closing the Profile', async () => { +function ProfileExample({ overlay = false }: { overlay?: boolean }) { + const { pages } = useUserProfileFixture(); + const profile = ( + + ); + return ( + + {overlay ? ( + + {profile} + + ) : ( + profile + )} + + ); +} + +describe('Profile MFA flows', () => { + it('retries copying and verification, then finishes authenticator setup without closing the Profile overlay', async () => { const user = userEvent.setup(); - function Example() { - const { pages } = useUserProfileFixture(); - return ( - - - - - - - - ); - } - render(); + vi.spyOn(navigator.clipboard, 'writeText') + .mockRejectedValueOnce(new Error('Clipboard unavailable')) + .mockResolvedValue(); + render(); const profile = screen.getByRole('dialog'); const add = within(profile).getByRole('button', { name: 'Add verification method' }); await user.click(add); const setup = screen.getByRole('dialog', { name: 'Add 2-step verification' }); await user.click(within(setup).getByRole('button', { name: /Authenticator app/ })); - await user.type(await within(setup).findByRole('textbox', { name: 'Verification code' }), '123456'); + await user.click(within(setup).getByRole('button', { name: 'Can’t scan? View setup key' })); + await user.click(within(setup).getByRole('button', { name: 'Copy setup key' })); + expect(await within(setup).findByRole('alert')).toHaveTextContent('Could not copy. Please try again.'); + await user.click(within(setup).getByRole('button', { name: 'Copy setup key' })); + expect(within(setup).getByRole('status', { name: 'Copy feedback' })).toHaveTextContent('Copied'); + const code = within(setup).getByRole('textbox', { name: 'Verification code' }); + await user.type(code, '000000'); + expect(await within(setup).findByText('That code is incorrect. Try again.')).toBeVisible(); + expect(within(setup).getByRole('textbox', { name: 'Setup key' })).toBeVisible(); + await user.clear(code); + await user.type(code, '123456'); await within(setup).findByRole('list', { name: 'Backup codes' }); await user.click(within(setup).getByRole('button', { name: 'Copy and close' })); await waitFor(() => expect(setup).not.toBeInTheDocument()); @@ -46,52 +62,30 @@ describe('shared MFA examples', () => { expect(within(profile).getByRole('button', { name: 'Manage Authenticator app' })).toBeVisible(); }); - it.each(['section', 'security', 'profile'] as const)( - 'uses the enrollment and backup-code flow in %s', - async surface => { - const user = userEvent.setup(); - const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); - function Example() { - const mfa = useUserProfileMfaExample(); - const { pages } = useUserProfileFixture(); - return ( - - {surface === 'section' ? ( - - ) : surface === 'security' ? ( - - ) : ( - - )} - - ); - } - render(); - const add = screen.getByRole('button', { name: 'Add verification method' }); - await user.click(add); - const dialog = screen.getByRole('dialog', { name: 'Add 2-step verification' }); - await user.click(within(dialog).getByRole('button', { name: /SMS verification/ })); - await user.click(within(dialog).getByRole('button', { name: 'Continue' })); - const code = await screen.findByRole('textbox', { name: 'Verification code' }); - await user.type(code, '000000'); - expect(await screen.findByText('That code is incorrect. Try again.')).toBeVisible(); - expect(screen.getByRole('dialog')).toBe(dialog); - await user.clear(code); - await user.type(code, '123456'); - await screen.findByRole('list', { name: 'Backup codes' }); - expect(screen.getByRole('dialog', { name: 'Save your backup codes' })).toBe(dialog); - await user.click(screen.getByRole('button', { name: 'Copy and close' })); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(copy).toHaveBeenCalledOnce(); - expect(add).toHaveFocus(); - await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); - expect(screen.getAllByRole('menuitem')).toHaveLength(1); - await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); - expect(await screen.findByText('demo-new-01')).toBeVisible(); - }, - ); + it('retries SMS verification in one dialog, saves backup codes, and regenerates them', async () => { + const user = userEvent.setup(); + const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + render(); + const add = screen.getByRole('button', { name: 'Add verification method' }); + await user.click(add); + const dialog = screen.getByRole('dialog', { name: 'Add 2-step verification' }); + await user.click(within(dialog).getByRole('button', { name: /SMS verification/ })); + await user.click(within(dialog).getByRole('button', { name: 'Continue' })); + const code = await screen.findByRole('textbox', { name: 'Verification code' }); + await user.type(code, '000000'); + expect(await screen.findByText('That code is incorrect. Try again.')).toBeVisible(); + expect(screen.getByRole('dialog')).toBe(dialog); + await user.clear(code); + await user.type(code, '123456'); + await screen.findByRole('list', { name: 'Backup codes' }); + expect(screen.getByRole('dialog', { name: 'Save your backup codes' })).toBe(dialog); + await user.click(screen.getByRole('button', { name: 'Copy and close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(copy).toHaveBeenCalledOnce(); + expect(add).toHaveFocus(); + await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); + expect(screen.getAllByRole('menuitem')).toHaveLength(1); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + expect(await screen.findByText('demo-new-01')).toBeVisible(); + }); }); diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts index 1694c9820bd..614d05229a2 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts @@ -1,10 +1,7 @@ -import { act, render, renderHook, screen } from '@testing-library/react'; -import { createElement } from 'react'; +import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { UserProfileBackupCodesDialog } from '../../../../mosaic/src/features/user-profile/user-profile-backup-codes.dialog'; import { deferred } from '../../../../mosaic/src/machines/__tests__/test-utils'; -import { MosaicProvider } from '../../../../mosaic/src/MosaicProvider'; import { useUserProfileMfaFixture } from './user-profile-mfa'; const enrollmentCodes = ['enrollment-code-1', 'enrollment-code-2']; @@ -42,62 +39,6 @@ describe('MFA playground', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); - it.each(['sms', 'authenticator'] as const)('rejects 000000 for %s and accepts a corrected code', async type => { - const { result } = setup(); - act(() => result.current.section.onAdd?.(type)); - if (type === 'sms') { - await complete(() => result.current.sms.onSubmit()); - expect(result.current.sms.step).toBe('verify'); - } - const methods = result.current.section.methods; - act(() => result.current[type].onCodeChange('000000')); - act(() => result.current[type].onSubmit('000000')); - expect(result.current[type].isPending).toBe(true); - await complete(() => undefined); - expect(result.current[type].open).toBe(true); - expect(result.current[type].isPending).toBe(false); - expect(result.current[type].errorMessage).toBe('That code is incorrect. Try again.'); - expect(result.current.section.methods).toEqual(methods); - expect(result.current.backupCodes.open).toBe(false); - - act(() => result.current[type].onCodeChange('123456')); - expect(result.current[type].errorMessage).toBeUndefined(); - await complete(() => result.current[type].onSubmit('123456')); - expect(result.current[type].open).toBe(false); - expect(result.current.backupCodes.open).toBe(true); - }); - - it('keeps the setup dialog open from method selection through enrollment and backup codes', async () => { - const { result } = setup(); - act(() => result.current.setup.onOpenChange(true)); - expect(result.current.setup).toMatchObject({ open: true, step: 'select' }); - act(() => result.current.section.onAdd?.('sms')); - expect(result.current.setup).toMatchObject({ open: true, step: 'sms' }); - act(() => result.current.sms.onSelectedPhoneIdChange('other')); - await complete(() => result.current.sms.onSubmit()); - expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); - await complete(() => result.current.backupCodes.onCopy()); - expect(result.current.setup.open).toBe(false); - act(() => result.current.setup.onOpenChange(true)); - expect(result.current.setup).toMatchObject({ open: true, step: 'select' }); - }); - - it.each(['sms', 'backup-codes'] as const)('starts an inline %s example at its first screen', initialFlow => { - const { result } = renderHook(() => - useUserProfileMfaFixture({ - initialFlow, - enrollmentBackupCodes: enrollmentCodes, - onCopy: vi.fn(), - onDownload: vi.fn(), - }), - ); - expect(result.current.sms.open).toBe(initialFlow === 'sms'); - expect(result.current.backupCodes.open).toBe(initialFlow === 'backup-codes'); - expect(result.current.sms.step).toBe('select'); - expect(result.current.sms.selectedPhoneId).toBe('work'); - expect(result.current.backupCodes.codes).toEqual(initialFlow === 'backup-codes' ? enrollmentCodes : []); - }); - it('creates backup codes for existing SMS enrollment when the instance enables them later', async () => { const { result, rerender, onGenerateBackupCodes } = setup([], false); expect(result.current.section.methods.map(method => method.type)).toEqual(['sms']); @@ -126,27 +67,6 @@ describe('MFA playground', () => { expect(onGenerateBackupCodes).toHaveBeenCalledOnce(); }); - it.each(['sms', 'authenticator'] as const)('opens Save your backup codes after %s enrollment', async type => { - const { result } = setup(); - act(() => result.current.section.onAdd?.(type)); - if (type === 'authenticator') { - await complete(() => result.current.authenticator.onSubmit('123456')); - } else { - act(() => result.current.sms.onSelectedPhoneIdChange('other')); - await complete(() => result.current.sms.onSubmit()); - } - expect(result.current.sms.open).toBe(false); - expect(result.current.authenticator.open).toBe(false); - expect(result.current.backupCodes.open).toBe(true); - render( - createElement(MosaicProvider, null, createElement(UserProfileBackupCodesDialog, result.current.backupCodes)), - ); - expect(screen.getByRole('dialog', { name: 'Save your backup codes' })).toBeVisible(); - expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(enrollmentCodes); - expect(screen.getByRole('button', { name: 'Download' })).toBeVisible(); - expect(screen.getByRole('button', { name: 'Copy and close' })).toBeVisible(); - }); - it('withholds backup-code creation until the instance enables codes and the user has MFA', async () => { const { result, rerender, onGenerateBackupCodes } = setup([], false); act(() => result.current.section.onAdd?.('backup-codes')); @@ -198,18 +118,6 @@ describe('MFA playground', () => { }, ); - it('automatically adds supplied backup codes during enrollment and removes their Add choice', async () => { - const { result } = setup(); - expect(result.current.section.addableMethods).toEqual(['sms', 'authenticator', 'backup-codes']); - act(() => result.current.section.onAdd?.('authenticator')); - await complete(() => result.current.authenticator.onSubmit('123456')); - expect(result.current.section.methods.map(method => method.type)).toEqual(['authenticator', 'sms', 'backup-codes']); - const codes = result.current.backupCodes.codes; - expect(codes).toEqual(enrollmentCodes); - expect(result.current.backupCodes.open).toBe(true); - expect(result.current.backupCodes.codes).toEqual(codes); - }); - it('enrolls an authenticator on the first attempt, saves backup codes, and regenerates them', async () => { const { result, onCopy, onDownload, onGenerateBackupCodes } = setup(); act(() => result.current.section.onAdd?.('authenticator')); @@ -292,6 +200,10 @@ describe('MFA playground', () => { 'other', ]); await complete(() => void result.current.section.onSetDefault?.('other')); + expect(result.current.section.methods.filter(method => method.type === 'sms').map(method => method.id)).toEqual([ + 'other', + 'personal', + ]); expect(result.current.section.methods.filter(method => method.isDefault).map(method => method.id)).toEqual([ 'authenticator', ]); @@ -307,28 +219,6 @@ describe('MFA playground', () => { }); }); - it.each([false, true])('orders the default SMS number first with authenticator enabled: %s', async authenticator => { - const { result } = setup([]); - if (authenticator) { - act(() => result.current.section.onAdd?.('authenticator')); - await complete(() => result.current.authenticator.onSubmit('123456')); - } - act(() => result.current.section.onAdd?.('sms')); - act(() => result.current.sms.onSelectedPhoneIdChange('other')); - await complete(() => result.current.sms.onSubmit()); - expect(result.current.section.methods.filter(method => method.type === 'sms').map(method => method.id)).toEqual([ - 'personal', - 'other', - ]); - - await complete(() => void result.current.section.onSetDefault?.('other')); - - expect(result.current.section.methods.filter(method => method.type === 'sms').map(method => method.id)).toEqual([ - 'other', - 'personal', - ]); - }); - it('preserves codes on a real copy failure and closes after a successful retry', async () => { const { result, onCopy } = setup(); act(() => result.current.section.onAdd?.('authenticator')); @@ -380,23 +270,6 @@ describe('MFA playground', () => { expect(result.current.backupCodes.codes).toEqual(enrollmentCodes); }); - it('keeps enrollment and backup codes when dismissed during regeneration', async () => { - const { result } = setup(); - act(() => result.current.section.onAdd?.('authenticator')); - await complete(() => result.current.authenticator.onSubmit('123456')); - expect(result.current.backupCodes.open).toBe(true); - expect(result.current.section.methods.some(method => method.type === 'authenticator')).toBe(true); - act(() => result.current.backupCodes.onOpenChange(false)); - expect(result.current.backupCodes.open).toBe(false); - expect(result.current.section.methods.some(method => method.type === 'authenticator')).toBe(true); - expect(result.current.section.addableMethods).toEqual(['sms']); - expect(result.current.section.methods.some(method => method.type === 'backup-codes')).toBe(true); - expect(result.current.backupCodes.codes).toEqual(enrollmentCodes); - await complete(() => result.current.section.onRegenerateBackupCodes?.()); - expect(result.current.section.methods.some(method => method.type === 'authenticator')).toBe(true); - expect(result.current.backupCodes.open).toBe(true); - }); - it.each(['authenticator', 'sms'] as const)( 'finishes %s enrollment without backup codes when none are supplied', async type => { @@ -441,4 +314,3 @@ describe('MFA playground', () => { }, ); }); -import '@testing-library/jest-dom/vitest'; From e0acbdf161a55d2f43c1c992276364a11a2766fb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 17 Sep 2026 16:37:35 -0600 Subject: [PATCH 26/38] refactor(mosaic): align MFA default action with phone and email --- .../user-profile-mfa-section.view.test.tsx | 103 +++++++----------- .../user-profile/user-profile-action-menu.tsx | 24 +--- .../user-profile-mfa-row.view.tsx | 56 +++------- .../user-profile-mfa-section.controller.ts | 62 ----------- .../user-profile-mfa-section.styles.ts | 1 - .../user-profile-mfa-section.view.tsx | 47 ++++++-- 6 files changed, 95 insertions(+), 198 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-mfa-section.controller.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx index 3a6ea882261..888c4111661 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx @@ -100,16 +100,14 @@ describe('MFA section', () => { await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); }); - it('marks the selected default change pending and blocks overlapping method actions', async () => { + it('hides Set as default during an update while keeping the menu available', async () => { const user = userEvent.setup(); const pending = deferred(); const onSetDefault = vi.fn(() => pending.promise); - const { props } = renderView({ + renderView({ methods: [ - { id: 'personal', type: 'sms', description: '+1 801-555-0100', isDefault: true }, { id: 'work', type: 'sms', description: '+1 801-555-0200', canSetDefault: true }, { id: 'other', type: 'sms', description: '+1 801-555-0300', canSetDefault: true }, - { id: 'backup', type: 'backup-codes' }, ], onSetDefault, }); @@ -117,79 +115,56 @@ describe('MFA section', () => { const selected = screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' }); await user.click(selected); await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); - - expect(selected).toHaveAttribute('aria-busy', 'true'); - expect(selected).toHaveAttribute('aria-disabled', 'true'); - expect(selected).toHaveFocus(); await user.click(selected); - await user.keyboard('{Enter}'); - const other = screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0300' }); - expect(other).toHaveAttribute('aria-disabled', 'true'); - await user.click(other); - expect(screen.getByRole('button', { name: 'Manage Backup codes' })).toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByRole('button', { name: 'Add verification method' })).toBeDisabled(); - expect(screen.queryByRole('menu')).not.toBeInTheDocument(); - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); - expect(screen.getAllByText('Default')).toHaveLength(1); + expect(screen.queryByRole('menuitem', { name: 'Set as default' })).not.toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Remove method' })).toBeVisible(); + await user.keyboard('{Escape}'); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0300' })); + expect(screen.queryByRole('menuitem', { name: 'Set as default' })).not.toBeInTheDocument(); expect(onSetDefault).toHaveBeenCalledExactlyOnceWith('work'); - expect(props.onRemove).not.toHaveBeenCalled(); await act(async () => { pending.resolve(); await pending.promise; }); - await waitFor(() => expect(selected).not.toHaveAttribute('aria-busy', 'true')); - expect(other).not.toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByRole('button', { name: 'Add verification method' })).toBeEnabled(); - await user.click(other); - expect(screen.getByRole('menuitem', { name: 'Set as default' })).toBeVisible(); + expect(await screen.findByRole('menuitem', { name: 'Set as default' })).toBeVisible(); }); it.each([ { cause: new Error('Unable to update the default method.'), message: 'Unable to update the default method.' }, { cause: 'network failure', message: 'Unable to set this method as default. Please try again.' }, - ])( - 'shows a default-change error beside the selected row and clears it on retry: $message', - async ({ cause, message }) => { - const user = userEvent.setup(); - const retry = deferred(); - const onSetDefault = vi.fn().mockRejectedValueOnce(cause).mockReturnValueOnce(retry.promise); - renderView({ - methods: [ - { id: 'personal', type: 'sms', description: '+1 801-555-0100', isDefault: true }, - { id: 'work', type: 'sms', description: '+1 801-555-0200', canSetDefault: true }, - ], - onSetDefault, - }); - - const selected = screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' }); - await user.click(selected); - await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); - - expect(await screen.findByRole('alert')).toHaveTextContent(message); - expect(selected).toHaveAccessibleDescription(message); - expect( - screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0100' }), - ).not.toHaveAccessibleDescription(); - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); - expect(screen.getAllByText('Default')).toHaveLength(1); - await user.click(selected); - await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); - - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(selected).not.toHaveAccessibleDescription(); - expect(selected).toHaveAttribute('aria-busy', 'true'); - await act(async () => { - retry.resolve(); - await retry.promise; - }); - - await waitFor(() => expect(selected).not.toHaveAttribute('aria-busy', 'true')); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(onSetDefault.mock.calls).toEqual([['work'], ['work']]); - }, - ); + ])('shows a default-change error and clears it on retry: $message', async ({ cause, message }) => { + const user = userEvent.setup(); + const retry = deferred(); + const onSetDefault = vi.fn().mockRejectedValueOnce(cause).mockReturnValueOnce(retry.promise); + renderView({ + methods: [ + { id: 'personal', type: 'sms', description: '+1 801-555-0100', isDefault: true }, + { id: 'work', type: 'sms', description: '+1 801-555-0200', canSetDefault: true }, + ], + onSetDefault, + }); + + const selected = screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' }); + await user.click(selected); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(message); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(screen.getAllByText('Default')).toHaveLength(1); + await user.click(selected); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + await act(async () => { + retry.resolve(); + await retry.promise; + }); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(onSetDefault.mock.calls).toEqual([['work'], ['work']]); + }); it('renders supplied backup codes without other methods and only offers regeneration', async () => { const user = userEvent.setup(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx b/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx index 6d8b1717c4a..19750de3c17 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx @@ -1,9 +1,7 @@ import type { Ref } from 'react'; -import { Button } from '../../components/button'; import { Icon } from '../../components/icon'; import { Menu } from '../../components/menu'; -import { Spinner } from '../../components/spinner'; import type { IconName } from '../../icons/registry'; export interface UserProfileMenuAction { @@ -17,17 +15,11 @@ export function UserProfileActionMenu({ label, actions, triggerRef, - isPending, - disabled, - 'aria-describedby': ariaDescribedBy, }: { label: string; actions: UserProfileMenuAction[]; /** The trigger element, for a caller that has to hand focus back to this row. */ triggerRef?: Ref; - isPending?: boolean; - disabled?: boolean; - 'aria-describedby'?: string; }) { if (actions.length === 0) { return null; @@ -38,21 +30,7 @@ export function UserProfileActionMenu({ ( - -
- ); -} From bdecd183d5a87e7c978036acdc0dc34d53a60a67 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 18 Sep 2026 13:14:56 -0600 Subject: [PATCH 31/38] test(mosaic): isolate MFA picker interaction in security panel --- .../user-profile-security-panel.view.test.tsx | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 8de5ea7b1c1..831c094f29a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -76,36 +76,45 @@ describe('UserProfileSecurityPanelView', () => { ).toBeInTheDocument(); }); + it('adds an available MFA method through the picker', async () => { + const onAddMfaMethod = vi.fn(); + const user = userEvent.setup(); + + renderView({ + mfaMethods: [ + { id: 'sms_1', type: 'sms', description: '+1 801-888-8181' }, + { id: 'backup_1', type: 'backup-codes' }, + ], + onAddMfaMethod, + addableMfaMethods: ['authenticator'], + }); + + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + expect(screen.queryByRole('button', { name: /SMS verification Get a code/ })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /Authenticator app Get codes/ })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(onAddMfaMethod).toHaveBeenCalledWith('authenticator'); + }); + it('forwards security actions', async () => { const onAddPasskey = vi.fn(); const onRenamePasskey = vi.fn(() => Promise.resolve()); const onRemovePasskey = vi.fn(); - const onAddMfaMethod = vi.fn(); const onSignOutDevice = vi.fn(); const onSignOutAllOtherDevices = vi.fn(); const onDeleteAccount = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); renderView({ - mfaMethods: [ - { id: 'sms_1', type: 'sms', description: '+1 801-888-8181' }, - { id: 'backup_1', type: 'backup-codes' }, - ], onAddPasskey, onRenamePasskey, onRemovePasskey, - onAddMfaMethod, - addableMfaMethods: ['authenticator'], onSignOutDevice, onSignOutAllOtherDevices, onDeleteAccount, }); await user.click(screen.getByRole('button', { name: 'Add passkey' })); - await user.click(screen.getByRole('button', { name: 'Add verification method' })); - expect(screen.queryByRole('button', { name: /SMS verification Get a code/ })).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /Authenticator app Get codes/ })); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); @@ -137,7 +146,6 @@ describe('UserProfileSecurityPanelView', () => { expect(onAddPasskey).toHaveBeenCalledOnce(); expect(onRenamePasskey).toHaveBeenCalledWith('passkey_1', 'Work laptop'); expect(onRemovePasskey).toHaveBeenCalledWith('passkey_1'); - expect(onAddMfaMethod).toHaveBeenCalledWith('authenticator'); expect(onSignOutDevice).toHaveBeenCalledWith('mobile'); expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce(); expect(onDeleteAccount).toHaveBeenCalledOnce(); From 0bba99de93ff3a7c72e50803a2b625ff0f519d62 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 18 Sep 2026 14:16:55 -0600 Subject: [PATCH 32/38] fix(test):Remove redundant phone input check Combobox tests this --- .../components/phone-input/phone-input.test.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/mosaic/src/components/phone-input/phone-input.test.tsx b/packages/mosaic/src/components/phone-input/phone-input.test.tsx index 8743248f769..859585380a7 100644 --- a/packages/mosaic/src/components/phone-input/phone-input.test.tsx +++ b/packages/mosaic/src/components/phone-input/phone-input.test.tsx @@ -42,22 +42,6 @@ describe('Mosaic PhoneInput', () => { expect(ref).toHaveBeenLastCalledWith(null); }); - it('keeps the country indicator on the selection while hovering another country', async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByRole('button', { name: 'Country, United States' })); - const us = screen.getByRole('option', { name: /United States/ }); - const uk = screen.getByRole('option', { name: /United Kingdom/ }); - await user.hover(uk); - expect(us.querySelector('.cl-combobox-option-indicator')).toBeVisible(); - expect(uk.querySelector('.cl-combobox-option-indicator')).not.toBeInTheDocument(); - await user.click(uk); - await user.click(screen.getByRole('button', { name: 'Country, United Kingdom' })); - expect( - screen.getByRole('option', { name: /United Kingdom/ }).querySelector('.cl-combobox-option-indicator'), - ).toBeVisible(); - }); - it('positions the country popup against the full phone field', async () => { const user = userEvent.setup(); render(); From 687514dd36aac05bc18e0b4d20e6c4f2c7f6c197 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 18 Sep 2026 14:29:44 -0600 Subject: [PATCH 33/38] refactor(mosaic): remove standalone MFA dialogs --- .../user-profile/__tests__/mfa-test-utils.tsx | 51 ++++++++++ ...r-profile-add-authenticator.view.test.tsx} | 88 ++++++++---------- ...tsx => user-profile-add-sms.view.test.tsx} | 92 +++++++++---------- ...> user-profile-backup-codes.view.test.tsx} | 62 +++++++------ .../user-profile-add-authenticator.dialog.tsx | 43 --------- .../user-profile-add-sms.dialog.tsx | 54 ----------- .../user-profile-add-sms.view.tsx | 6 +- .../user-profile-backup-codes.dialog.tsx | 49 ---------- .../user-profile-backup-codes.view.tsx | 6 +- packages/mosaic/src/styles/index.ts | 4 +- .../fixtures/user-profile-authenticator.ts | 4 +- .../stories/fixtures/user-profile-mfa.test.ts | 54 +++++------ .../src/stories/fixtures/user-profile-mfa.ts | 18 +--- 13 files changed, 201 insertions(+), 330 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/mfa-test-utils.tsx rename packages/mosaic/src/features/user-profile/__tests__/{user-profile-add-authenticator.dialog.test.tsx => user-profile-add-authenticator.view.test.tsx} (71%) rename packages/mosaic/src/features/user-profile/__tests__/{user-profile-add-sms.dialog.test.tsx => user-profile-add-sms.view.test.tsx} (72%) rename packages/mosaic/src/features/user-profile/__tests__/{user-profile-backup-codes.dialog.test.tsx => user-profile-backup-codes.view.test.tsx} (74%) delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-backup-codes.dialog.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/mfa-test-utils.tsx b/packages/mosaic/src/features/user-profile/__tests__/mfa-test-utils.tsx new file mode 100644 index 00000000000..357d9dff7fb --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/mfa-test-utils.tsx @@ -0,0 +1,51 @@ +import { vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileAddMfaDialog } from '../user-profile-add-mfa.dialog'; +import type { UserProfileMfaSetupViewProps } from '../user-profile-mfa-setup.view'; +import { UserProfileMfaSetupView } from '../user-profile-mfa-setup.view'; + +export function MfaSetupDialog(props: Partial) { + return ( + + + + + + ); +} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx similarity index 71% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx rename to packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx index 300ae007865..28faa4c0f1a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx @@ -3,21 +3,20 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserProfileAddAuthenticatorDialogProps } from '../user-profile-add-authenticator.dialog'; -import { UserProfileAddAuthenticatorDialog } from '../user-profile-add-authenticator.dialog'; +import type { UserProfileMfaSetupViewProps } from '../user-profile-mfa-setup.view'; +import { MfaSetupDialog } from './mfa-test-utils'; + +type ViewProps = UserProfileMfaSetupViewProps['authenticator']; const setup = { secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://totp/Swingset:demo@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Swingset', }; -function renderView(overrides: Partial = {}) { - const props: UserProfileAddAuthenticatorDialogProps = { +function renderView(overrides: Partial = {}) { + const props: ViewProps = { setup, onRetry: vi.fn(), - open: true, - onOpenChange: vi.fn(), code: '', onCodeChange: vi.fn(), onSubmit: vi.fn(), @@ -26,31 +25,25 @@ function renderView(overrides: Partial = return { props, ...render( - - - , + , ), }; } -function VerificationExample({ onSubmit }: Pick) { +function VerificationExample({ onSubmit }: Pick) { const [code, setCode] = useState(''); return ( - - undefined} - open - onOpenChange={() => undefined} - code={code} - onCodeChange={setCode} - onSubmit={onSubmit} - /> - + undefined, code, onCodeChange: setCode, onSubmit }} + /> ); } -describe('UserProfileAddAuthenticatorDialog', () => { +describe('UserProfileAddAuthenticatorView', () => { it('shows preparation, offers retry on failure, and waits for setup data before verification', async () => { const user = userEvent.setup(); const { props, rerender } = renderView({ setup: undefined }); @@ -62,12 +55,10 @@ describe('UserProfileAddAuthenticatorDialog', () => { expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled(); rerender( - - - , + , ); expect(screen.getByRole('alert')).toHaveTextContent('Unable to prepare your authenticator.'); expect(screen.queryByRole('status', { name: 'Preparing authenticator…' })).not.toBeInTheDocument(); @@ -76,21 +67,20 @@ describe('UserProfileAddAuthenticatorDialog', () => { expect(props.onSubmit).not.toHaveBeenCalled(); rerender( - - - , + , ); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(screen.getByRole('status', { name: 'Preparing authenticator…' })).toBeVisible(); expect(screen.getByRole('button', { name: /Preparing authenticator/ })).toHaveFocus(); rerender( - - - , + , ); expect(screen.getByRole('dialog')).toBe(dialog); expect(screen.queryByRole('status', { name: 'Preparing authenticator…' })).not.toBeInTheDocument(); @@ -100,13 +90,10 @@ describe('UserProfileAddAuthenticatorDialog', () => { await user.keyboard('{Enter}'); expect(props.onSubmit).not.toHaveBeenCalled(); rerender( - - - , + , ); expect(props.onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); }); @@ -121,13 +108,10 @@ describe('UserProfileAddAuthenticatorDialog', () => { expect(props.onSubmit).not.toHaveBeenCalled(); rerender( - - - , + , ); expect(verify).toHaveAttribute('aria-busy', 'true'); expect(screen.getByRole('progressbar', { name: 'Verifying code' })).toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.view.test.tsx similarity index 72% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.dialog.test.tsx rename to packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.view.test.tsx index 241b1752130..26e3b715c8a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.view.test.tsx @@ -3,14 +3,13 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserProfileAddSmsDialogProps } from '../user-profile-add-sms.dialog'; -import { UserProfileAddSmsDialog } from '../user-profile-add-sms.dialog'; +import type { UserProfileMfaSetupViewProps } from '../user-profile-mfa-setup.view'; +import { MfaSetupDialog } from './mfa-test-utils'; -function renderView(overrides: Partial = {}) { - const props: UserProfileAddSmsDialogProps = { - open: true, - onOpenChange: vi.fn(), +type ViewProps = UserProfileMfaSetupViewProps['sms']; + +function renderView(overrides: Partial = {}) { + const props: ViewProps = { step: 'select', phoneNumbers: [ { id: 'personal', phoneNumber: '+18015550100' }, @@ -31,40 +30,40 @@ function renderView(overrides: Partial = {}) { return { props, ...render( - - - , + , ), }; } -describe('UserProfileAddSmsDialog', () => { +describe('UserProfileAddSmsView', () => { it('adds and verifies a new number in the same dialog, preserving the number on Back', async () => { const user = userEvent.setup(); const onVerify = vi.fn(); function Example() { - const [step, setStep] = useState('select'); + const [step, setStep] = useState('select'); const [phoneNumber, setPhoneNumber] = useState('+18015550300'); const [code, setCode] = useState(''); return ( - - setStep('phone')} - onBack={() => setStep(step === 'verify' ? 'phone' : 'select')} - phoneNumber={phoneNumber} - onPhoneNumberChange={setPhoneNumber} - code={code} - onCodeChange={setCode} - onSubmit={value => (step === 'phone' ? setStep('verify') : onVerify(value))} - onResend={vi.fn()} - /> - + setStep('phone'), + onBack: () => setStep(step === 'verify' ? 'phone' : 'select'), + phoneNumber, + onPhoneNumberChange: setPhoneNumber, + code, + onCodeChange: setCode, + onSubmit: value => (step === 'phone' ? setStep('verify') : onVerify(value)), + onResend: vi.fn(), + }} + /> ); } render(); @@ -108,13 +107,10 @@ describe('UserProfileAddSmsDialog', () => { expect(screen.getByRole('button', { name: step === 'select' ? 'Cancel' : 'Back' })).toBeDisabled(); rerender( - - - , + , ); expect(screen.getByRole(role, { name })).toHaveAttribute('aria-invalid', 'true'); const describedControl = @@ -135,24 +131,18 @@ describe('UserProfileAddSmsDialog', () => { expect(screen.getByRole('button', { name: 'Sending a new code…' })).toBeDisabled(); rerender( - - - , + , ); expect(code).toBeEnabled(); expect(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (12)' })).toBeDisabled(); rerender( - - - , + , ); await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })); expect(props.onResend).toHaveBeenCalledOnce(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.view.test.tsx similarity index 74% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx rename to packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.view.test.tsx index 22672c64072..546e116bc60 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.view.test.tsx @@ -2,16 +2,15 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; -import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserProfileBackupCodesDialogProps } from '../user-profile-backup-codes.dialog'; -import { UserProfileBackupCodesDialog } from '../user-profile-backup-codes.dialog'; +import type { UserProfileMfaSetupViewProps } from '../user-profile-mfa-setup.view'; +import { MfaSetupDialog } from './mfa-test-utils'; + +type ViewProps = UserProfileMfaSetupViewProps['backupCodes']; const codes = ['pwkkay19', 'cvgunlqs', '4czio578', 'a38eewtw', 'qqnwzvyr', 'znq8j16s']; -function renderView(overrides: Partial = {}) { - const props: UserProfileBackupCodesDialogProps = { - open: true, - onOpenChange: vi.fn(), +function renderView(overrides: Partial = {}, step: UserProfileMfaSetupViewProps['step'] = 'backup-codes') { + const props: ViewProps = { codes, onRetry: vi.fn(), onCopy: vi.fn(), @@ -21,19 +20,26 @@ function renderView(overrides: Partial = {}) return { props, ...render( - - - , + , ), }; } -describe('UserProfileBackupCodesDialog', () => { +describe('UserProfileBackupCodesView', () => { it.each([ { codes, action: 'Copy and close' }, { codes: [], action: 'Try again' }, - ])('focuses $action when opened', async ({ codes, action }) => { - renderView({ codes }); + ])('focuses $action when entering backup codes', async ({ codes, action }) => { + const { props, rerender } = renderView({ codes }, 'select'); + rerender( + , + ); const button = screen.getByRole('button', { name: action }); await waitFor(() => expect(document.activeElement === button).toBe(true), { timeout: 1000 }); }); @@ -58,13 +64,14 @@ describe('UserProfileBackupCodesDialog', () => { expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); rerender( - - - , + , ); expect(screen.getByRole('alert')).toHaveTextContent('Unable to generate backup codes. Please try again.'); await user.click(screen.getByRole('button', { name: 'Try again' })); @@ -89,13 +96,14 @@ describe('UserProfileBackupCodesDialog', () => { expect(props.onDownload).not.toHaveBeenCalled(); rerender( - - - , + , ); expect(screen.getByRole('dialog')).toBe(dialog); expect(screen.getByRole('alert')).toHaveTextContent('Unable to save your backup codes. Please try again.'); diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx deleted file mode 100644 index 2441e52182f..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.dialog.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Card } from '../../components/card'; -import type { DialogFocusTarget, DialogTriggerProps } from '../../components/dialog'; -import { Dialog } from '../../components/dialog'; -import type { UserProfileAddAuthenticatorViewProps } from './user-profile-add-authenticator.view'; -import { UserProfileAddAuthenticatorView } from './user-profile-add-authenticator.view'; - -export interface UserProfileAddAuthenticatorDialogProps extends Omit { - open: boolean; - onOpenChange: (open: boolean) => void; - trigger?: DialogTriggerProps['render']; - finalFocus?: DialogFocusTarget; -} - -export function UserProfileAddAuthenticatorDialog({ - open, - onOpenChange, - trigger, - finalFocus, - ...props -}: UserProfileAddAuthenticatorDialogProps) { - return ( - - {trigger ? : null} - - - onOpenChange(false)} - /> - - - - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx deleted file mode 100644 index 0c6c7322989..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-add-sms.dialog.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useRef } from 'react'; - -import { Card } from '../../components/card'; -import type { DialogFocusTarget, DialogTriggerProps } from '../../components/dialog'; -import { Dialog } from '../../components/dialog'; -import type { UserProfileAddSmsViewProps } from './user-profile-add-sms.view'; -import { UserProfileAddSmsView } from './user-profile-add-sms.view'; - -export interface UserProfileAddSmsDialogProps extends Omit< - UserProfileAddSmsViewProps, - 'onCancel' | 'selectRef' | 'phoneRef' -> { - open: boolean; - onOpenChange: (open: boolean) => void; - trigger?: DialogTriggerProps['render']; - finalFocus?: DialogFocusTarget; -} - -export function UserProfileAddSmsDialog({ - open, - onOpenChange, - trigger, - finalFocus, - ...props -}: UserProfileAddSmsDialogProps) { - const selectRef = useRef(null); - const phoneRef = useRef(null); - - return ( - - {trigger ? : null} - - - onOpenChange(false)} - selectRef={selectRef} - phoneRef={phoneRef} - /> - - - - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-sms.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-sms.view.tsx index 6fa84bab5c6..33a3808a0d8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-sms.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-sms.view.tsx @@ -13,8 +13,6 @@ import { EnterPhoneStep, VerifyPhoneStep } from './user-profile-phone.steps'; export interface UserProfileAddSmsViewProps { onCancel: () => void; - selectRef?: Ref; - phoneRef?: Ref; step: 'select' | 'phone' | 'verify'; direction?: FlowDirection; phoneNumbers: readonly { id: string; phoneNumber: string }[]; @@ -36,8 +34,8 @@ export interface UserProfileAddSmsViewProps { export function UserProfileAddSmsView(props: UserProfileAddSmsViewProps) { const m = useMessages('userProfileAddSms'); - const selectRef = useMergeRefs([props.selectRef, useFlowAutoFocus()]); - const phoneRef = useMergeRefs([props.phoneRef, useFlowAutoFocus()]); + const selectRef = useFlowAutoFocus(); + const phoneRef = useFlowAutoFocus(); return ( { - open: boolean; - onOpenChange: (open: boolean) => void; - trigger?: DialogTriggerProps['render']; - finalFocus?: DialogFocusTarget; -} - -export function UserProfileBackupCodesDialog({ - open, - onOpenChange, - trigger, - finalFocus, - ...props -}: UserProfileBackupCodesDialogProps) { - const actionRef = useRef(null); - - return ( - - {trigger ? : null} - - - onOpenChange(false)} - /> - - - - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx index b1bca3eb1dc..cca1ae35ad8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx @@ -1,6 +1,4 @@ -import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; -import type { Ref } from 'react'; import { Banner } from '../../components/banner'; import { Button, SubmitButton } from '../../components/button'; @@ -14,7 +12,6 @@ import { reset } from '../../utils/reset.styles'; import { styles } from './user-profile-backup-codes.styles'; export interface UserProfileBackupCodesViewProps { - actionRef?: Ref; onCancel: () => void; codes: readonly string[]; onRetry: () => void; @@ -25,7 +22,6 @@ export interface UserProfileBackupCodesViewProps { } export function UserProfileBackupCodesView({ - actionRef: actionRefProp, onCancel, codes, onRetry, @@ -35,7 +31,7 @@ export function UserProfileBackupCodesView({ errorMessage, }: UserProfileBackupCodesViewProps) { const m = useMessages('userProfileBackupCodes'); - const actionRef = useMergeRefs([actionRefProp, useFlowAutoFocus()]); + const actionRef = useFlowAutoFocus(); const hasCodes = codes.length > 0 && pendingAction !== 'generate'; return ( diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index 83fb1dc958b..9e88acd2a53 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -210,6 +210,4 @@ export type TargetVarName = keyof typeof targetVars; export type TypeScaleVarName = keyof typeof typeScaleVars; export { mergeStyleProps, themeProps } from '../props'; export { UserProfileMfaSectionView } from '../features/user-profile/user-profile-mfa-section.view'; -export { UserProfileAddSmsDialog } from '../features/user-profile/user-profile-add-sms.dialog'; -export { UserProfileAddAuthenticatorDialog } from '../features/user-profile/user-profile-add-authenticator.dialog'; -export { UserProfileBackupCodesDialog } from '../features/user-profile/user-profile-backup-codes.dialog'; +export { UserProfileMfaSetupView } from '../features/user-profile/user-profile-mfa-setup.view'; diff --git a/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts b/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts index fc0cbea02b1..f6f899d20f4 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts @@ -1,4 +1,4 @@ -import type { UserProfileAddAuthenticatorDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-add-authenticator.dialog'; +import type { UserProfileAuthenticatorSetupViewProps } from '@clerk/mosaic/features/user-profile/user-profile-authenticator-setup.view'; import { useState } from 'react'; export const authenticatorSetup = { @@ -7,7 +7,7 @@ export const authenticatorSetup = { }; export function useAuthenticatorCopy() { - const [copyStatus, setCopyStatus] = useState(); + const [copyStatus, setCopyStatus] = useState(); const [copyErrorMessage, setCopyErrorMessage] = useState(); return { diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts index 614d05229a2..4004e62dc01 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts @@ -49,7 +49,7 @@ describe('MFA playground', () => { const request = deferred(); onGenerateBackupCodes.mockReturnValueOnce(request.promise); act(() => result.current.section.onAdd?.('backup-codes')); - expect(result.current.backupCodes.open).toBe(true); + expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.pendingAction).toBe('generate'); expect(result.current.section.methods.map(method => method.type)).toEqual(['sms']); expect(result.current.section.onRegenerateBackupCodes).toBeUndefined(); @@ -70,14 +70,14 @@ describe('MFA playground', () => { it('withholds backup-code creation until the instance enables codes and the user has MFA', async () => { const { result, rerender, onGenerateBackupCodes } = setup([], false); act(() => result.current.section.onAdd?.('backup-codes')); - expect(result.current.backupCodes.open).toBe(false); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); expect(onGenerateBackupCodes).not.toHaveBeenCalled(); await complete(() => void result.current.section.onRemove?.('personal')); rerender({ backupCodesEnabled: true }); expect(result.current.section.addableMethods).not.toContain('backup-codes'); act(() => result.current.section.onAdd?.('backup-codes')); - expect(result.current.backupCodes.open).toBe(false); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); expect(onGenerateBackupCodes).not.toHaveBeenCalled(); act(() => result.current.section.onAdd?.('authenticator')); @@ -96,7 +96,7 @@ describe('MFA playground', () => { } await complete(() => result.current.section.onAdd?.('backup-codes')); - expect(result.current.backupCodes.open).toBe(true); + expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.errorMessage).toContain('Unable to generate'); expect(result.current.backupCodes.codes).toEqual([]); expect(result.current.section.methods.map(method => method.type)).toEqual(['sms']); @@ -109,10 +109,10 @@ describe('MFA playground', () => { expect(result.current.section.methods.map(method => method.type)).toEqual(['sms', 'backup-codes']); expect(result.current.section.addableMethods).not.toContain('backup-codes'); await complete(() => result.current.backupCodes.onCopy()); - expect(result.current.backupCodes.open).toBe(false); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); await complete(() => result.current.section.onRegenerateBackupCodes?.()); - expect(result.current.backupCodes.open).toBe(true); + expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.section.methods.filter(method => method.type === 'backup-codes')).toHaveLength(1); expect(onGenerateBackupCodes).toHaveBeenCalledTimes(3); }, @@ -121,10 +121,10 @@ describe('MFA playground', () => { it('enrolls an authenticator on the first attempt, saves backup codes, and regenerates them', async () => { const { result, onCopy, onDownload, onGenerateBackupCodes } = setup(); act(() => result.current.section.onAdd?.('authenticator')); - expect(result.current.authenticator.open).toBe(true); + expect(result.current.setup).toMatchObject({ open: true, step: 'authenticator' }); await complete(() => result.current.authenticator.onSubmit('123456')); - expect(result.current.authenticator.open).toBe(false); - expect(result.current.backupCodes.open).toBe(true); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'authenticator' }); + expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.codes).toEqual(enrollmentCodes); expect(result.current.section.methods.map(method => method.type)).toEqual(['authenticator', 'sms', 'backup-codes']); expect(result.current.section.addableMethods).toEqual(['sms']); @@ -133,12 +133,12 @@ describe('MFA playground', () => { expect(onGenerateBackupCodes).not.toHaveBeenCalled(); await complete(() => result.current.backupCodes.onDownload()); expect(onDownload).toHaveBeenCalledExactlyOnceWith(codes); - expect(result.current.backupCodes.open).toBe(true); + expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); await complete(() => result.current.backupCodes.onCopy()); expect(onCopy).toHaveBeenCalledExactlyOnceWith(codes); - expect(result.current.backupCodes.open).toBe(false); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); await complete(() => result.current.section.onRegenerateBackupCodes?.()); - expect(result.current.backupCodes.open).toBe(true); + expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.codes).toEqual(regeneratedCodes); expect(onGenerateBackupCodes).toHaveBeenCalledOnce(); expect(result.current.section.methods.filter(method => method.type === 'backup-codes')).toHaveLength(1); @@ -152,13 +152,13 @@ describe('MFA playground', () => { await complete(() => result.current.sms.onSubmit()); expect(result.current.sms.step).toBe('verify'); await complete(() => result.current.sms.onSubmit('123456')); - expect(result.current.backupCodes.open).toBe(true); + expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); const method = result.current.section.methods.find(method => method.id === 'phone-+18015550300'); expect(method).toBeDefined(); if (!method) { throw new Error('New SMS method missing'); } - act(() => result.current.backupCodes.onOpenChange(false)); + act(() => result.current.setup.onOpenChange(false)); await complete(() => void result.current.section.onSetDefault?.(method.id)); expect(result.current.section.methods.find(item => item.isDefault)?.id).toBe(method.id); await complete(() => void result.current.section.onRemove?.(method.id)); @@ -174,16 +174,16 @@ describe('MFA playground', () => { expect(result.current.sms.phoneNumbers.some(phone => phone.id === 'personal')).toBe(false); act(() => result.current.sms.onSelectedPhoneIdChange('other')); await complete(() => result.current.sms.onSubmit()); - expect(result.current.sms.open).toBe(false); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'sms' }); expect(result.current.section.methods.some(method => method.id === 'other')).toBe(true); - act(() => result.current.backupCodes.onOpenChange(false)); + act(() => result.current.setup.onOpenChange(false)); act(() => result.current.section.onAdd?.('sms')); act(() => result.current.sms.onSelectedPhoneIdChange('work')); await complete(() => result.current.sms.onSubmit()); expect(result.current.sms.step).toBe('verify'); await complete(() => result.current.sms.onSubmit('654321')); - expect(result.current.sms.open).toBe(false); - expect(result.current.backupCodes.open).toBe(false); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'sms' }); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.section.methods.some(method => method.id === 'work')).toBe(true); }); @@ -226,11 +226,11 @@ describe('MFA playground', () => { const codes = result.current.backupCodes.codes; onCopy.mockRejectedValueOnce(new Error('Clipboard unavailable')); await complete(() => result.current.backupCodes.onCopy()); - expect(result.current.backupCodes.open).toBe(true); + expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.codes).toEqual(codes); expect(result.current.backupCodes.errorMessage).toContain('Unable to copy'); await complete(() => result.current.backupCodes.onCopy()); - expect(result.current.backupCodes.open).toBe(false); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.errorMessage).toBeUndefined(); }); @@ -239,7 +239,7 @@ describe('MFA playground', () => { const initialMethods = result.current.section.methods; act(() => result.current.section.onAdd?.('authenticator')); act(() => result.current.authenticator.onCodeChange('123')); - act(() => result.current.authenticator.onOpenChange(false)); + act(() => result.current.setup.onOpenChange(false)); expect(result.current.section.methods).toEqual(initialMethods); act(() => result.current.section.onAdd?.('authenticator')); expect(result.current.authenticator.code).toBe(''); @@ -249,7 +249,7 @@ describe('MFA playground', () => { const { result } = setup(); act(() => result.current.section.onAdd?.('authenticator')); await complete(() => result.current.authenticator.onSubmit('123456')); - act(() => result.current.backupCodes.onOpenChange(false)); + act(() => result.current.setup.onOpenChange(false)); const firstId = type === 'authenticator' ? 'personal' : 'authenticator'; const lastId = type === 'authenticator' ? 'authenticator' : 'personal'; await complete(() => void result.current.section.onRemove?.(firstId)); @@ -259,14 +259,14 @@ describe('MFA playground', () => { await complete(() => void result.current.section.onRemove?.(lastId)); expect(result.current.section.methods).toEqual([]); expect(result.current.backupCodes.codes).toEqual([]); - expect(result.current.backupCodes.open).toBe(false); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.section.onRegenerateBackupCodes).toBeUndefined(); expect(result.current.section.addableMethods).toEqual(['sms', 'authenticator']); act(() => result.current.section.onAdd?.('authenticator')); await complete(() => result.current.authenticator.onSubmit('123456')); expect(result.current.section.methods.map(method => method.type)).toEqual(['authenticator', 'backup-codes']); - expect(result.current.backupCodes.open).toBe(true); + expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.codes).toEqual(enrollmentCodes); }); @@ -284,7 +284,7 @@ describe('MFA playground', () => { expect(result.current.section.methods.some(method => method.type === type)).toBe(true); expect(result.current.section.methods.some(method => method.type === 'backup-codes')).toBe(false); expect(result.current.section.addableMethods).toContain('backup-codes'); - expect(result.current.backupCodes.open).toBe(false); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.codes).toEqual([]); expect(result.current.section.onRegenerateBackupCodes).toBeUndefined(); }, @@ -296,14 +296,14 @@ describe('MFA playground', () => { const { result, onGenerateBackupCodes } = setup(); act(() => result.current.section.onAdd?.('authenticator')); await complete(() => result.current.authenticator.onSubmit('123456')); - act(() => result.current.backupCodes.onOpenChange(false)); + act(() => result.current.setup.onOpenChange(false)); if (failure === 'rejection') { onGenerateBackupCodes.mockRejectedValueOnce(new Error('Try again')); } else { onGenerateBackupCodes.mockResolvedValueOnce([]); } await complete(() => result.current.section.onRegenerateBackupCodes?.()); - expect(result.current.backupCodes.open).toBe(true); + expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.codes).toEqual([]); expect(result.current.backupCodes.errorMessage).toContain('Unable to generate'); expect(result.current.section.methods.some(method => method.type === 'backup-codes')).toBe(true); diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts index 963704c9a5e..b5d116565c6 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts @@ -1,11 +1,9 @@ -import type { UserProfileAddAuthenticatorDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-add-authenticator.dialog'; -import type { UserProfileAddSmsDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-add-sms.dialog'; -import type { UserProfileBackupCodesDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-backup-codes.dialog'; import type { UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfileMfaSectionViewProps, } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; +import type { UserProfileMfaSetupViewProps } from '@clerk/mosaic/features/user-profile/user-profile-mfa-setup.view'; import { stringToFormattedPhoneString } from '@clerk/shared/phone'; import { useEffect, useState } from 'react'; @@ -72,9 +70,9 @@ export function useUserProfileMfaFixture({ onOpenChange: (open: boolean) => void; }; section: UserProfileMfaSectionViewProps; - authenticator: UserProfileAddAuthenticatorDialogProps; - sms: UserProfileAddSmsDialogProps; - backupCodes: UserProfileBackupCodesDialogProps; + authenticator: UserProfileMfaSetupViewProps['authenticator']; + sms: UserProfileMfaSetupViewProps['sms']; + backupCodes: UserProfileMfaSetupViewProps['backupCodes']; } { const [account, setAccount] = useState({ phones: [ @@ -93,7 +91,7 @@ export function useUserProfileMfaFixture({ const [codes, setCodes] = useState( initialFlow === 'backup-codes' ? (enrollmentBackupCodes ?? []) : [], ); - const [step, setStep] = useState('select'); + const [step, setStep] = useState('select'); const [direction, setDirection] = useState<1 | -1>(1); const [verifyFrom, setVerifyFrom] = useState<'select' | 'phone'>('select'); const [selectedPhoneId, setSelectedPhoneId] = useState(() => account.phones.find(phone => !phone.enrolled)?.id ?? ''); @@ -339,8 +337,6 @@ export function useUserProfileMfaFixture({ }, }, authenticator: { - open: flow === 'authenticator', - onOpenChange: close, setup: authenticatorSetup, onRetry: () => undefined, code, @@ -350,8 +346,6 @@ export function useUserProfileMfaFixture({ errorMessage, }, sms: { - open: flow === 'sms', - onOpenChange: close, step, direction, phoneNumbers: eligiblePhones, @@ -392,8 +386,6 @@ export function useUserProfileMfaFixture({ errorMessage, }, backupCodes: { - open: flow === 'backup-codes', - onOpenChange: close, codes, pendingAction: pending === 'generate' || pending === 'copy' || pending === 'download' ? pending : undefined, errorMessage, From 9684fd700621a4848637126193779a7017aba91e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 18 Sep 2026 14:47:35 -0600 Subject: [PATCH 34/38] fix(mosaic): focus authenticator code when setup is ready --- .../user-profile-add-authenticator.view.test.tsx | 16 ++++++++++++---- .../user-profile-add-authenticator.view.tsx | 2 ++ .../user-profile/user-profile-mfa-setup.view.tsx | 10 +++++++++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx index 28faa4c0f1a..f6b20aeb681 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx @@ -13,7 +13,7 @@ const setup = { uri: 'otpauth://totp/Swingset:demo@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Swingset', }; -function renderView(overrides: Partial = {}) { +function renderView(overrides: Partial = {}, step: UserProfileMfaSetupViewProps['step'] = 'authenticator') { const props: ViewProps = { setup, onRetry: vi.fn(), @@ -26,7 +26,7 @@ function renderView(overrides: Partial = {}) { props, ...render( , ), @@ -46,13 +46,20 @@ function VerificationExample({ onSubmit }: Pick) { describe('UserProfileAddAuthenticatorView', () => { it('shows preparation, offers retry on failure, and waits for setup data before verification', async () => { const user = userEvent.setup(); - const { props, rerender } = renderView({ setup: undefined }); + const { props, rerender } = renderView({ setup: undefined }, 'select'); + rerender( + , + ); const dialog = screen.getByRole('dialog', { name: 'Add an authenticator app' }); expect(screen.getByRole('status', { name: 'Preparing authenticator…' })).toBeVisible(); expect(screen.queryByRole('img')).not.toBeInTheDocument(); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Verify', exact: true })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled(); + expect(screen.getByRole('button', { name: /Preparing authenticator/ })).toHaveFocus(); rerender( { ); expect(screen.getByRole('alert')).toHaveTextContent('Unable to prepare your authenticator.'); expect(screen.queryByRole('status', { name: 'Preparing authenticator…' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toHaveFocus(); await user.click(screen.getByRole('button', { name: 'Try again' })); expect(props.onRetry).toHaveBeenCalledOnce(); expect(props.onSubmit).not.toHaveBeenCalled(); @@ -85,7 +93,7 @@ describe('UserProfileAddAuthenticatorView', () => { expect(screen.getByRole('dialog')).toBe(dialog); expect(screen.queryByRole('status', { name: 'Preparing authenticator…' })).not.toBeInTheDocument(); expect(screen.getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible(); - expect(screen.getByRole('button', { name: 'Verify', exact: true })).toHaveFocus(); + expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus(); expect(screen.getByRole('button', { name: 'Verify', exact: true })).toHaveAttribute('aria-disabled', 'true'); await user.keyboard('{Enter}'); expect(props.onSubmit).not.toHaveBeenCalled(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx index 937606e2167..ecb7dfdfb4c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx @@ -45,6 +45,7 @@ export function UserProfileAddAuthenticatorView({ const setupMessages = useMessages('userProfileAuthenticatorSetup'); const formId = useId(); const actionRef = useFlowAutoFocus(); + const inputRef = useFlowAutoFocus(); const submitCode = (value: string) => { if (setup && !isPending && value.length === 6) { onSubmit(value); @@ -105,6 +106,7 @@ export function UserProfileAddAuthenticatorView({ > {m.codeLabel} {current => ( @@ -43,6 +45,12 @@ export function UserProfileMfaSetupView(props: UserProfileMfaSetupViewProps) { onCancel={current.onCancel} /> + + + Date: Fri, 18 Sep 2026 15:04:17 -0600 Subject: [PATCH 35/38] fix(mosaic): return MFA setup to method picker on Back --- .../user-profile/__tests__/mfa-test-utils.tsx | 1 + ...er-profile-add-authenticator.view.test.tsx | 4 +-- .../user-profile-add-sms.view.test.tsx | 2 +- .../user-profile-backup-codes.view.test.tsx | 9 ++++++ .../user-profile-mfa-cards.view.test.tsx | 1 - ...user-profile-add-authenticator.messages.ts | 2 +- .../user-profile-add-authenticator.view.tsx | 8 ++--- .../user-profile-add-mfa.view.tsx | 5 ++- .../user-profile-add-sms.messages.ts | 1 - .../user-profile-add-sms.view.tsx | 5 ++- .../user-profile-backup-codes.messages.ts | 1 + .../user-profile-backup-codes.view.tsx | 6 ++-- .../user-profile-mfa-setup.view.tsx | 15 ++++----- .../user-profile-mfa-example.test.tsx | 32 +++++++++++-------- .../fixtures/user-profile-mfa-example.tsx | 1 + .../stories/fixtures/user-profile-mfa.test.ts | 24 ++++++++++++++ .../src/stories/fixtures/user-profile-mfa.ts | 27 ++++++++++++---- 17 files changed, 101 insertions(+), 43 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/mfa-test-utils.tsx b/packages/mosaic/src/features/user-profile/__tests__/mfa-test-utils.tsx index 357d9dff7fb..62746b5b451 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/mfa-test-utils.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/mfa-test-utils.tsx @@ -16,6 +16,7 @@ export function MfaSetupDialog(props: Partial) { step='select' methods={['sms', 'authenticator', 'backup-codes']} onSelect={vi.fn()} + onBack={vi.fn()} onCancel={vi.fn()} authenticator={{ onRetry: vi.fn(), diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx index f6b20aeb681..1a811e194a0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx @@ -58,7 +58,7 @@ describe('UserProfileAddAuthenticatorView', () => { expect(screen.queryByRole('img')).not.toBeInTheDocument(); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Verify', exact: true })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Back' })).toBeEnabled(); expect(screen.getByRole('button', { name: /Preparing authenticator/ })).toHaveFocus(); rerender( @@ -126,7 +126,7 @@ describe('UserProfileAddAuthenticatorView', () => { for (const slot of screen.getAllByRole('textbox')) { expect(slot).toBeDisabled(); } - expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); await user.click(verify); const form = screen.getByRole('textbox', { name: 'Verification code' }).closest('form'); if (!form) { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.view.test.tsx index 26e3b715c8a..17cfa6f198a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.view.test.tsx @@ -104,7 +104,7 @@ describe('UserProfileAddSmsView', () => { } form.requestSubmit(); expect(props.onSubmit).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: step === 'select' ? 'Cancel' : 'Back' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); rerender( { expect(props.onRetry).toHaveBeenCalledTimes(1); }); + it('returns to method selection when generation fails after choosing backup codes', async () => { + const user = userEvent.setup(); + const onBack = vi.fn(); + renderView({ codes: [], errorMessage: 'Unable to generate backup codes.', onBack }); + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Back' })); + expect(onBack).toHaveBeenCalledOnce(); + }); + it.each([ ['copy', 'Copy and close', 'Download', 'Copying backup codes'], ['download', 'Download', 'Copy and close', 'Downloading backup codes'], diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx index 76a3a531a9b..67eadf58c39 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx @@ -43,7 +43,6 @@ describe('MFA cards', () => { onCodeChange={vi.fn()} onSubmit={vi.fn()} onResend={vi.fn()} - onCancel={vi.fn()} /> diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts index 746294e51a3..227d0dd9705 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts @@ -1,6 +1,6 @@ export const userProfileAddAuthenticatorMessages = { codeLabel: 'Verification code', - cancel: 'Cancel', + back: 'Back', verify: 'Verify', pending: 'Verifying code', preparing: 'Preparing authenticator…', diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx index ecb7dfdfb4c..ded031e0ae3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx @@ -19,7 +19,7 @@ export interface UserProfileAddAuthenticatorViewProps extends Omit< setup?: { secret: string; uri: string }; setupErrorMessage?: string; onRetry: () => void; - onCancel: () => void; + onBack: () => void; code: string; onCodeChange: (value: string) => void; onSubmit: (code: string) => void; @@ -28,7 +28,7 @@ export interface UserProfileAddAuthenticatorViewProps extends Omit< } export function UserProfileAddAuthenticatorView({ - onCancel, + onBack, setup, setupErrorMessage, onRetry, @@ -125,9 +125,9 @@ export function UserProfileAddAuthenticatorView({ color='neutral' fullWidth disabled={Boolean(setup) && isPending} - onClick={onCancel} + onClick={onBack} > - {m.cancel} + {m.back} (); return ( <> @@ -25,9 +27,10 @@ export function UserProfileAddMfaView({ methods, onSelect }: UserProfileAddMfaVi - {methods.map(type => ( + {methods.map((type, index) => ( void; step: 'select' | 'phone' | 'verify'; direction?: FlowDirection; phoneNumbers: readonly { id: string; phoneNumber: string }[]; @@ -160,9 +159,9 @@ function SelectPhoneStep(props: UserProfileAddSmsViewProps & { inputRef?: Ref - {m.cancel} + {m.back} void; + onBack?: () => void; codes: readonly string[]; onRetry: () => void; onCopy: () => void; @@ -23,6 +24,7 @@ export interface UserProfileBackupCodesViewProps { export function UserProfileBackupCodesView({ onCancel, + onBack, codes, onRetry, onCopy, @@ -133,9 +135,9 @@ export function UserProfileBackupCodesView({ color='neutral' fullWidth disabled={Boolean(pendingAction)} - onClick={onCancel} + onClick={onBack ?? onCancel} > - {m.cancel} + {onBack ? m.back : m.cancel} ; - authenticator: Omit; + sms: UserProfileAddSmsViewProps; + authenticator: Omit; backupCodes: Omit; + onBack: () => void; onCancel: () => void; } @@ -23,6 +24,7 @@ export function UserProfileMfaSetupView(props: UserProfileMfaSetupViewProps) { return ( {current => ( @@ -34,21 +36,18 @@ export function UserProfileMfaSetupView(props: UserProfileMfaSetupViewProps) { /> - + diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx index 103075188ed..3b4ef3fcf1b 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx @@ -39,19 +39,25 @@ function ProfileExample({ } describe('Profile MFA flows', () => { - it('uses the localized Add action and returns to method selection after cancelling setup', async () => { - const user = userEvent.setup(); - render(); - const add = screen.getByRole('button', { name: 'Add a second factor' }); - await user.click(add); - await user.click(screen.getByRole('button', { name: /SMS verification/ })); - await user.click(screen.getByRole('button', { name: 'Cancel' })); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(add).toHaveFocus(); - await user.click(add); - expect(screen.getByRole('dialog', { name: 'Add 2-step verification' })).toBeVisible(); - expect(screen.getByRole('button', { name: /Authenticator app/ })).toBeVisible(); - }); + it.each(['SMS verification', 'Authenticator app'])( + 'returns from %s to the method picker without closing the dialog', + async method => { + const user = userEvent.setup(); + render(); + const add = screen.getByRole('button', { name: 'Add a second factor' }); + await user.click(add); + const dialog = screen.getByRole('dialog', { name: 'Add 2-step verification' }); + await user.click(within(dialog).getByRole('button', { name: new RegExp(method) })); + await user.click(within(dialog).getByRole('button', { name: 'Back' })); + expect(screen.getByRole('dialog', { name: 'Add 2-step verification' })).toBe(dialog); + expect(within(dialog).getByRole('button', { name: /SMS verification/ })).toHaveFocus(); + await user.click(within(dialog).getByRole('button', { name: new RegExp(method) })); + expect(within(dialog).getByRole('button', { name: 'Back' })).toBeVisible(); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(add).toHaveFocus(); + }, + ); it('retries copying and verification, then finishes authenticator setup without closing the Profile overlay', async () => { const user = userEvent.setup(); diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx index 952ea35f4bf..ba40d55bbea 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx @@ -20,6 +20,7 @@ export function useUserProfileMfaExample() { sms={fixture.sms} authenticator={{ ...fixture.authenticator, ...authenticatorCopy }} backupCodes={fixture.backupCodes} + onBack={fixture.setup.onBack} onCancel={() => fixture.setup.onOpenChange(false)} />
diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts index 4004e62dc01..6a93da10b08 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts @@ -118,6 +118,24 @@ describe('MFA playground', () => { }, ); + it('returns from failed backup-code setup to the picker but cancels direct regeneration', async () => { + const { result, onGenerateBackupCodes } = setup([]); + onGenerateBackupCodes.mockRejectedValueOnce(new Error('Generation failed')); + act(() => result.current.setup.onOpenChange(true)); + await complete(() => result.current.section.onAdd?.('backup-codes')); + act(() => result.current.backupCodes.onBack?.()); + expect(result.current.setup).toMatchObject({ open: true, step: 'select' }); + expect(result.current.backupCodes.errorMessage).toBeUndefined(); + + await complete(() => result.current.section.onAdd?.('backup-codes')); + act(() => result.current.setup.onOpenChange(false)); + onGenerateBackupCodes.mockRejectedValueOnce(new Error('Regeneration failed')); + await complete(() => result.current.section.onRegenerateBackupCodes?.()); + expect(result.current.backupCodes.onBack).toBeUndefined(); + act(() => result.current.setup.onOpenChange(false)); + expect(result.current.setup.open).toBe(false); + }); + it('enrolls an authenticator on the first attempt, saves backup codes, and regenerates them', async () => { const { result, onCopy, onDownload, onGenerateBackupCodes } = setup(); act(() => result.current.section.onAdd?.('authenticator')); @@ -185,6 +203,12 @@ describe('MFA playground', () => { expect(result.current.setup).not.toMatchObject({ open: true, step: 'sms' }); expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.section.methods.some(method => method.id === 'work')).toBe(true); + + act(() => result.current.section.onAdd?.('sms')); + expect(result.current.sms.phoneNumbers).toEqual([]); + expect(result.current.sms.step).toBe('phone'); + act(() => result.current.sms.onBack()); + expect(result.current.setup).toMatchObject({ open: true, step: 'select' }); }); it('allows changing the default SMS number while an authenticator keeps the Default badge', async () => { diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts index b5d116565c6..d8310cb7440 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts @@ -68,6 +68,7 @@ export function useUserProfileMfaFixture({ open: boolean; step: UserProfileMfaAddableMethod | 'select'; onOpenChange: (open: boolean) => void; + onBack: () => void; }; section: UserProfileMfaSectionViewProps; authenticator: UserProfileMfaSetupViewProps['authenticator']; @@ -85,6 +86,7 @@ export function useUserProfileMfaFixture({ hasBackupCodes: initialFlow === 'backup-codes' && Boolean(enrollmentBackupCodes?.length), }); const [flow, setFlow] = useState(initialFlow); + const [backupCodesSource, setBackupCodesSource] = useState<'select' | 'regenerate'>('regenerate'); const [pending, setPending] = useState<'submit' | 'resend' | 'generate' | 'copy' | 'download'>(); const [errorMessage, setErrorMessage] = useState(); const [code, setCode] = useState(''); @@ -133,10 +135,11 @@ export function useUserProfileMfaFixture({ if (onGenerateBackupCodes && !account.hasBackupCodes && (account.authenticator || enrolledPhones.length > 0)) { addableMethods.push('backup-codes'); } - const generateBackupCodes = async () => { + const generateBackupCodes = async (source: 'select' | 'regenerate') => { if (pending || !onGenerateBackupCodes) { return; } + setBackupCodesSource(source); setFlow('backup-codes'); setPending('generate'); setErrorMessage(undefined); @@ -160,7 +163,7 @@ export function useUserProfileMfaFixture({ return; } if (type === 'backup-codes') { - void generateBackupCodes(); + void generateBackupCodes('select'); return; } setCode(''); @@ -180,6 +183,16 @@ export function useUserProfileMfaFixture({ } }; + const backToMethods = () => { + if (pending) { + return; + } + setFlow('select'); + setCode(''); + setErrorMessage(undefined); + setResendSeconds(0); + }; + const finishEnrollment = () => { setResendSeconds(0); setPending(undefined); @@ -300,6 +313,7 @@ export function useUserProfileMfaFixture({ setup: { open: flow !== undefined, step: flow ?? 'select', + onBack: backToMethods, onOpenChange: next => { if (next && !pending) { setFlow('select'); @@ -314,7 +328,7 @@ export function useUserProfileMfaFixture({ sectionTitle: 'Authentication', onAdd: open, onRegenerateBackupCodes: - account.hasBackupCodes && onGenerateBackupCodes ? () => void generateBackupCodes() : undefined, + account.hasBackupCodes && onGenerateBackupCodes ? () => void generateBackupCodes('regenerate') : undefined, onSetDefault: async id => { await pause(); setAccount(current => ({ ...current, defaultPhoneId: id })); @@ -361,8 +375,8 @@ export function useUserProfileMfaFixture({ setStep('phone'); }, onBack: () => { - if (step === 'phone' && eligiblePhones.length === 0) { - close(false); + if (step === 'select' || (step === 'phone' && eligiblePhones.length === 0)) { + backToMethods(); return; } setStep(step === 'verify' ? verifyFrom : 'select'); @@ -386,12 +400,13 @@ export function useUserProfileMfaFixture({ errorMessage, }, backupCodes: { + onBack: backupCodesSource === 'select' ? backToMethods : undefined, codes, pendingAction: pending === 'generate' || pending === 'copy' || pending === 'download' ? pending : undefined, errorMessage, onRetry: () => { if (!pending) { - void generateBackupCodes(); + void generateBackupCodes(backupCodesSource); } }, onCopy: () => void save('copy'), From 0d78404ab3139c6fb260b58cf45be2d57d105d3e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 18 Sep 2026 15:17:16 -0600 Subject: [PATCH 36/38] fix(mosaic): use pulse easing token and preview backup-code loading --- .claude/skills/mosaic/references/motion.md | 12 ++++++++++-- .../user-profile/user-profile-backup-codes.styles.ts | 4 ++-- packages/mosaic/src/tokens.stylex.ts | 1 + .../fixtures/user-profile-mfa-example.test.tsx | 2 ++ .../src/stories/fixtures/user-profile-mfa.ts | 8 +++++--- 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.claude/skills/mosaic/references/motion.md b/.claude/skills/mosaic/references/motion.md index 1b9eb34ebc5..3d05b2d6680 100644 --- a/.claude/skills/mosaic/references/motion.md +++ b/.claude/skills/mosaic/references/motion.md @@ -1,4 +1,4 @@ -# Motion: entrances and exits +# Motion: entrances, exits, and pulses Token semantics live in `packages/mosaic/src/tokens.stylex.ts`, above `durationDefaults` / `easingDefaults` — read those comments first. This file is the @@ -15,6 +15,7 @@ rather than eyeball it. | `--cl-ease-default` | `cubic-bezier(0.175, 0.885, 0.32, 1.1)` | things ARRIVING (Swift Out) | | `--cl-ease-enter` | `cubic-bezier(0, 0, 0.2, 1)` | arrivals that must not overshoot | | `--cl-ease-exit` | `cubic-bezier(0.55, 0.085, 0.68, 0.53)` | things LEAVING (In Quad) | +| `--cl-ease-pulse` | `cubic-bezier(0.4, 0, 0.6, 1)` | repeating opacity pulses | Named curves come from [easing.dev](https://www.easing.dev) (Lochie Axon's Easing Graphs). Take one from there rather than inventing a bezier, so the catalog stays @@ -47,10 +48,17 @@ So the axis is not the element's type but the size of its overshoot. Work out wh 2% of the travel actually is; once it is enough pixels to notice as a bounce, take `--cl-ease-enter`, which decelerates the same way without the pass-through. -Opacity is the degenerate case and always takes `--cl-ease-enter`: there is nothing +For entrances, opacity takes `--cl-ease-enter`: there is nothing past `1` to overshoot into, so the pass is clamped away and only its cost — the slower approach to full opacity — is left. +## Repeating pulses + +Use `--cl-ease-pulse` for repeating opacity fades such as loading skeletons. Its +symmetric curve slows at both ends of each fade, keeping the reversal smooth. +Keep the pulse duration on the component and disable the animation under +`prefers-reduced-motion: reduce`. + ## A curve has a direction — don't run the entrance curve backwards The single most common motion bug in this codebase. `--cl-ease-default` is diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts index f1191d9f9c0..de07661a95f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, radiusVars, space } from '../../tokens.stylex'; +import { colorVars, easingVars, radiusVars, space } from '../../tokens.stylex'; const pulse = stylex.keyframes({ '50%': { opacity: 0.5 }, @@ -15,7 +15,7 @@ export const styles = stylex.create({ default: pulse, '@media (prefers-reduced-motion: reduce)': 'none', }, - animationTimingFunction: 'cubic-bezier(0.4, 0, 0.6, 1)', + animationTimingFunction: easingVars['--cl-ease-pulse'], backgroundColor: colorVars['--cl-color-neutral-alpha-200'], height: '1lh', width: space['16'], diff --git a/packages/mosaic/src/tokens.stylex.ts b/packages/mosaic/src/tokens.stylex.ts index 9c7cf3bfe40..256a7957d35 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -404,6 +404,7 @@ const easingDefaults = { '--cl-ease-default': 'cubic-bezier(0.175, 0.885, 0.32, 1.1)', '--cl-ease-enter': 'cubic-bezier(0, 0, 0.2, 1)', '--cl-ease-exit': 'cubic-bezier(0.55, 0.085, 0.68, 0.53)', + '--cl-ease-pulse': 'cubic-bezier(0.4, 0, 0.6, 1)', } as const; export const easingVars = stylex.defineVars(easingDefaults); diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx index 3b4ef3fcf1b..cada8939e4c 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx @@ -113,6 +113,8 @@ describe('Profile MFA flows', () => { await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); expect(screen.getAllByRole('menuitem')).toHaveLength(1); await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + expect(screen.getByRole('status', { name: 'Generating backup codes' })).toBeVisible(); + expect(screen.queryByRole('list', { name: 'Backup codes' })).not.toBeInTheDocument(); expect(await screen.findByText('demo-new-01')).toBeVisible(); }); }); diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts index d8310cb7440..5dfb7bff017 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts @@ -30,8 +30,9 @@ export const mfaDemoOptions: FixtureOptions = { 'pnr8i06f', 'ycga0jge', ], - onGenerateBackupCodes: () => - Promise.resolve([ + onGenerateBackupCodes: async () => { + await pause(); + return [ 'demo-new-01', 'demo-new-02', 'demo-new-03', @@ -42,7 +43,8 @@ export const mfaDemoOptions: FixtureOptions = { 'demo-new-08', 'demo-new-09', 'demo-new-10', - ]), + ]; + }, onCopy: codes => navigator.clipboard.writeText(codes.join('\n')), onDownload: codes => { const blob = new Blob(['Swingset demo backup codes\n\n', codes.join('\n')], { type: 'text/plain' }); From c556e04f8b81f4d105f2e5e278a3c748044ba6eb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 18 Sep 2026 15:35:38 -0600 Subject: [PATCH 37/38] fix(mosaic): keep authenticator copy feedback within each field --- .../user-profile-add-authenticator.view.tsx | 10 +- ...er-profile-authenticator-setup.messages.ts | 1 - .../user-profile-authenticator-setup.view.tsx | 96 +++++++++---------- .../fixtures/user-profile-authenticator.ts | 29 +++--- .../user-profile-mfa-example.test.tsx | 17 +++- .../fixtures/user-profile-mfa-example.tsx | 5 +- 6 files changed, 87 insertions(+), 71 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx index ded031e0ae3..8da976f3dc3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx @@ -32,9 +32,8 @@ export function UserProfileAddAuthenticatorView({ setup, setupErrorMessage, onRetry, - onCopy, - copyStatus, - copyErrorMessage, + secretCopy, + uriCopy, code, onCodeChange, onSubmit, @@ -57,9 +56,8 @@ export function UserProfileAddAuthenticatorView({ {setup ? ( ) : ( <> diff --git a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.messages.ts index 868653ebf19..06f53289850 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.messages.ts @@ -7,7 +7,6 @@ export const userProfileAuthenticatorSetupMessages = { setupUri: 'Setup URI', copyKey: 'Copy setup key', copyUri: 'Copy setup URI', - copyFeedback: 'Copy feedback', copying: 'Copying…', copied: 'Copied', viewSetupKey: 'Can’t scan? View setup key', diff --git a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.view.tsx index 6155b95bcd2..21147cede38 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.view.tsx @@ -2,30 +2,32 @@ import * as stylex from '@stylexjs/stylex'; import { QRCodeSVG } from 'qrcode.react'; import { useState } from 'react'; -import { Banner } from '../../components/banner'; import { Button } from '../../components/button'; import { Card } from '../../components/card'; import { Field } from '../../components/field'; import { Icon } from '../../components/icon'; import { InputGroup } from '../../components/input-group'; -import { Text } from '../../components/text'; +import { VisuallyHidden } from '../../components/visually-hidden'; import { useMessages } from '../../localization'; import { styles } from './user-profile-authenticator-setup.styles'; +export interface UserProfileAuthenticatorCopyProps { + onCopy: (value: string) => void; + state?: { status: 'pending' | 'success' } | { status: 'error'; message: string }; +} + export interface UserProfileAuthenticatorSetupViewProps { secret: string; uri: string; - onCopy?: (value: string) => void; - copyStatus?: 'pending' | 'success'; - copyErrorMessage?: string; + secretCopy?: UserProfileAuthenticatorCopyProps; + uriCopy?: UserProfileAuthenticatorCopyProps; } export function UserProfileAuthenticatorSetupView({ secret, uri, - onCopy, - copyStatus, - copyErrorMessage, + secretCopy, + uriCopy, }: UserProfileAuthenticatorSetupViewProps) { const m = useMessages('userProfileAuthenticatorSetup'); const [showSetupKey, setShowSetupKey] = useState(false); @@ -40,46 +42,44 @@ export function UserProfileAuthenticatorSetupView({ {showSetupKey ? ( <> {[ - { label: m.setupKey, value: secret, copyLabel: m.copyKey }, - { label: m.setupUri, value: uri, copyLabel: m.copyUri }, - ].map(({ label, value, copyLabel }) => ( - - {label} - - - {onCopy ? ( - - - - ) : null} - - - ))} - {copyErrorMessage ? ( - - {copyErrorMessage} - - ) : null} - - {copyStatus === 'pending' ? m.copying : copyStatus === 'success' ? m.copied : null} - + { label: m.setupKey, value: secret, copyLabel: m.copyKey, copy: secretCopy }, + { label: m.setupUri, value: uri, copyLabel: m.copyUri, copy: uriCopy }, + ].map(({ label, value, copyLabel, copy }) => { + const feedback = copy?.state; + return ( + + {label} + + + {copy ? ( + + + + ) : null} + + + {feedback?.status === 'error' ? feedback.message : null} + + + {feedback?.status === 'pending' ? m.copying : feedback?.status === 'success' ? m.copied : null} + + + ); + })} ) : (
diff --git a/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts b/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts index f6f899d20f4..bb065066f2b 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts @@ -1,5 +1,5 @@ -import type { UserProfileAuthenticatorSetupViewProps } from '@clerk/mosaic/features/user-profile/user-profile-authenticator-setup.view'; -import { useState } from 'react'; +import type { UserProfileAuthenticatorCopyProps } from '@clerk/mosaic/features/user-profile/user-profile-authenticator-setup.view'; +import { useEffect, useState } from 'react'; export const authenticatorSetup = { secret: 'JBSWY3DPEHPK3PXP', @@ -7,21 +7,28 @@ export const authenticatorSetup = { }; export function useAuthenticatorCopy() { - const [copyStatus, setCopyStatus] = useState(); - const [copyErrorMessage, setCopyErrorMessage] = useState(); + const [copyState, setCopyState] = useState(); + + useEffect(() => { + if (copyState?.status !== 'success') { + return; + } + const timeout = setTimeout(() => setCopyState(undefined), 2000); + return () => clearTimeout(timeout); + }, [copyState]); return { - copyStatus, - copyErrorMessage, + state: copyState, onCopy: async (value: string) => { - setCopyStatus('pending'); - setCopyErrorMessage(undefined); + if (copyState?.status === 'pending') { + return; + } + setCopyState({ status: 'pending' }); try { await navigator.clipboard.writeText(value); - setCopyStatus('success'); + setCopyState({ status: 'success' }); } catch { - setCopyStatus(undefined); - setCopyErrorMessage('Could not copy. Please try again.'); + setCopyState({ status: 'error', message: 'Could not copy. Please try again.' }); } }, }; diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx index cada8939e4c..4386f320f50 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx @@ -9,6 +9,7 @@ import type { ComponentProps } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { useUserProfileFixture } from './user-profile'; +import { authenticatorSetup } from './user-profile-authenticator'; function ProfileExample({ overlay = false, @@ -61,7 +62,8 @@ describe('Profile MFA flows', () => { it('retries copying and verification, then finishes authenticator setup without closing the Profile overlay', async () => { const user = userEvent.setup(); - vi.spyOn(navigator.clipboard, 'writeText') + const copy = vi + .spyOn(navigator.clipboard, 'writeText') .mockRejectedValueOnce(new Error('Clipboard unavailable')) .mockResolvedValue(); render(); @@ -72,9 +74,18 @@ describe('Profile MFA flows', () => { await user.click(within(setup).getByRole('button', { name: /Authenticator app/ })); await user.click(within(setup).getByRole('button', { name: 'Can’t scan? View setup key' })); await user.click(within(setup).getByRole('button', { name: 'Copy setup key' })); - expect(await within(setup).findByRole('alert')).toHaveTextContent('Could not copy. Please try again.'); + expect(await within(setup).findByText('Could not copy. Please try again.')).toBeVisible(); + expect(within(setup).getByRole('textbox', { name: 'Setup key' })).toHaveAccessibleDescription( + 'Could not copy. Please try again.', + ); + expect(within(setup).getByRole('textbox', { name: 'Setup key' })).not.toHaveAttribute('aria-invalid', 'true'); + expect(within(setup).getByRole('textbox', { name: 'Setup URI' })).not.toHaveAccessibleDescription(); + expect(within(setup).queryByRole('alert')).not.toBeInTheDocument(); await user.click(within(setup).getByRole('button', { name: 'Copy setup key' })); - expect(within(setup).getByRole('status', { name: 'Copy feedback' })).toHaveTextContent('Copied'); + expect(copy).toHaveBeenLastCalledWith(authenticatorSetup.secret); + expect(within(setup).getByRole('textbox', { name: 'Setup key' })).not.toHaveAccessibleDescription(); + await user.click(within(setup).getByRole('button', { name: 'Copy setup URI' })); + expect(copy).toHaveBeenLastCalledWith(authenticatorSetup.uri); const code = within(setup).getByRole('textbox', { name: 'Verification code' }); await user.type(code, '000000'); expect(await within(setup).findByText('That code is incorrect. Try again.')).toBeVisible(); diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx index ba40d55bbea..6caf23a8525 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx @@ -7,7 +7,8 @@ import { useUserProfileMfaFixture } from './user-profile-mfa'; export function useUserProfileMfaExample() { const fixture = useUserProfileMfaFixture(); - const authenticatorCopy = useAuthenticatorCopy(); + const secretCopy = useAuthenticatorCopy(); + const uriCopy = useAuthenticatorCopy(); const addControl = ( fixture.section.onAdd?.(type)} sms={fixture.sms} - authenticator={{ ...fixture.authenticator, ...authenticatorCopy }} + authenticator={{ ...fixture.authenticator, secretCopy, uriCopy }} backupCodes={fixture.backupCodes} onBack={fixture.setup.onBack} onCancel={() => fixture.setup.onOpenChange(false)} From 40eb4be59002b66ed79faa476e3e0ad97ba6e08d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 18 Sep 2026 15:44:45 -0600 Subject: [PATCH 38/38] fix(mosaic): make backup-code recovery wording method-neutral --- .../features/user-profile/user-profile-backup-codes.messages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.messages.ts index f7ad23836fc..37b8b0a4c55 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.messages.ts @@ -1,6 +1,6 @@ export const userProfileBackupCodesMessages = { title: 'Save your backup codes', - description: 'Save these somewhere safe. Each code can be used once if you lose access to your phone.', + description: 'Save these somewhere safe. Each code can be used once if you lose access to your verification method.', codesLabel: 'Backup codes', download: 'Download', copyAndClose: 'Copy and close',