diff --git a/.changeset/quiet-mfa-setup.md b/.changeset/quiet-mfa-setup.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quiet-mfa-setup.md @@ -0,0 +1,2 @@ +--- +--- 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/bundlewatch.config.json b/packages/mosaic/bundlewatch.config.json index 462d46b158c..b3f91a18a8b 100644 --- a/packages/mosaic/bundlewatch.config.json +++ b/packages/mosaic/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/index.js", "maxSize": "123KB" }, + { "path": "./dist/index.js", "maxSize": "125KB" }, { "path": "./dist/styles.css", "maxSize": "12KB" } ] } 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/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(); 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..62746b5b451 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/mfa-test-utils.tsx @@ -0,0 +1,52 @@ +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.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx new file mode 100644 index 00000000000..1a811e194a0 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx @@ -0,0 +1,151 @@ +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'; + +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 = {}, step: UserProfileMfaSetupViewProps['step'] = 'authenticator') { + const props: ViewProps = { + setup, + onRetry: vi.fn(), + code: '', + onCodeChange: vi.fn(), + onSubmit: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + , + ), + }; +} + +function VerificationExample({ onSubmit }: Pick) { + const [code, setCode] = useState(''); + return ( + undefined, code, onCodeChange: setCode, onSubmit }} + /> + ); +} + +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 }, '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: 'Back' })).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(); + + 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('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(); + rerender( + , + ); + expect(props.onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); + }); + + 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).toHaveAttribute('aria-disabled', 'true'); + 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: 'Back' })).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('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/__tests__/user-profile-add-sms.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.view.test.tsx new file mode 100644 index 00000000000..17cfa6f198a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.view.test.tsx @@ -0,0 +1,150 @@ +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 type { UserProfileMfaSetupViewProps } from '../user-profile-mfa-setup.view'; +import { MfaSetupDialog } from './mfa-test-utils'; + +type ViewProps = UserProfileMfaSetupViewProps['sms']; + +function renderView(overrides: Partial = {}) { + const props: ViewProps = { + 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('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 [phoneNumber, setPhoneNumber] = useState('+18015550300'); + const [code, setCode] = useState(''); + return ( + 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(); + 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: '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/__tests__/user-profile-authenticator-setup.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-authenticator-setup.view.test.tsx new file mode 100644 index 00000000000..4439cc4bf4c --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-authenticator-setup.view.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { Card } from '../../../components/card'; +import { Dialog } from '../../../components/dialog'; +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileAuthenticatorSetupView } from '../user-profile-authenticator-setup.view'; + +const setup = { + secret: 'JBSWY3DPEHPK3PXP', + uri: 'otpauth://totp/Swingset:demo@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Swingset', +}; + +function renderView() { + return render( + + + Set up authenticator + + + + + + + , + ); +} + +describe('Authenticator setup', () => { + it('switches between scanning and manual setup using the same supplied credentials', async () => { + const user = userEvent.setup(); + renderView(); + await user.click(screen.getByRole('button', { name: 'Set up authenticator' })); + + expect(screen.getByRole('heading', { name: 'Add an authenticator app' })).toBeVisible(); + expect(screen.getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Can’t scan? View setup key' })); + + expect(screen.queryByRole('img', { name: 'Authenticator setup QR code' })).not.toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Setup key' })).toHaveValue(setup.secret); + expect(screen.getByRole('textbox', { name: 'Setup URI' })).toHaveValue(setup.uri); + expect(screen.getByRole('textbox', { name: 'Setup key' })).toHaveAttribute('readonly'); + expect(screen.getByRole('textbox', { name: 'Setup URI' })).toHaveAttribute('readonly'); + + await user.click(screen.getByRole('button', { name: 'Scan QR code instead' })); + expect(screen.getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Can’t scan? View setup key' })); + expect(screen.getByRole('textbox', { name: 'Setup key' })).toHaveValue(setup.secret); + expect(screen.getByRole('textbox', { name: 'Setup URI' })).toHaveValue(setup.uri); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.view.test.tsx new file mode 100644 index 00000000000..0b4b663c219 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.view.test.tsx @@ -0,0 +1,124 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +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 = {}, step: UserProfileMfaSetupViewProps['step'] = 'backup-codes') { + const props: ViewProps = { + codes, + onRetry: vi.fn(), + onCopy: vi.fn(), + onDownload: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + , + ), + }; +} + +describe('UserProfileBackupCodesView', () => { + it.each([ + { codes, action: 'Copy and close' }, + { codes: [], action: 'Try again' }, + ])('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 }); + }); + + it('keeps the default focus while generating codes', async () => { + renderView({ codes: [], pendingAction: 'generate' }); + const close = screen.getByRole('button', { name: 'Close' }); + await waitFor(() => expect(document.activeElement === close).toBe(true), { timeout: 1000 }); + }); + + 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(); + 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(); + 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('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'], + ] 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); + }, + ); +}); 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..67eadf58c39 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx @@ -0,0 +1,61 @@ +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 { UserProfileAddSmsView } from '../user-profile-add-sms.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 ( + + + + {() => ( + <> + + setActive('sms')} + > + Start SMS + + + + + + > + )} + + + + ); + } + render(); + await userEvent.click(screen.getByRole('button', { name: 'Start SMS' })); + await waitFor(() => { + expect(screen.getByRole(step === 'select' ? 'combobox' : 'textbox', { name: /Phone/ })).toHaveFocus(); + }); + }); +}); 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..888c4111661 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx @@ -0,0 +1,183 @@ +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { deferred } from '../../../machines/__tests__/test-utils'; +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: [], + addableMethods: ['sms', 'authenticator'], + onAdd: vi.fn(), + onRemove: vi.fn(), + onSetDefault: vi.fn(), + onRegenerateBackupCodes: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +describe('MFA section', () => { + 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: /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(); + }); + + it('cancels without selecting a method and restores focus to Add', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + const add = screen.getByRole('button', { name: 'Add verification method' }); + await user.click(add); + await user.tab(); + expect(screen.getByRole('button', { name: /SMS verification/ })).toHaveFocus(); + await user.keyboard('{Escape}'); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(add).toHaveFocus(); + expect(props.onAdd).not.toHaveBeenCalled(); + await user.click(add); + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(add).toHaveFocus(); + }); + + it('confirms the selected SMS method and restores focus when removal is cancelled', 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(); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); + expect(screen.getByRole('alertdialog', { name: 'Remove SMS verification' })).toHaveAccessibleDescription( + 'You will no longer receive sign-in verification codes at +1 801-555-0100. The phone number will remain on your account.', + ); + expect(props.onRemove).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0100' })).toHaveFocus(); + + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); + const dialog = screen.getByRole('alertdialog', { name: 'Remove SMS verification' }); + 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.', + ); + await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); + + expect(props.onRemove).toHaveBeenCalledExactlyOnceWith('work'); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + 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); + renderView({ + methods: [ + { id: 'work', type: 'sms', description: '+1 801-555-0200', canSetDefault: true }, + { id: 'other', type: 'sms', description: '+1 801-555-0300', 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' })); + await user.click(selected); + 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'); + + await act(async () => { + pending.resolve(); + await pending.promise; + }); + + 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 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(); + 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(); + }); +}); 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..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,34 +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, 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('menuitem', { name: 'SMS verification' })).not.toBeInTheDocument(); - await user.click(screen.getByRole('menuitem', { name: 'Authenticator app' })); 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()); @@ -135,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(); @@ -149,6 +159,7 @@ describe('UserProfileSecurityPanelView', () => { devices: [], onAddPasskey: vi.fn(), onAddMfaMethod: vi.fn(), + addableMfaMethods: ['sms', 'authenticator'], }); expect(screen.getByText('No passkeys added')).toBeInTheDocument(); @@ -229,7 +240,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 +250,7 @@ describe('UserProfileSecurityPanelView', () => { onRemoveMfaMethod, }); - expect(screen.queryByText('Backup codes')).not.toBeInTheDocument(); + expect(screen.getByText('Backup codes')).toBeVisible(); backupOnlyView.unmount(); const user = userEvent.setup(); @@ -253,6 +264,13 @@ describe('UserProfileSecurityPanelView', () => { await user.click(screen.getByRole('button', { name: 'Manage SMS verification' })); expect(screen.queryByRole('menuitem', { name: 'Manage' })).not.toBeInTheDocument(); await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); + const dialog = screen.getByRole('alertdialog', { name: 'Remove SMS verification' }); + expect(dialog).toHaveAccessibleDescription( + 'This phone number will no longer receive sign-in verification codes. It will remain on your account.', + ); + expect(onRemoveMfaMethod).not.toHaveBeenCalled(); + await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); expect(screen.queryByRole('menuitem', { name: 'Remove method' })).not.toBeInTheDocument(); await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); 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} - - 0} - onClick={props.onResend} - > - {props.isResending ? ( - m.verify.resending - ) : (props.resendSeconds ?? 0) > 0 ? ( - - {rich(m.verify.resendCountdown, { - values: { seconds: {props.resendSeconds} }, - })} - - ) : ( - m.verify.resend - )} - - - - - - } - > - {m.verify.cancel} - - - {m.verify.submit} - - - > - ); -} 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..227d0dd9705 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts @@ -0,0 +1,8 @@ +export const userProfileAddAuthenticatorMessages = { + codeLabel: 'Verification code', + back: 'Back', + verify: 'Verify', + pending: 'Verifying code', + preparing: 'Preparing authenticator…', + retry: 'Try again', +} as const; 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/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..8da976f3dc3 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx @@ -0,0 +1,146 @@ +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 { useMessages } from '../../localization'; +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 UserProfileAddAuthenticatorViewProps extends Omit< + UserProfileAuthenticatorSetupViewProps, + 'secret' | 'uri' +> { + setup?: { secret: string; uri: string }; + setupErrorMessage?: string; + onRetry: () => void; + onBack: () => void; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code: string) => void; + isPending?: boolean; + errorMessage?: string; +} + +export function UserProfileAddAuthenticatorView({ + onBack, + setup, + setupErrorMessage, + onRetry, + secretCopy, + uriCopy, + code, + onCodeChange, + onSubmit, + isPending = false, + errorMessage, +}: UserProfileAddAuthenticatorViewProps) { + const m = useMessages('userProfileAddAuthenticator'); + 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); + } + }; + + return ( + <> + {setup ? ( + + ) : ( + <> + + {setupMessages.title} + + + {setupErrorMessage ? ( + + {setupErrorMessage} + + ) : ( + + {m.preparing} + + )} + + > + )} + {setup ? ( + { + event.preventDefault(); + submitCode(code); + }} + /> + } + > + + {m.codeLabel} + + + {errorMessage} + + + + ) : null} + + + {m.back} + + + {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 new file mode 100644 index 00000000000..de412504706 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx @@ -0,0 +1,65 @@ +import { useMergeRefs } from '@floating-ui/react'; +import { type ReactNode, type Ref, useRef } from 'react'; + +import { Button } from '../../components/button'; +import { Card } from '../../components/card'; +import { Dialog } from '../../components/dialog'; +import { Icon } from '../../components/icon'; +import { useMessages } from '../../localization'; + +export interface UserProfileAddMfaDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; + disabled?: boolean; + triggerRef?: Ref; +} + +export function UserProfileAddMfaDialog({ + open, + onOpenChange, + children, + disabled, + triggerRef: triggerRefProp, +}: UserProfileAddMfaDialogProps) { + const m = useMessages('userProfileMfa'); + const addButtonRef = useRef(null); + const triggerRef = useMergeRefs([triggerRefProp, addButtonRef]); + return ( + + + } + > + + {m.add} + + + + {children} + + + + ); +} 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..1b36a61399a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-mfa.view.tsx @@ -0,0 +1,60 @@ +import { Card } from '../../components/card'; +import { useFlowAutoFocus } from '../../components/flow'; +import { Icon, IconFrame } from '../../components/icon'; +import { Item } from '../../components/item'; +import { useMessages } from '../../localization'; +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) { + const m = useMessages('userProfileMfa'); + const firstMethodRef = useFlowAutoFocus(); + return ( + <> + + {m.addDialog.title} + {m.addDialog.description} + + + + {methods.map((type, index) => ( + onSelect(type)} + /> + } + > + + + + + + + {m.methods[type]} + {m.addDialog.methods[type]} + + + + + + ))} + + + > + ); +} 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..2fcedd5531a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-sms.messages.ts @@ -0,0 +1,10 @@ +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', + back: 'Back', +} as const; 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..16b1ced1cf7 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-add-sms.view.tsx @@ -0,0 +1,178 @@ +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 { useMessages } from '../../localization'; +import { EnterPhoneStep, VerifyPhoneStep } from './user-profile-phone.steps'; + +export interface UserProfileAddSmsViewProps { + 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 m = useMessages('userProfileAddSms'); + const selectRef = useFlowAutoFocus(); + const phoneRef = useFlowAutoFocus(); + return ( + + {current => { + const backAction = ( + + {m.back} + + ); + return ( + <> + + + + + + + + + + > + ); + }} + + ); +} + +function SelectPhoneStep(props: UserProfileAddSmsViewProps & { inputRef?: Ref }) { + const m = useMessages('userProfileAddSms'); + 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.addPhone} + + + + + {m.back} + + + {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 new file mode 100644 index 00000000000..06f53289850 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.messages.ts @@ -0,0 +1,14 @@ +export const userProfileAuthenticatorSetupMessages = { + title: 'Add an authenticator app', + scanDescription: 'Set up a new sign-in method in your authenticator app and scan the QR code.', + manualDescription: 'Enter the setup key in your authenticator app, or use the setup URI if your app supports it.', + qrCodeLabel: 'Authenticator setup QR code', + setupKey: 'Setup key', + setupUri: 'Setup URI', + copyKey: 'Copy setup key', + copyUri: 'Copy setup URI', + copying: 'Copying…', + copied: 'Copied', + viewSetupKey: 'Can’t scan? View setup key', + scanQrCode: 'Scan QR code instead', +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.styles.ts new file mode 100644 index 00000000000..ef48c7e9b6b --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.styles.ts @@ -0,0 +1,8 @@ +import * as stylex from '@stylexjs/stylex'; + +export const styles = stylex.create({ + qrCode: { + display: 'flex', + justifyContent: 'center', + }, +}); 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 new file mode 100644 index 00000000000..21147cede38 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.view.tsx @@ -0,0 +1,106 @@ +import * as stylex from '@stylexjs/stylex'; +import { QRCodeSVG } from 'qrcode.react'; +import { useState } from 'react'; + +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 { 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; + secretCopy?: UserProfileAuthenticatorCopyProps; + uriCopy?: UserProfileAuthenticatorCopyProps; +} + +export function UserProfileAuthenticatorSetupView({ + secret, + uri, + secretCopy, + uriCopy, +}: UserProfileAuthenticatorSetupViewProps) { + const m = useMessages('userProfileAuthenticatorSetup'); + const [showSetupKey, setShowSetupKey] = useState(false); + + return ( + <> + + {m.title} + {showSetupKey ? m.manualDescription : m.scanDescription} + + + {showSetupKey ? ( + <> + {[ + { 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 ? ( + + copy.onCopy(value)} + > + + + + ) : null} + + + {feedback?.status === 'error' ? feedback.message : null} + + + {feedback?.status === 'pending' ? m.copying : feedback?.status === 'success' ? m.copied : null} + + + ); + })} + > + ) : ( + + + + )} + setShowSetupKey(current => !current)} + > + {showSetupKey ? m.scanQrCode : m.viewSetupKey} + + + > + ); +} 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..37b8b0a4c55 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.messages.ts @@ -0,0 +1,13 @@ +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 verification method.', + codesLabel: 'Backup codes', + download: 'Download', + copyAndClose: 'Copy and close', + cancel: 'Cancel', + back: 'Back', + retry: 'Try again', + generating: 'Generating backup codes', + copying: 'Copying backup codes', + downloading: 'Downloading backup codes', +} as const; 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..de07661a95f --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts @@ -0,0 +1,52 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, easingVars, 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: easingVars['--cl-ease-pulse'], + backgroundColor: colorVars['--cl-color-neutral-alpha-200'], + height: '1lh', + width: space['16'], + }, + 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/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..e425aa8b340 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx @@ -0,0 +1,157 @@ +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 { useMessages } from '../../localization'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { styles } from './user-profile-backup-codes.styles'; + +export interface UserProfileBackupCodesViewProps { + onCancel: () => void; + onBack?: () => void; + codes: readonly string[]; + onRetry: () => void; + onCopy: () => void; + onDownload: () => void; + pendingAction?: 'generate' | 'copy' | 'download'; + errorMessage?: string; +} + +export function UserProfileBackupCodesView({ + onCancel, + onBack, + codes, + onRetry, + onCopy, + onDownload, + pendingAction, + errorMessage, +}: UserProfileBackupCodesViewProps) { + const m = useMessages('userProfileBackupCodes'); + 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) => ( + + } + xstyle={styles.skeleton} + /> + + ))} + + ) : null} + + + {hasCodes ? ( + <> + + + {m.download} + + + + {m.copyAndClose} + + > + ) : ( + <> + + {onBack ? m.back : m.cancel} + + + {m.retry} + + > + )} + + > + ); +} 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..25cd5afc34c --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-row.view.tsx @@ -0,0 +1,60 @@ +import { Badge } from '../../components/badge'; +import { Section } from '../../components/section'; +import { fill, useMessages } from '../../localization'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +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; + onRemove?: () => void; + onSetDefault?: (id: string) => void; +}) { + const m = useMessages('userProfileMfa'); + 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 && !method.isDefault && 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 }); + } + + 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..3463921b362 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts @@ -0,0 +1,38 @@ +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', + setDefaultError: 'Unable to set this method as default. Please try again.', + remove: 'Remove method', + regenerate: 'Regenerate', + manage: 'Manage {label}', + manageSms: 'Manage {label} {phoneNumber}', + addDialog: { + title: 'Add 2-step verification', + description: 'Choose a verification method', + 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: { + smsTitle: 'Remove SMS verification', + smsDescription: + 'You will no longer receive sign-in verification codes at {phoneNumber}. The phone number will remain on your account.', + smsDescriptionWithoutNumber: + 'This phone number will no longer receive sign-in verification codes. It will remain on your account.', + authenticatorTitle: 'Remove authenticator app', + authenticatorDescription: + 'Verification codes from this authenticator will no longer be required when signing in. Your account may not be as secure.', + confirm: 'Remove', + }, +} as const; 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..1d4c0d1bf3e 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,11 @@ -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 { type ReactNode, type Ref, useMemo, useRef, useState } from 'react'; + +import { Confirmation } from '../../blocks/confirmation'; +import { Text } from '../../components/text'; +import { fill, type MosaicMessages, useMessages } from '../../localization'; +import { UserProfileAddMfaDialog } from './user-profile-add-mfa.dialog'; +import { UserProfileAddMfaView } from './user-profile-add-mfa.view'; +import { UserProfileMfaRowView } from './user-profile-mfa-row.view'; import { UserProfileSecurityList } from './user-profile-security-list'; export interface UserProfileMfaMethod { @@ -12,111 +13,130 @@ export interface UserProfileMfaMethod { type: 'sms' | 'authenticator' | 'backup-codes'; label?: string; description?: string; + isDefault?: boolean; + canRemove?: boolean; + canSetDefault?: boolean; } -export type UserProfileMfaAddableMethod = Extract; +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; - onRemove?: (id: string) => void; + onRemove?: (id: string) => void | Promise; + onSetDefault?: (id: string) => void | Promise; } -const labels: Record = { - sms: 'SMS verification', - authenticator: 'Authenticator app', - 'backup-codes': 'Backup codes', -}; - -const addableMethods: UserProfileMfaAddableMethod[] = ['sms', 'authenticator']; - export function UserProfileMfaSectionView({ methods, + addableMethods, + addButtonRef, + addControl, sectionTitle, 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); + const m = useMessages('userProfileMfa'); + const removeMethod = useMemo(() => Confirmation.createHandle(), []); + const [pickerOpen, setPickerOpen] = useState(false); + const [isSettingDefault, setIsSettingDefault] = useState(false); + const [defaultError, setDefaultError] = useState(); + const settingDefault = useRef(false); + + const setDefault = async (id: string) => { + const method = methods.find(method => method.id === id); + if ( + !onSetDefault || + method?.type !== 'sms' || + !method.canSetDefault || + method.isDefault || + settingDefault.current + ) { + return; + } + settingDefault.current = true; + setIsSettingDefault(true); + setDefaultError(undefined); + try { + await onSetDefault(id); + } catch (error) { + setDefaultError(error instanceof Error ? error.message : m.setDefaultError); + } finally { + settingDefault.current = false; + setIsSettingDefault(false); + } + }; return ( - 0 ? ( - - ( - - )} + <> + - { + onAdd(type); + setPickerOpen(false); + }} /> - Add - - - {availableMethods.map(type => ( - onAdd(type)} - > - {labels[type]} - - ))} - - - ) : null - } - addLabel='Add verification method' - emptyLabel='No verification methods added' - hasItems={visibleMethods.length > 0} - label='2-step verification' - sectionTitle={sectionTitle} - > - {visibleMethods.map(method => { - const label = method.label ?? labels[method.type]; - const actions: UserProfileMenuAction[] = []; - - if (method.type === 'backup-codes') { - if (onRegenerateBackupCodes) { - actions.push({ - label: 'Regenerate', - onClick: onRegenerateBackupCodes, - }); - } - } else if (onRemove) { - actions.push({ label: 'Remove method', color: 'negative', onClick: () => onRemove(method.id) }); + + ) : null) } - - return ( - - - - {label} - {method.description ? {method.description} : null} - - - - - - ); - })} - + addLabel={m.addLabel} + emptyLabel={m.empty} + hasItems={methods.length > 0} + label={m.label} + sectionTitle={sectionTitle} + > + {methods.map(method => ( + removeMethod.open(method) : undefined} + onSetDefault={onSetDefault && !isSettingDefault ? id => void setDefault(id) : undefined} + onRegenerateBackupCodes={onRegenerateBackupCodes} + /> + ))} + + {defaultError ? ( + + {defaultError} + + ) : null} + {onRemove ? ( + (method.type === 'sms' ? m.removeDialog.smsTitle : m.removeDialog.authenticatorTitle)} + description={method => describeMethodRemoval(method, m)} + actionLabel={m.removeDialog.confirm} + onConfirm={method => onRemove(method.id)} + /> + ) : null} + > ); } + +function describeMethodRemoval(method: UserProfileMfaMethod, m: MosaicMessages['userProfileMfa']) { + if (method.type === 'sms') { + return method.description + ? fill(m.removeDialog.smsDescription, { phoneNumber: method.description }) + : m.removeDialog.smsDescriptionWithoutNumber; + } + return m.removeDialog.authenticatorDescription; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-setup.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-setup.view.tsx new file mode 100644 index 00000000000..77225ac39bd --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-setup.view.tsx @@ -0,0 +1,63 @@ +import { Flow } from '../../components/flow'; +import type { UserProfileAddAuthenticatorViewProps } from './user-profile-add-authenticator.view'; +import { UserProfileAddAuthenticatorView } from './user-profile-add-authenticator.view'; +import type { UserProfileAddMfaViewProps } from './user-profile-add-mfa.view'; +import { UserProfileAddMfaView } from './user-profile-add-mfa.view'; +import type { UserProfileAddSmsViewProps } from './user-profile-add-sms.view'; +import { UserProfileAddSmsView } from './user-profile-add-sms.view'; +import type { UserProfileBackupCodesViewProps } from './user-profile-backup-codes.view'; +import { UserProfileBackupCodesView } from './user-profile-backup-codes.view'; +import type { UserProfileMfaAddableMethod } from './user-profile-mfa-section.view'; + +export interface UserProfileMfaSetupViewProps extends UserProfileAddMfaViewProps { + step: UserProfileMfaAddableMethod | 'select'; + sms: UserProfileAddSmsViewProps; + authenticator: Omit; + backupCodes: Omit; + onBack: () => void; + onCancel: () => void; +} + +export function UserProfileMfaSetupView(props: UserProfileMfaSetupViewProps) { + const step = props.step === 'authenticator' && props.authenticator.setup ? 'authenticator-verify' : props.step; + + return ( + + {current => ( + <> + + + + + + + + + + + + + + + + > + )} + + ); +} 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..6d38cdf923c --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx @@ -0,0 +1,193 @@ +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 { 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} + + 0} + onClick={props.onResend} + > + {props.isResending ? ( + m.verify.resending + ) : (props.resendSeconds ?? 0) > 0 ? ( + + {rich(m.verify.resendCountdown, { + values: { seconds: {props.resendSeconds} }, + })} + + ) : ( + m.verify.resend + )} + + + + + {props.secondaryAction ?? ( + + } + > + {m.verify.cancel} + + )} + + {m.verify.submit} + + + > + ); +} 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 5d3f67ccab4..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'; @@ -40,6 +40,8 @@ export interface UserProfileSecurityPanelViewProps passkeys?: UserProfilePasskey[]; passkeysVisible?: boolean; mfaMethods?: UserProfileMfaMethod[]; + addableMfaMethods?: readonly UserProfileMfaAddableMethod[]; + mfaAddControl?: ReactNode; devices?: UserProfileDevice[]; onAddPasskey?: () => void; addPasskeyError?: string; @@ -47,7 +49,8 @@ export interface UserProfileSecurityPanelViewProps onRemovePasskey?: (id: string) => void | Promise; onAddMfaMethod?: (type: UserProfileMfaAddableMethod) => void; onRegenerateBackupCodes?: () => void; - onRemoveMfaMethod?: (id: string) => void; + onRemoveMfaMethod?: (id: string) => void | Promise; + onSetDefaultMfaMethod?: (id: string) => void | Promise; /** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */ onDeleteAccount?: () => Promise; } @@ -59,6 +62,8 @@ export function UserProfileSecurityPanelView({ passkeys, passkeysVisible = true, mfaMethods, + addableMfaMethods, + mfaAddControl, devices, onSubmitPassword, onAddPasskey, @@ -68,6 +73,7 @@ export function UserProfileSecurityPanelView({ onAddMfaMethod, onRegenerateBackupCodes, onRemoveMfaMethod, + onSetDefaultMfaMethod, onSignOutDevice, onSignOutAllOtherDevices, onDeleteAccount, @@ -102,10 +108,13 @@ export function UserProfileSecurityPanelView({ {mfaMethods !== undefined ? ( ) : null} diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 278ebe1d727..6c37b74b67b 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -7,9 +7,14 @@ import { userProfileAddPhoneMessages } from '../features/user-profile/user-profi import { userProfileVerifyEmailLinkMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-link.messages'; import { userProfileVerifyEmailSsoMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages'; import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices.messages'; +import { userProfileAddAuthenticatorMessages } from '../features/user-profile/user-profile-add-authenticator.messages'; +import { userProfileAddSmsMessages } from '../features/user-profile/user-profile-add-sms.messages'; +import { userProfileAuthenticatorSetupMessages } from '../features/user-profile/user-profile-authenticator-setup.messages'; +import { userProfileBackupCodesMessages } from '../features/user-profile/user-profile-backup-codes.messages'; import { userProfileConnectedAccountsMessages } from '../features/user-profile/user-profile-connected-accounts.messages'; import { userProfileDeleteSectionMessages } from '../features/user-profile/user-profile-delete-section/user-profile-delete-section.messages'; import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages'; +import { userProfileMfaMessages } from '../features/user-profile/user-profile-mfa-section.messages'; import { userProfilePasswordSectionMessages } from '../features/user-profile/user-profile-password-section/user-profile-password-section.messages'; import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; @@ -23,9 +28,14 @@ export const mosaicMessages = { userProfileAddPhone: userProfileAddPhoneMessages, userProfileVerifyEmailLink: userProfileVerifyEmailLinkMessages, userProfileVerifyEmailSso: userProfileVerifyEmailSsoMessages, + userProfileAddAuthenticator: userProfileAddAuthenticatorMessages, + userProfileAddSms: userProfileAddSmsMessages, + userProfileAuthenticatorSetup: userProfileAuthenticatorSetupMessages, + userProfileBackupCodes: userProfileBackupCodesMessages, userProfileConnectedAccounts: userProfileConnectedAccountsMessages, userProfileDeleteSection: userProfileDeleteSectionMessages, userProfileEnterpriseAccountsSection: userProfileEnterpriseAccountsMessages, + userProfileMfa: userProfileMfaMessages, userProfilePasswordSection: userProfilePasswordSectionMessages, userProfileWeb3Wallets: userProfileWeb3WalletsMessages, }; diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index cc8a1cccd47..9e88acd2a53 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -209,3 +209,5 @@ export type SpacingVarName = keyof typeof spacingVars; 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 { UserProfileMfaSetupView } from '../features/user-profile/user-profile-mfa-setup.view'; 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/package.json b/packages/swingset/package.json index 6d7c64cade8..ee27015de47 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" }, "dependencies": { "@base-ui/react": "^1.5.0", diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 63ce6e97639..eadd81c7f4c 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -306,7 +306,6 @@ import { } from '../stories/user-profile-enterprise-accounts-section.stories'; import { Default as UserProfileMfaSectionDefault, - Empty as UserProfileMfaSectionEmpty, meta as userProfileMfaSectionMeta, } from '../stories/user-profile-mfa-section.stories'; import { @@ -676,7 +675,6 @@ const userProfilePasskeysSectionModule: StoryModule = { const userProfileMfaSectionModule: StoryModule = { meta: userProfileMfaSectionMeta, Default: UserProfileMfaSectionDefault, - Empty: UserProfileMfaSectionEmpty, }; const userProfileActiveDevicesSectionModule: StoryModule = { meta: userProfileActiveDevicesSectionMeta, 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..bb065066f2b --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-authenticator.ts @@ -0,0 +1,35 @@ +import type { UserProfileAuthenticatorCopyProps } from '@clerk/mosaic/features/user-profile/user-profile-authenticator-setup.view'; +import { useEffect, useState } from 'react'; + +export const authenticatorSetup = { + secret: 'JBSWY3DPEHPK3PXP', + uri: 'otpauth://totp/Swingset:demo@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Swingset', +}; + +export function useAuthenticatorCopy() { + const [copyState, setCopyState] = useState(); + + useEffect(() => { + if (copyState?.status !== 'success') { + return; + } + const timeout = setTimeout(() => setCopyState(undefined), 2000); + return () => clearTimeout(timeout); + }, [copyState]); + + return { + state: copyState, + onCopy: async (value: string) => { + if (copyState?.status === 'pending') { + return; + } + setCopyState({ status: 'pending' }); + try { + await navigator.clipboard.writeText(value); + setCopyState({ status: 'success' }); + } catch { + 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 new file mode 100644 index 00000000000..4386f320f50 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.test.tsx @@ -0,0 +1,131 @@ +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 { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +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, + localization, +}: { + overlay?: boolean; + localization?: ComponentProps['localization']; +}) { + const { pages } = useUserProfileFixture(); + const profile = ( + + ); + return ( + + {overlay ? ( + + {profile} + + ) : ( + profile + )} + + ); +} + +describe('Profile MFA flows', () => { + 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(); + const copy = 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.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).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(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(); + 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()); + expect(screen.getByRole('dialog')).toBe(profile); + expect(add).toHaveFocus(); + expect(within(profile).getByRole('button', { name: 'Manage Authenticator app' })).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(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-example.tsx b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx new file mode 100644 index 00000000000..6caf23a8525 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx @@ -0,0 +1,49 @@ +import { UserProfileAddMfaDialog } from '@clerk/mosaic/features/user-profile/user-profile-add-mfa.dialog'; +import { UserProfileMfaSetupView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-setup.view'; +import type { UserProfileSecurityPanelViewProps } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; + +import { useAuthenticatorCopy } from './user-profile-authenticator'; +import { useUserProfileMfaFixture } from './user-profile-mfa'; + +export function useUserProfileMfaExample() { + const fixture = useUserProfileMfaFixture(); + const secretCopy = useAuthenticatorCopy(); + const uriCopy = useAuthenticatorCopy(); + const addControl = ( + + fixture.section.onAdd?.(type)} + sms={fixture.sms} + authenticator={{ ...fixture.authenticator, secretCopy, uriCopy }} + backupCodes={fixture.backupCodes} + onBack={fixture.setup.onBack} + onCancel={() => fixture.setup.onOpenChange(false)} + /> + + ); + 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 }; +} 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..6a93da10b08 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.test.ts @@ -0,0 +1,340 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deferred } from '../../../../mosaic/src/machines/__tests__/test-utils'; +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, backupCodesEnabled = true) { + const onCopy = vi.fn<(codes: readonly string[]) => Promise>().mockResolvedValue(undefined); + const onDownload = vi.fn<(codes: readonly string[]) => Promise>().mockResolvedValue(undefined); + const onGenerateBackupCodes = vi.fn<() => Promise>().mockResolvedValue(regeneratedCodes); + return { + ...renderHook( + ({ backupCodesEnabled }) => + useUserProfileMfaFixture({ + enrollmentBackupCodes, + onGenerateBackupCodes: backupCodesEnabled ? onGenerateBackupCodes : undefined, + onCopy, + onDownload, + }), + { initialProps: { backupCodesEnabled } }, + ), + onCopy, + onDownload, + onGenerateBackupCodes, + }; +} + +async function complete(action: () => void) { + await act(async () => { + action(); + await vi.advanceTimersByTimeAsync(1500); + }); +} + +describe('MFA playground', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + 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.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(); + + 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('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.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.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); + 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.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']); + 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.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); + + await complete(() => result.current.section.onRegenerateBackupCodes?.()); + 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); + }, + ); + + 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')); + expect(result.current.setup).toMatchObject({ open: true, step: 'authenticator' }); + await complete(() => result.current.authenticator.onSubmit('123456')); + 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']); + const codes = result.current.backupCodes.codes; + expect(codes).toEqual(enrollmentCodes); + expect(onGenerateBackupCodes).not.toHaveBeenCalled(); + await complete(() => result.current.backupCodes.onDownload()); + expect(onDownload).toHaveBeenCalledExactlyOnceWith(codes); + expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); + await complete(() => result.current.backupCodes.onCopy()); + expect(onCopy).toHaveBeenCalledExactlyOnceWith(codes); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); + await complete(() => result.current.section.onRegenerateBackupCodes?.()); + 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); + }); + + 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.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.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)); + 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.setup).not.toMatchObject({ open: true, step: 'sms' }); + expect(result.current.section.methods.some(method => method.id === 'other')).toBe(true); + 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.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 () => { + 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.type === 'sms').map(method => method.id)).toEqual([ + 'other', + 'personal', + ]); + 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('preserves codes on a real copy failure and closes after a successful retry', async () => { + const { result, onCopy } = setup(); + 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()); + 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.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); + expect(result.current.backupCodes.errorMessage).toBeUndefined(); + }); + + 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')); + act(() => result.current.authenticator.onCodeChange('123')); + 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(''); + }); + + 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.setup.onOpenChange(false)); + 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.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.setup).toMatchObject({ open: true, step: 'backup-codes' }); + expect(result.current.backupCodes.codes).toEqual(enrollmentCodes); + }); + + 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).toContain('backup-codes'); + expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); + 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, onGenerateBackupCodes } = setup(); + act(() => result.current.section.onAdd?.('authenticator')); + await complete(() => result.current.authenticator.onSubmit('123456')); + 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.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); + 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 new file mode 100644 index 00000000000..5dfb7bff017 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts @@ -0,0 +1,418 @@ +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'; + +import { authenticatorSetup } from './user-profile-authenticator'; + +interface FixtureOptions { + initialFlow?: UserProfileMfaAddableMethod; + enrollmentBackupCodes?: readonly string[]; + 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: async () => { + await pause(); + return [ + '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, + onGenerateBackupCodes, + onCopy, + onDownload, +}: FixtureOptions = mfaDemoOptions): { + setup: { + open: boolean; + step: UserProfileMfaAddableMethod | 'select'; + onOpenChange: (open: boolean) => void; + onBack: () => void; + }; + section: UserProfileMfaSectionViewProps; + authenticator: UserProfileMfaSetupViewProps['authenticator']; + sms: UserProfileMfaSetupViewProps['sms']; + backupCodes: UserProfileMfaSetupViewProps['backupCodes']; +} { + 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', + 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(''); + 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(() => account.phones.find(phone => !phone.enrolled)?.id ?? ''); + 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; + 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 => { + 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'); + } + if (onGenerateBackupCodes && !account.hasBackupCodes && (account.authenticator || enrolledPhones.length > 0)) { + addableMethods.push('backup-codes'); + } + const generateBackupCodes = async (source: 'select' | 'regenerate') => { + if (pending || !onGenerateBackupCodes) { + return; + } + setBackupCodesSource(source); + setFlow('backup-codes'); + setPending('generate'); + setErrorMessage(undefined); + setCodes([]); + try { + 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 generate backup codes. Please try again.'); + } finally { + setPending(undefined); + } + }; + + const open = (type: UserProfileMfaAddableMethod) => { + if (pending || !addableMethods.includes(type)) { + return; + } + if (type === 'backup-codes') { + void generateBackupCodes('select'); + return; + } + setCode(''); + setErrorMessage(undefined); + setResendSeconds(0); + setPhoneNumber(''); + setSelectedPhoneId(eligiblePhones[0]?.id ?? ''); + setStep(eligiblePhones.length > 0 ? 'select' : 'phone'); + setDirection(1); + setFlow(type); + }; + + const close = (next: boolean) => { + if (!next && !pending) { + setFlow(undefined); + setResendSeconds(0); + } + }; + + const backToMethods = () => { + if (pending) { + return; + } + setFlow('select'); + setCode(''); + setErrorMessage(undefined); + setResendSeconds(0); + }; + + const finishEnrollment = () => { + setResendSeconds(0); + setPending(undefined); + if (!account.hasBackupCodes && enrollmentBackupCodes?.length) { + setCodes(enrollmentBackupCodes); + setAccount(current => ({ ...current, hasBackupCodes: true })); + setFlow('backup-codes'); + } else { + setFlow(undefined); + } + }; + + const verifyAuthenticator = async (value: string) => { + 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(); + }; + + 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' && 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 => ({ + ...current, + phones: phone + ? current.phones.map(item => (item.id === phone.id ? enrolled : item)) + : [...current.phones, enrolled], + })); + 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 { + setup: { + open: flow !== undefined, + step: flow ?? 'select', + onBack: backToMethods, + onOpenChange: next => { + if (next && !pending) { + setFlow('select'); + } else { + close(next); + } + }, + }, + section: { + methods, + addableMethods, + sectionTitle: 'Authentication', + onAdd: open, + onRegenerateBackupCodes: + account.hasBackupCodes && onGenerateBackupCodes ? () => void generateBackupCodes('regenerate') : undefined, + 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 hasSecondFactor = authenticator || phones.some(phone => phone.enrolled); + setAccount(current => ({ + ...current, + phones, + authenticator, + hasBackupCodes: current.hasBackupCodes && hasSecondFactor, + })); + if (!hasSecondFactor) { + setCodes([]); + setFlow(undefined); + } + }, + }, + authenticator: { + setup: authenticatorSetup, + onRetry: () => undefined, + code, + onCodeChange, + onSubmit: value => void verifyAuthenticator(value), + isPending: pending === 'submit', + errorMessage, + }, + sms: { + step, + direction, + phoneNumbers: eligiblePhones, + selectedPhoneId, + onSelectedPhoneIdChange: id => { + setSelectedPhoneId(id); + setErrorMessage(undefined); + }, + onAddPhone: () => { + setPhoneNumber(''); + setErrorMessage(undefined); + setDirection(1); + setStep('phone'); + }, + onBack: () => { + if (step === 'select' || (step === 'phone' && eligiblePhones.length === 0)) { + backToMethods(); + 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: { + onBack: backupCodesSource === 'select' ? backToMethods : undefined, + codes, + pendingAction: pending === 'generate' || pending === 'copy' || pending === 'download' ? pending : undefined, + errorMessage, + onRetry: () => { + if (!pending) { + void generateBackupCodes(backupCodesSource); + } + }, + onCopy: () => void save('copy'), + onDownload: () => void save('download'), + }, + }; +} 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-mfa-section.mdx b/packages/swingset/src/stories/user-profile-mfa-section.mdx index c915eceedaa..7bba313321b 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.mdx +++ b/packages/swingset/src/stories/user-profile-mfa-section.mdx @@ -2,16 +2,36 @@ import * as Stories from './user-profile-mfa-section.stories'; # UserProfileMfaSection -Two-step verification methods composed with the shared Security list treatment. +Display and manage two-step verification methods. - +## Playground + +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 changes are simulated. Copy and Download use your browser’s clipboard and file downloads. 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 dc0c89f72a2..33631c6bb02 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,15 @@ -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 { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; +import { useUserProfileMfaExample } from './fixtures/user-profile-mfa-example'; + export { default as __source } from './user-profile-mfa-section.stories?raw'; export const meta: StoryMeta = { group: 'User Profile', status: 'wip', + substatus: 'needs wire-up', title: 'UserProfileMfaSection', label: '2-step verification', navigation: { category: 'Sections' }, @@ -16,70 +17,6 @@ export const meta: StoryMeta = { }; export function Default() { - const [methods, setMethods] = useState([ - { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, - { id: 'backup', type: 'backup-codes' }, - ]); - - return ( - - setMethods(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 }]), - ]; - }) - } - onRegenerateBackupCodes={() => - setMethods(current => - current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), - ) - } - onRemove={id => setMethods(current => current.filter(method => method.id !== id))} - /> - ); -} - -export function Empty() { - const [methods, setMethods] = useState([]); - - return ( - - setMethods(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 }]), - ]; - }) - } - onRegenerateBackupCodes={() => - setMethods(current => - current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), - ) - } - onRemove={id => setMethods(current => current.filter(method => method.id !== id))} - /> - ); + const mfa = useUserProfileMfaExample(); + return ; } 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 8f310349c09..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,49 +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 ( - - 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'), + }, + }, +}); 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