diff --git a/.changeset/bright-mfa-panel.md b/.changeset/bright-mfa-panel.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/bright-mfa-panel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi-mfa.feature.test.tsx b/packages/mosaic/src/__tests__/feature/fake-fapi-mfa.feature.test.tsx new file mode 100644 index 00000000000..acc27fff91f --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi-mfa.feature.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { fapiUrl, serveFapi } from './fake-fapi'; +import { fapiClient, fapiPhoneNumber, fapiSession, fapiUser, fapiVerification } from './fapi'; + +describe('fake FAPI MFA session routing', () => { + it('updates the requested user across their sessions without changing the active user', async () => { + const requestedUser = fapiUser({ + id: 'user_2', + phone_numbers: [ + fapiPhoneNumber({ + id: 'phone_2', + phone_number: '+15555550202', + verification: fapiVerification('phone_code', { status: 'verified' }), + }), + ], + }); + const fapi = serveFapi({ + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ id: 'sess_2', user: requestedUser }), + fapiSession({ id: 'sess_3', user: requestedUser }), + ]), + }); + const response = await fetch(`${fapiUrl('/v1/me/phone_numbers/phone_2')}?_method=PATCH&_clerk_session_id=sess_2`, { + method: 'POST', + body: new URLSearchParams({ reserved_for_second_factor: 'true' }), + }); + + expect(response.ok).toBe(true); + expect(fapi.client.sessions[0]?.user.phone_numbers).toHaveLength(0); + for (const session of fapi.client.sessions.slice(1)) { + expect(session.user.phone_numbers[0]?.reserved_for_second_factor).toBe(true); + expect(session.user.two_factor_enabled).toBe(true); + } + }); +}); diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 62940b66714..de409b5e0bd 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -19,8 +19,9 @@ import { http, HttpResponse } from 'msw'; import { setupWorker } from 'msw/browser'; import { enterpriseHandlers, type FakeEnterpriseLinking } from './fake-fapi/enterprise'; +import { createMfaState, type FakeMfaState, mfaHandlers } from './fake-fapi/mfa'; import { type FakePasskeysSeed, passkeyHandlers } from './fake-fapi/passkeys'; -import { envelope, error, findSession, missing, updateUser } from './fake-fapi/shared'; +import { envelope, error, findSession, missing, updateUser, VERIFICATION_CODE } from './fake-fapi/shared'; import { createVerificationState, type FakeVerificationSeed, @@ -60,6 +61,7 @@ export interface FakeFapiState { passwordUpdates: URLSearchParams[]; enterpriseConnections: EnterpriseConnectionJSON[]; enterpriseLinking: FakeEnterpriseLinking; + mfa: FakeMfaState; activeDevices?: ActiveDeviceRecord[]; deviceTrackingEnabled: boolean; } @@ -206,7 +208,7 @@ function rejected(code: string, message: string) { return HttpResponse.json({ errors: [{ code, message, long_message: message }] }, { status: 400 }); } -export const VERIFICATION_CODE = '424242'; +export { VERIFICATION_CODE } from './fake-fapi/shared'; export const PROFILE_IMAGE_URL = 'https://img.clerk.com/uploaded.png'; const USER_FIELDS = [ @@ -290,6 +292,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { passwordUpdates: [], enterpriseConnections: [], deviceTrackingEnabled: true, + mfa: createMfaState(), ...rest, verification: createVerificationState(verification), enterpriseLinking: { @@ -306,6 +309,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { ...verificationHandlers(state, fapiUrl), ...enterpriseHandlers(state, fapiUrl), ...passkeyHandlers(state, fapiUrl, passkeys), + ...mfaHandlers(state, fapiUrl), http.get(fapiUrl('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/v1/environment'), () => HttpResponse.json(state.environment, state.country ? { headers: { 'x-country': state.country } } : undefined), ), diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts new file mode 100644 index 00000000000..41f511abb5d --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts @@ -0,0 +1,283 @@ +import type { ClientJSON, PhoneNumberJSON, UserJSON } from '@clerk/shared/types'; +import { http, HttpResponse } from 'msw'; + +import { type FapiEnvironment, fapiPhoneNumber, fapiVerification } from '../fapi'; +import { envelope, missing, requestUser, updateUser, VERIFICATION_CODE } from './shared'; + +export interface FakeMfaState { + totpCreations: number; + totpAttempts: string[]; + totpRemovals: number; + backupCodeCreations: number; + phoneCreations: string[]; + phonePreparations: string[]; + phoneAttempts: { id: string; code: string }[]; + phoneUpdates: { id: string; reserved?: boolean; default?: boolean }[]; + codes: string[]; +} + +export function createMfaState(): FakeMfaState { + return { + totpCreations: 0, + totpAttempts: [], + totpRemovals: 0, + backupCodeCreations: 0, + phoneCreations: [], + phonePreparations: [], + phoneAttempts: [], + phoneUpdates: [], + codes: Array.from({ length: 10 }, (_, index) => `CODE00${String(index + 1).padStart(2, '0')}`), + }; +} + +export function mfaHandlers( + state: { client: ClientJSON; environment: FapiEnvironment; mfa: FakeMfaState }, + fapiUrl: (path: string) => string, +) { + const backupEnabled = () => + state.environment.user_settings.attributes.backup_code.enabled && + state.environment.user_settings.attributes.backup_code.used_for_second_factor; + const usableFactors = (user: UserJSON) => + Number(user.totp_enabled) + + user.phone_numbers.filter(phone => phone.reserved_for_second_factor && phone.verification?.status === 'verified') + .length; + const error = (code: string, message: string, status = 400) => + HttpResponse.json({ errors: [{ code, message, long_message: message }] }, { status }); + const updatePhone = (user: UserJSON, id: string, transform: (phone: PhoneNumberJSON) => PhoneNumberJSON) => { + const phone = user.phone_numbers.find(phone => phone.id === id); + if (!phone) { + return undefined; + } + const updated = transform(phone); + updateUser(state, { + ...user, + phone_numbers: user.phone_numbers.map(phone => (phone.id === id ? updated : phone)), + }); + return updated; + }; + + return [ + http.post(fapiUrl('/v1/me/totp'), ({ request }) => { + const currentUser = requestUser(state, request); + if (!currentUser) { + return missing(); + } + if (new URL(request.url).searchParams.get('_method') === 'DELETE') { + if ( + !currentUser.totp_enabled || + (state.environment.user_settings.sign_up.mfa?.required && usableFactors(currentUser) <= 1) + ) { + return error('second_factor_deletion_not_allowed', 'This method cannot be removed.'); + } + state.mfa.totpRemovals += 1; + updateUser(state, { + ...currentUser, + totp_enabled: false, + two_factor_enabled: currentUser.phone_numbers.some(phone => phone.reserved_for_second_factor), + backup_code_enabled: + currentUser.phone_numbers.some(phone => phone.reserved_for_second_factor) && + currentUser.backup_code_enabled, + }); + return envelope({ object: 'deleted', id: 'totp_1' }, state.client); + } + if ( + currentUser.totp_enabled || + !state.environment.user_settings.attributes.authenticator_app.used_for_second_factor + ) { + return error('form_param_value_invalid', 'Authenticator is unavailable.', 422); + } + state.mfa.totpCreations += 1; + return envelope( + { + object: 'totp', + id: 'totp_1', + secret: `SECRET${state.mfa.totpCreations}`, + uri: `otpauth://totp/Acme:user?secret=SECRET${state.mfa.totpCreations}`, + verified: false, + created_at: Date.now(), + updated_at: Date.now(), + }, + state.client, + ); + }), + http.post(fapiUrl('/v1/me/totp/attempt_verification'), async ({ request }) => { + const currentUser = requestUser(state, request); + if (!currentUser) { + return missing(); + } + if (state.mfa.totpCreations === 0 || currentUser.totp_enabled) { + return error('form_param_value_invalid', 'No authenticator setup is pending.', 422); + } + const code = new URLSearchParams(await request.text()).get('code') ?? ''; + state.mfa.totpAttempts.push(code); + if (code !== '123456') { + return HttpResponse.json( + { errors: [{ code: 'form_code_incorrect', message: 'Incorrect code', long_message: 'Incorrect code' }] }, + { status: 422 }, + ); + } + const newCodes = backupEnabled() && !currentUser.backup_code_enabled; + updateUser(state, { + ...currentUser, + totp_enabled: true, + two_factor_enabled: true, + backup_code_enabled: currentUser.backup_code_enabled || newCodes, + }); + return envelope( + { + object: 'totp', + id: 'totp_1', + verified: true, + ...(newCodes ? { backup_codes: state.mfa.codes } : {}), + created_at: Date.now(), + updated_at: Date.now(), + }, + state.client, + ); + }), + http.post(fapiUrl('/v1/me/backup_codes/'), ({ request }) => { + const currentUser = requestUser(state, request); + if (!currentUser || usableFactors(currentUser) === 0 || !backupEnabled()) { + return error('form_param_value_invalid', 'Set up a verification method first.', 422); + } + state.mfa.backupCodeCreations += 1; + state.mfa.codes = Array.from( + { length: 10 }, + (_, index) => `CODE${String(state.mfa.backupCodeCreations).padStart(2, '0')}${String(index).padStart(2, '0')}`, + ); + updateUser(state, { ...currentUser, backup_code_enabled: true }); + return envelope( + { + object: 'backup_code', + id: 'backup_1', + codes: state.mfa.codes, + created_at: Date.now(), + updated_at: Date.now(), + }, + state.client, + ); + }), + http.post(fapiUrl('/v1/me/phone_numbers/'), async ({ request }) => { + const currentUser = requestUser(state, request); + if (!currentUser) { + return missing(); + } + const number = new URLSearchParams(await request.text()).get('phone_number') ?? ''; + state.mfa.phoneCreations.push(number); + if (currentUser.phone_numbers.some(phone => phone.phone_number === number)) { + return HttpResponse.json( + { + errors: [ + { + code: 'form_identifier_exists', + message: 'This phone number already exists.', + long_message: 'This phone number already exists.', + meta: { param_name: 'phone_number' }, + }, + ], + }, + { status: 422 }, + ); + } + const existingIds = new Set(currentUser.phone_numbers.map(phone => phone.id)); + let nextPhoneNumber = state.mfa.phoneCreations.length; + while (existingIds.has(`phone_${nextPhoneNumber}`)) { + nextPhoneNumber += 1; + } + const phone = fapiPhoneNumber({ + id: `phone_${nextPhoneNumber}`, + phone_number: number, + verification: fapiVerification('phone_code'), + }); + updateUser(state, { ...currentUser, phone_numbers: [...currentUser.phone_numbers, phone] }); + return envelope(phone, state.client); + }), + http.post(fapiUrl('/v1/me/phone_numbers/:id/prepare_verification'), ({ params, request }) => { + const currentUser = requestUser(state, request); + if (!currentUser) { + return missing(); + } + const id = String(params.id); + state.mfa.phonePreparations.push(id); + const phone = updatePhone(currentUser, id, current => ({ + ...current, + verification: fapiVerification('phone_code'), + })); + return phone ? envelope(phone, state.client) : missing(); + }), + http.post(fapiUrl('/v1/me/phone_numbers/:id/attempt_verification'), async ({ params, request }) => { + const currentUser = requestUser(state, request); + if (!currentUser) { + return missing(); + } + const id = String(params.id); + const code = new URLSearchParams(await request.text()).get('code') ?? ''; + state.mfa.phoneAttempts.push({ id, code }); + if (code !== VERIFICATION_CODE) { + return error('form_code_incorrect', 'Incorrect code'); + } + const phone = updatePhone(currentUser, id, current => ({ + ...current, + verification: fapiVerification('phone_code', { status: 'verified' }), + })); + return phone ? envelope(phone, state.client) : missing(); + }), + http.post(fapiUrl('/v1/me/phone_numbers/:id'), async ({ params, request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'PATCH') { + return undefined; + } + const id = String(params.id); + const body = new URLSearchParams(await request.text()); + const reserved = body.has('reserved_for_second_factor') + ? body.get('reserved_for_second_factor') === 'true' + : undefined; + const isDefault = body.has('default_second_factor') ? body.get('default_second_factor') === 'true' : undefined; + state.mfa.phoneUpdates.push({ id, reserved, default: isDefault }); + const user = requestUser(state, request); + const existingPhone = user?.phone_numbers.find(phone => phone.id === id); + if (!user || !existingPhone) { + return missing(); + } + if (reserved === true && existingPhone.verification?.status !== 'verified') { + return error( + 'identification_update_second_factor_unverified', + 'Cannot update second factor attributes for unverified identification', + ); + } + if (reserved === false && state.environment.user_settings.sign_up.mfa?.required && usableFactors(user) <= 1) { + return error('second_factor_deletion_not_allowed', 'This method cannot be removed.'); + } + const firstReservedPhone = !user.phone_numbers.some(item => item.reserved_for_second_factor); + const newCodes = reserved && backupEnabled() && !user.backup_code_enabled; + const phone = { + ...existingPhone, + reserved_for_second_factor: reserved ?? existingPhone.reserved_for_second_factor, + default_second_factor: + isDefault ?? + (reserved && firstReservedPhone ? true : reserved === false ? false : existingPhone.default_second_factor), + backup_codes: newCodes ? state.mfa.codes : undefined, + }; + let phones = user.phone_numbers.map(item => { + if (item.id === id) { + return phone; + } + return isDefault ? { ...item, default_second_factor: false } : item; + }); + if (reserved === false && existingPhone.default_second_factor) { + const next = phones.find(item => item.reserved_for_second_factor); + phones = phones.map(item => (item.id === next?.id ? { ...item, default_second_factor: true } : item)); + } + const hasFactor = user.totp_enabled || phones.some(item => item.reserved_for_second_factor); + updateUser(state, { + ...user, + phone_numbers: phones, + two_factor_enabled: reserved === undefined ? user.two_factor_enabled : hasFactor, + backup_code_enabled: + reserved === undefined + ? user.backup_code_enabled + : hasFactor && (user.backup_code_enabled || Boolean(newCodes)), + }); + return envelope(phone, state.client); + }), + ]; +} diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts index 05381d18a99..ad0f1a7a659 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts @@ -1,6 +1,8 @@ import type { ClientJSON, SessionJSON, UserJSON } from '@clerk/shared/types'; import { HttpResponse, type JsonBodyType } from 'msw'; +export const VERIFICATION_CODE = '424242'; + type ClientState = { client: ClientJSON }; export function envelope(response: JsonBodyType, client: ClientJSON | null) { diff --git a/packages/mosaic/src/__tests__/feature/render.tsx b/packages/mosaic/src/__tests__/feature/render.tsx index 0b4e0694f69..37da7dcface 100644 --- a/packages/mosaic/src/__tests__/feature/render.tsx +++ b/packages/mosaic/src/__tests__/feature/render.tsx @@ -4,10 +4,15 @@ import { act, render } from '@testing-library/react'; import type { ReactElement } from 'react'; import { vi } from 'vitest'; +import type { MosaicLocalization } from '../../localization'; import { MosaicProvider } from '../../mosaic-provider'; import { PUBLISHABLE_KEY } from './fake-fapi'; -export async function renderWithClerk(ui: ReactElement, options?: Parameters[0]) { +export async function renderWithClerk( + ui: ReactElement, + options?: Parameters[0], + localization?: MosaicLocalization, +) { const clerk = new Clerk(PUBLISHABLE_KEY); const navigate = vi.fn((_to: string) => Promise.resolve()); @@ -16,7 +21,7 @@ export async function renderWithClerk(ui: ReactElement, options?: Parameters - {element} + {element} ); let current = ui; 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 deleted file mode 100644 index ce405461e31..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/mfa-test-utils.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { vi } from 'vitest'; - -import { MosaicProvider } from '../../../mosaic-provider'; -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 deleted file mode 100644 index bc864208e45..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx +++ /dev/null @@ -1,151 +0,0 @@ -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.getByRole('alert').textContent).toBe(''); - 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 deleted file mode 100644 index eaccc021524..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.view.test.tsx +++ /dev/null @@ -1,156 +0,0 @@ -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 }); - if (role === 'textbox') { - expect(field).toHaveAttribute('aria-disabled', 'true'); - expect(field).toHaveAttribute('readonly'); - } else { - 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).toHaveAttribute('aria-disabled', 'true'); - expect(code).toHaveAttribute('readonly'); - 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).not.toHaveAttribute('aria-disabled'); - 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 deleted file mode 100644 index 964d311396e..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-authenticator-setup.view.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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 '../../../mosaic-provider'; -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 deleted file mode 100644 index 0b4b663c219..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.view.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index 14b2f9e61a4..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -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 '../../../mosaic-provider'; -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 ( - - - - {() => ( - <> - - - - - - - - )} - - - - ); - } - 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 deleted file mode 100644 index 7886e892af1..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { act, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { deferred } from '../../../__tests__/async'; -import { clerkApiError } from '../../../__tests__/clerk-errors'; -import { MosaicProvider } from '../../../mosaic-provider'; -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( - - - , - ), - }; -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -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', 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(props.onAdd).not.toHaveBeenCalled(); - await user.click(add); - await user.click(screen.getByRole('button', { name: 'Close' })); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - - it('removes only the SMS method confirmed after a cancel', 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()); - - 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: clerkApiError('phone_number_not_verified', 'Unable to update the default method.'), - message: 'Unable to update the default method.', - }, - { - cause: new Error('Cannot read properties of undefined'), - message: 'Unable to set this method as default. Please try again.', - }, - { 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 }) => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - 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' })); - - await waitFor(() => expect(screen.getByRole('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-mfa.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel-mfa.feature.test.tsx new file mode 100644 index 00000000000..44f3b4318e2 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel-mfa.feature.test.tsx @@ -0,0 +1,104 @@ +import { screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { mfaEnvironment } from '../user-profile-mfa-section/__tests__/mfa-feature-setup'; +import { mfaSectionNode } from '../user-profile-mfa-section/user-profile-mfa-section'; +import { useUserProfileMfaModel } from '../user-profile-mfa-section/user-profile-mfa-section.model'; +import { renderPasswordSection } from '../user-profile-password-section/user-profile-password-section'; +import { useUserProfilePasswordModel } from '../user-profile-password-section/user-profile-password-section.model'; +import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; + +describe('User profile security panel MFA composition', () => { + it('renders a plain MFA node in Authentication over legacy methods', async () => { + serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk( + Connected MFA} + mfaMethods={[{ id: 'injected', type: 'authenticator' }]} + />, + ); + + const authentication = screen.getByRole('region', { name: 'Authentication' }); + expect(within(authentication).getByText('Connected MFA')).toBeVisible(); + expect(within(authentication).queryByRole('heading', { name: '2-step verification' })).toBeNull(); + }); + + it('suppresses injected MFA when the slot is explicitly null', async () => { + serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk( + , + ); + + expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); + }); + + it('places a connected MFA slot after password in the security panel', async () => { + function Panel() { + const mfaSlot = mfaSectionNode(useUserProfileMfaModel()); + const passwordSlot = renderPasswordSection(useUserProfilePasswordModel(), null); + return ( + + ); + } + serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk(); + const authentication = screen.getByRole('region', { name: 'Authentication' }); + expect(authentication).toHaveTextContent('Password'); + expect(authentication).toHaveTextContent('2-step verification'); + expect(authentication.textContent?.indexOf('Password')).toBeLessThan( + authentication.textContent?.indexOf('2-step verification') ?? 0, + ); + }); + + it('uses the connected slot instead of duplicate injected MFA props', async () => { + function Panel() { + const mfaSlot = mfaSectionNode(useUserProfileMfaModel()); + return ( + + ); + } + serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk(); + expect(screen.getAllByText('2-step verification')).toHaveLength(1); + expect(screen.queryByText('Authenticator app')).toBeNull(); + }); + + it('suppresses injected MFA when the connected slot is hidden', async () => { + function Panel() { + const mfaSlot = mfaSectionNode(useUserProfileMfaModel()); + return ( + + ); + } + serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]) }); + await renderWithClerk(); + expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/mfa-feature-setup.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/mfa-feature-setup.tsx new file mode 100644 index 00000000000..75921cc1f65 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/mfa-feature-setup.tsx @@ -0,0 +1,41 @@ +import { serveFapi } from '../../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEnvironment, + fapiPhoneNumber, + fapiSession, + fapiUser, + fapiVerification, +} from '../../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { UserProfileMfaSection } from '../user-profile-mfa-section'; + +export const phone = fapiPhoneNumber({ + id: 'phone_1', + phone_number: '+15555550101', + verification: fapiVerification('phone_code', { status: 'verified' }), +}); + +export function mfaEnvironment() { + const environment = fapiEnvironment(); + environment.user_settings.attributes.authenticator_app.enabled = true; + environment.user_settings.attributes.authenticator_app.used_for_second_factor = true; + environment.user_settings.attributes.authenticator_app.second_factors = ['totp']; + environment.user_settings.attributes.phone_number.enabled = true; + environment.user_settings.attributes.phone_number.used_for_second_factor = true; + environment.user_settings.attributes.phone_number.second_factors = ['phone_code']; + environment.user_settings.attributes.backup_code.enabled = true; + environment.user_settings.attributes.backup_code.used_for_second_factor = true; + environment.user_settings.attributes.backup_code.second_factors = ['backup_code']; + environment.user_settings.sign_in.second_factor.enabled = true; + return environment; +} + +export async function renderMfa( + user = fapiUser({ id: 'user_1', phone_numbers: [phone] }), + environment = mfaEnvironment(), +) { + const fapi = serveFapi({ environment, client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + await renderWithClerk(); + return fapi; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-interactions.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-interactions.feature.test.tsx new file mode 100644 index 00000000000..52460d42121 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-interactions.feature.test.tsx @@ -0,0 +1,223 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it, vi } from 'vitest'; + +import { fapiUrl, holdRequests, serveFapi, worker } from '../../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiSession, fapiUser } from '../../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { UserProfileMfaSection } from '../user-profile-mfa-section'; +import { mfaEnvironment, renderMfa } from './mfa-feature-setup'; + +describe('User profile MFA interaction boundaries', () => { + it('restores Add focus after cancelling the method picker', async () => { + await renderMfa(); + const user = userEvent.setup(); + const add = screen.getByRole('button', { name: 'Add verification method' }); + await user.click(add); + expect(screen.getByRole('dialog', { name: 'Add 2-step verification' })).toHaveAccessibleDescription( + 'Choose a verification method', + ); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(add).toHaveFocus(); + await user.click(add); + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(add).toHaveFocus(); + }); + + it('retries failed authenticator preparation and preserves its manual secret and entered code', async () => { + const fapi = await renderMfa(); + let requests = 0; + worker.use( + http.post(fapiUrl('/v1/me/totp'), () => { + requests += 1; + return requests === 1 + ? HttpResponse.json( + { errors: [{ code: 'service_unavailable', message: 'Please try again.' }] }, + { status: 503 }, + ) + : undefined; + }), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Please try again.')); + const retry = screen.getByRole('button', { name: 'Try again' }); + await waitFor(() => expect(retry).toHaveFocus()); + await user.click(retry); + expect(await screen.findByRole('img', { name: /QR code/i })).toBeVisible(); + const code = screen.getByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(code).toHaveFocus()); + await user.type(code, '123'); + const form = code.closest('form'); + if (!form) { + throw new Error('Verification form missing'); + } + form.requestSubmit(); + expect(fapi.mfa.totpAttempts).toHaveLength(0); + await user.click(screen.getByRole('button', { name: 'Can’t scan? View setup key' })); + const secret = screen.getByRole('textbox', { name: 'Setup key' }); + const uri = screen.getByRole('textbox', { name: 'Setup URI' }); + expect(secret).toHaveAttribute('readonly'); + expect(uri).toHaveAttribute('readonly'); + const originalSecret = secret.getAttribute('value'); + await user.click(screen.getByRole('button', { name: 'Scan QR code instead' })); + expect(screen.getByRole('img', { name: /QR code/i })).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Can’t scan? View setup key' })); + expect(screen.getByRole('textbox', { name: 'Setup key' })).toHaveAttribute('value', originalSecret); + expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveValue('1'); + expect(screen.getByRole('textbox', { name: 'Character 2 of 6' })).toHaveValue('2'); + expect(screen.getByRole('textbox', { name: 'Character 3 of 6' })).toHaveValue('3'); + expect(fapi.mfa.totpCreations).toBe(1); + }); + + it('disables Back while authenticator preparation is pending', async () => { + await renderMfa(); + const held = holdRequests('post', '/v1/me/totp'); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await waitFor(() => expect(held.requests).toHaveLength(1)); + try { + await waitFor(() => { + expect(screen.getByRole('status', { name: 'Preparing authenticator…' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); + }); + } finally { + held.release(); + } + expect(await screen.findByRole('img', { name: /QR code/i })).toBeVisible(); + }); + + it('keeps the dialog closed when dismissed while authenticator preparation is pending', async () => { + const fapi = await renderMfa(); + const held = holdRequests('post', '/v1/me/totp'); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await waitFor(() => expect(held.requests).toHaveLength(1)); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + held.release(); + await waitFor(() => expect(fapi.mfa.totpCreations).toBe(1)); + expect(screen.queryByRole('dialog')).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + expect(await screen.findByRole('dialog', { name: 'Add 2-step verification' })).toHaveAccessibleDescription( + 'Choose a verification method', + ); + }); + + it('shows backup codes when dismissed while authenticator verification is pending', async () => { + const fapi = await renderMfa(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + const code = await screen.findByRole('textbox', { name: 'Verification code' }); + const held = holdRequests('post', '/v1/me/totp/attempt_verification'); + await user.type(code, '123456'); + await waitFor(() => expect(held.requests).toHaveLength(1)); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + held.release(); + await waitFor(() => expect(screen.getByText('CODE0001')).toBeVisible()); + expect(fapi.mfa.totpAttempts).toEqual(['123456']); + }); + + it('focuses SMS selection and new phone entry', async () => { + await renderMfa(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + const selection = screen.getByRole('combobox', { name: /Phone number/ }); + await waitFor(() => expect(selection).toHaveFocus()); + await user.click(screen.getByRole('button', { name: 'Add a new phone number' })); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Phone' })).toHaveFocus()); + }); + + it('blocks duplicate authenticator verification through the native form while pending', async () => { + const fapi = await renderMfa(); + const held = holdRequests('post', '/v1/me/totp/attempt_verification'); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + const code = await screen.findByRole('textbox', { name: 'Verification code' }); + await user.type(code, '123456'); + await waitFor(() => expect(held.requests).toHaveLength(1)); + try { + await waitFor(() => expect(screen.getByRole('progressbar', { name: 'Verifying code' })).toBeVisible()); + expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); + const form = code.closest('form'); + if (!form) { + throw new Error('Verification form missing'); + } + form.requestSubmit(); + expect(held.requests).toHaveLength(1); + expect(fapi.mfa.totpAttempts).toHaveLength(0); + } finally { + held.release(); + } + await waitFor(() => expect(fapi.mfa.totpAttempts).toEqual(['123456'])); + }); + + it('retries failed backup-code generation without exposing an empty save action', async () => { + serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', totp_enabled: true, two_factor_enabled: true }) }), + ]), + }); + await renderWithClerk(); + let requests = 0; + worker.use( + http.post(fapiUrl('/v1/me/backup_codes/'), () => { + requests += 1; + return requests === 1 + ? HttpResponse.json( + { errors: [{ code: 'service_unavailable', message: 'Please try again.' }] }, + { status: 503 }, + ) + : undefined; + }), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Backup codes/ })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Please try again.')); + expect(screen.queryByRole('button', { name: 'Copy and close' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Download' })).toBeNull(); + const retry = screen.getByRole('button', { name: 'Try again' }); + await waitFor(() => expect(retry).toHaveFocus()); + await user.click(retry); + expect(await screen.findByText('CODE0100')).toBeVisible(); + }); + + it('keeps issued codes available after copy fails and lets the user retry', async () => { + await renderMfa(); + const user = userEvent.setup(); + const write = vi + .spyOn(navigator.clipboard, 'writeText') + .mockRejectedValueOnce(new Error('Clipboard unavailable')) + .mockResolvedValue(); + try { + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + const codes = await screen.findByRole('list', { name: 'Backup codes' }); + expect(within(codes).getByText('CODE0001')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Copy and close' })); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong. Please try again.'), + ); + expect(screen.queryByText('Clipboard unavailable')).toBeNull(); + expect(within(codes).getByText('CODE0001')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Copy and close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(write).toHaveBeenCalledTimes(2); + } finally { + write.mockRestore(); + } + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx new file mode 100644 index 00000000000..f9cc227f9b9 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx @@ -0,0 +1,564 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it, vi } from 'vitest'; + +import { clerkApiError } from '../../../../__tests__/clerk-errors'; +import { fapiUrl, holdRequests, serveFapi, worker } from '../../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiPhoneNumber, fapiSession, fapiUser } from '../../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { MosaicProvider } from '../../../../mosaic-provider'; +import { UserProfileMfaSection } from '../user-profile-mfa-section'; +import { mfaEnvironment, phone, renderMfa } from './mfa-feature-setup'; + +describe('User profile MFA management', () => { + it('updates an existing setup error when localization changes', async () => { + serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + const section = (message: string) => ( + + + + ); + const { rerender } = await renderWithClerk(section('First localized error')); + worker.use( + http.post(fapiUrl('/v1/me/totp'), () => + HttpResponse.json({ errors: [{ code: 'action_blocked', message: 'Server detail' }] }, { status: 403 }), + ), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('First localized error')); + rerender(section('Updated localized error')); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Updated localized error')); + }); + + it.each([ + { + cause: clerkApiError('phone_number_not_verified', 'Unable to update the default method.'), + message: 'Unable to update the default method.', + }, + { + cause: new Error('Cannot read properties of undefined'), + message: 'Unable to set this method as default. Please try again.', + }, + { cause: 'network failure', message: 'Unable to set this method as default. Please try again.' }, + ])('shows a safe default-change error for $message', async ({ cause, message }) => { + const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const second = fapiPhoneNumber({ + ...phone, + id: 'phone_2', + phone_number: '+15555550202', + reserved_for_second_factor: true, + }); + serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', phone_numbers: [first, second] }) }), + ]), + }); + const { clerk } = await renderWithClerk(); + const selectedPhone = clerk.user?.phoneNumbers.find(item => item.id === 'phone_2'); + if (!selectedPhone) { + throw new Error('Expected the second phone'); + } + const changeDefault = vi.spyOn(selectedPhone, 'makeDefaultSecondFactor').mockRejectedValue(cause); + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0202' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(message)); + } finally { + changeDefault.mockRestore(); + log.mockRestore(); + } + }); + + it('shows a safe message when authenticator setup throws an unexpected error', async () => { + serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + const { clerk } = await renderWithClerk(); + if (!clerk.user) { + throw new Error('Expected the signed-in user'); + } + const create = vi.spyOn(clerk.user, 'createTOTP').mockRejectedValue(new Error('Internal authenticator failure')); + try { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong. Please try again.'), + ); + expect(screen.queryByText('Internal authenticator failure')).toBeNull(); + } finally { + create.mockRestore(); + } + }); + + it('keeps localized Clerk errors when removing an authenticator fails', async () => { + serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', totp_enabled: true, two_factor_enabled: true }) }), + ]), + }); + await renderWithClerk(, undefined, { + overrides: { 'errors.action_blocked': 'This authenticator must remain enabled.' }, + }); + worker.use( + http.post(fapiUrl('/v1/me/totp'), ({ request }) => + new URL(request.url).searchParams.get('_method') === 'DELETE' + ? HttpResponse.json({ errors: [{ code: 'action_blocked', message: 'Action blocked' }] }, { status: 403 }) + : undefined, + ), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage Authenticator app' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('This authenticator must remain enabled.')); + expect(screen.getByText('Authenticator app')).toBeVisible(); + }); + + it('removes SMS MFA while retaining the phone number', async () => { + const reserved = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const fapi = await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [reserved], two_factor_enabled: true })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })); + 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 (555) 555-0101. The phone number will remain on your account.', + ); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByText('+1 (555) 555-0101')).toBeNull()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Add verification method' })).toHaveFocus()); + expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_1', reserved: false, default: undefined }); + expect(fapi.client.sessions[0]?.user.phone_numbers).toHaveLength(1); + }); + + it('keeps removal pending through dismissal and duplicate confirmation attempts', async () => { + const reserved = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const fapi = await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [reserved], two_factor_enabled: true })); + const held = holdRequests('post', '/v1/me/phone_numbers/phone_1'); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); + const dialog = screen.getByRole('alertdialog'); + const remove = within(dialog).getByRole('button', { name: 'Remove' }); + await user.click(remove); + await waitFor(() => expect(held.requests).toHaveLength(1)); + try { + await user.keyboard('{Escape}'); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + expect(dialog).toBeVisible(); + remove.click(); + expect(held.requests).toHaveLength(1); + expect(fapi.mfa.phoneUpdates).toHaveLength(0); + } finally { + held.release(); + } + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(fapi.mfa.phoneUpdates).toHaveLength(1); + await waitFor(() => expect(screen.getByRole('button', { name: 'Add verification method' })).toHaveFocus()); + }); + + it('restores the selected SMS row action after cancelling removal confirmation', async () => { + const reserved = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [reserved], two_factor_enabled: true })); + const user = userEvent.setup(); + const action = screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' }); + await user.click(action); + await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(action).toHaveFocus(); + }); + + it('does not show a late authenticator setup response in another account', async () => { + const fapi = serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), + ]), + }); + const { clerk } = await renderWithClerk(); + let release = () => undefined; + const gate = new Promise(resolve => { + release = resolve; + }); + let started = false; + worker.use( + http.post(fapiUrl('/v1/me/totp'), async () => { + const initiatingClient = structuredClone(fapi.client); + const initiatingSession = initiatingClient.sessions.find(item => item.id === 'sess_1'); + if (initiatingSession?.user) { + initiatingSession.user.first_name = 'LateResponseConsumed'; + } + started = true; + await gate; + return HttpResponse.json({ + response: { + object: 'totp', + id: 'totp_held', + secret: 'FIRSTACCOUNT', + uri: 'otpauth://totp/Acme:first?secret=FIRSTACCOUNT', + verified: false, + created_at: Date.now(), + updated_at: Date.now(), + }, + client: initiatingClient, + }); + }), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await waitFor(() => expect(started).toBe(true)); + try { + await clerk.setActive({ session: 'sess_2' }); + await waitFor(() => expect(fapi.client.last_active_session_id).toBe('sess_2')); + } finally { + release(); + } + await waitFor(() => + expect(clerk.client?.sessions.find(item => item.id === 'sess_1')?.user?.firstName).toBe('LateResponseConsumed'), + ); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(screen.queryByDisplayValue('FIRSTACCOUNT')).toBeNull(); + expect(fapi.mfa.totpCreations).toBe(0); + expect(clerk.session?.id).toBe('sess_2'); + expect(clerk.user?.id).toBe('user_2'); + }); + + it('opens the backup-code surface while regeneration is pending', async () => { + await renderMfa( + fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), + ); + const held = holdRequests('post', '/v1/me/backup_codes/'); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + await waitFor(() => expect(held.requests).toHaveLength(1)); + await waitFor(() => expect(screen.getByRole('status', { name: 'Generating backup codes' })).toBeVisible()); + for (const name of ['Print', 'Download']) { + expect(screen.getByRole('button', { name })).toBeVisible(); + expect(screen.getByRole('button', { name })).toBeDisabled(); + } + expect(screen.getByRole('button', { name: 'Copy and close' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Copy and close' })).toHaveAttribute('aria-disabled', 'true'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus()); + held.release(); + await waitFor(() => expect(screen.getByText('CODE0100')).toBeVisible()); + for (const name of ['Print', 'Download', 'Copy and close']) { + expect(screen.getByRole('button', { name })).toBeEnabled(); + } + }); + + it('uses fresh server codes for explicit regeneration', async () => { + const fapi = await renderMfa( + fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + const list = await screen.findByRole('list', { name: 'Backup codes' }); + expect(within(list).getAllByRole('listitem')).toHaveLength(10); + expect(within(list).queryByText('CODE0001')).toBeNull(); + expect(fapi.mfa.backupCodeCreations).toBe(1); + }); + + it('copies server-issued backup codes and closes their one-time surface', async () => { + const fapi = await renderMfa(); + const write = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + try { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + await waitFor(() => expect(screen.getByText('CODE0001')).toBeVisible()); + await user.click(screen.getByRole('button', { name: 'Copy and close' })); + await waitFor(() => expect(write).toHaveBeenCalledWith(fapi.mfa.codes.join('\n'))); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + } finally { + write.mockRestore(); + } + }); + + it('downloads the displayed backup codes in a file named after the application', async () => { + const fapi = await renderMfa(fapiUser({ id: 'user_1', username: 'alice', phone_numbers: [phone] })); + const create = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:backup-codes'); + const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); + try { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + await waitFor(() => expect(screen.getByText('CODE0001')).toBeVisible()); + await user.click(screen.getByRole('button', { name: 'Download' })); + expect(create).toHaveBeenCalledOnce(); + const saved = create.mock.calls[0]?.[0]; + if (!(saved instanceof Blob)) { + throw new Error('Expected the displayed backup codes to be saved in a blob'); + } + expect(await saved.text()).toBe( + [ + 'These are your backup codes for Acme account alice.', + 'Store them securely and keep them secret. Each code can only be used once.', + '', + ...fapi.mfa.codes, + ].join('\n'), + ); + expect(click).toHaveBeenCalledOnce(); + expect(click.mock.instances[0]?.download).toBe('Acme_backup_codes.txt'); + expect(revoke).toHaveBeenCalledWith('blob:backup-codes'); + expect(screen.getByText('CODE0001')).toBeVisible(); + } finally { + create.mockRestore(); + revoke.mockRestore(); + click.mockRestore(); + } + }); + + it('keeps newly issued authenticator codes when the follow-up account reload fails', async () => { + const fapi = await renderMfa(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await screen.findByRole('textbox', { name: 'Verification code' }); + worker.use( + http.get(fapiUrl('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/v1/me'), () => + HttpResponse.json({ errors: [{ code: 'unexpected_error', message: 'Reload failed' }] }, { status: 503 }), + ), + ); + await user.type(screen.getByRole('textbox', { name: 'Verification code' }), '123456'); + expect(await screen.findByText('CODE0001')).toBeVisible(); + expect(fapi.mfa.totpAttempts).toEqual(['123456']); + }); + + it('keeps regenerated codes when the follow-up account reload fails', async () => { + const fapi = await renderMfa( + fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), + ); + worker.use( + http.get(fapiUrl('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/v1/me'), () => + HttpResponse.json({ errors: [{ code: 'unexpected_error', message: 'Reload failed' }] }, { status: 503 }), + ), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + await waitFor(() => expect(screen.getByText('CODE0100')).toBeVisible()); + expect(fapi.mfa.backupCodeCreations).toBe(1); + }); + + it('does not expose replacement codes when another factor is enrolled with existing codes', async () => { + const reserved = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const fapi = await renderMfa( + fapiUser({ id: 'user_1', phone_numbers: [reserved], two_factor_enabled: true, backup_code_enabled: true }), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + await waitFor(() => expect(fapi.mfa.totpAttempts).toEqual(['123456'])); + expect(screen.queryByRole('list', { name: 'Backup codes' })).toBeNull(); + expect(fapi.client.sessions[0]?.user.backup_code_enabled).toBe(true); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + }); + + it('sets a different SMS phone as default and moves its row first', async () => { + const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const second = fapiPhoneNumber({ + ...phone, + id: 'phone_2', + phone_number: '+15555550202', + reserved_for_second_factor: true, + }); + const fapi = await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [first, second], two_factor_enabled: true })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0202' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + await waitFor(() => + expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_2', default: true, reserved: undefined }), + ); + await waitFor(() => + expect(screen.getAllByText('SMS verification')[0]?.closest('li')).toHaveTextContent('+1 (555) 555-0202'), + ); + expect(screen.getAllByText('SMS verification')[0]?.closest('li')).toHaveTextContent('Default'); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })); + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as default' })).toBeVisible()); + }); + + it('shows a failed default change and clears its error on retry', async () => { + const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const second = fapiPhoneNumber({ + ...phone, + id: 'phone_2', + phone_number: '+15555550202', + reserved_for_second_factor: true, + }); + const fapi = await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [first, second], two_factor_enabled: true })); + let attempts = 0; + worker.use( + http.post(fapiUrl('/v1/me/phone_numbers/phone_2'), ({ request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'PATCH') { + return undefined; + } + attempts += 1; + return attempts === 1 + ? HttpResponse.json( + { errors: [{ code: 'unexpected_error', message: 'Unable to update default method.' }] }, + { status: 503 }, + ) + : undefined; + }), + ); + const user = userEvent.setup(); + const selected = screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0202' }); + + await user.click(selected); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Unable to update default method.')); + expect(screen.getAllByText('Default')).toHaveLength(1); + + await user.click(selected); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + await waitFor(() => + expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_2', default: true, reserved: undefined }), + ); + expect(attempts).toBe(2); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })); + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as default' })).toBeVisible()); + }); + + it.each([ + { phase: 'mutation', method: 'post', path: '/v1/me/phone_numbers/phone_2', pendingPhone: '+1 (555) 555-0202' }, + { phase: 'refresh', method: 'get', path: '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/v1/me', pendingPhone: '+1 (555) 555-0101' }, + ] as const)( + 'keeps method menus available but hides default changes during $phase', + async ({ method, path, pendingPhone }) => { + const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const second = fapiPhoneNumber({ + ...phone, + id: 'phone_2', + phone_number: '+15555550202', + reserved_for_second_factor: true, + }); + await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [first, second], two_factor_enabled: true })); + const held = holdRequests(method, path); + const user = userEvent.setup(); + const selected = screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0202' }); + await user.click(selected); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + await waitFor(() => expect(held.requests).toHaveLength(1)); + try { + const pendingAction = screen.getByRole('button', { name: `Manage SMS verification ${pendingPhone}` }); + await user.click(pendingAction); + if (pendingAction.getAttribute('aria-expanded') !== 'true') { + await user.click(pendingAction); + } + expect(screen.queryByRole('menuitem', { name: 'Set as default' })).toBeNull(); + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Remove method' })).toBeVisible()); + await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); + expect(screen.queryByRole('alertdialog')).toBeNull(); + } finally { + held.release(); + } + await waitFor(() => expect(screen.getByText('+1 (555) 555-0202').closest('li')).toHaveTextContent('Default')); + await user.keyboard('{Escape}'); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })); + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as default' })).toBeVisible()); + }, + ); + + it('clears backup-code eligibility when the final optional factor is removed', async () => { + const fapi = await renderMfa( + fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage Authenticator app' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(fapi.mfa.totpRemovals).toBe(1)); + expect(fapi.client.sessions[0]?.user.backup_code_enabled).toBe(false); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + }); + + it('finishes removal when the follow-up account reload fails', async () => { + const fapi = await renderMfa( + fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage Authenticator app' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); + worker.use( + http.get(fapiUrl('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/v1/me'), () => + HttpResponse.json({ errors: [{ code: 'unexpected_error', message: 'Reload failed' }] }, { status: 503 }), + ), + ); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(fapi.mfa.totpRemovals).toBe(1)); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + }); + + it.each([ + { message: 'Printing is unavailable in this browser.', overrides: {} }, + { + message: 'Allow popups to print these codes.', + overrides: { 'errors.mfa_print_unavailable': 'Allow popups to print these codes.' }, + }, + ])('localizes printing failures as $message', async ({ message, overrides }) => { + serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk(, undefined, { overrides }); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + expect(await screen.findByText('CODE0001')).toBeVisible(); + const open = vi.spyOn(window, 'open').mockReturnValue(null); + await user.click(screen.getByRole('button', { name: 'Print' })); + expect(open).toHaveBeenCalledOnce(); + expect(screen.getByRole('alert')).toHaveTextContent(message); + open.mockRestore(); + }); + + it('prints the codes under a heading naming the application and account', async () => { + const fapi = await renderMfa(fapiUser({ id: 'user_1', username: 'alice', phone_numbers: [phone] })); + const printable = { + document: document.implementation.createHTMLDocument(), + addEventListener: vi.fn(), + setTimeout: vi.fn(), + focus: vi.fn(), + print: vi.fn(), + close: vi.fn(), + }; + const open = vi.spyOn(window, 'open').mockReturnValue(printable as unknown as Window); + try { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + expect(await screen.findByText('CODE0001')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Print' })); + expect(printable.print).toHaveBeenCalledOnce(); + expect(printable.document.title).toBe('Your backup codes for Acme account alice'); + expect(printable.document.querySelector('h1')?.textContent).toBe('Your backup codes for Acme account alice'); + expect(printable.document.querySelector('pre')?.textContent).toBe(fapi.mfa.codes.join('\n')); + expect(screen.getByRole('alert').textContent).toBe(''); + } finally { + open.mockRestore(); + } + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa.feature.test.tsx new file mode 100644 index 00000000000..b1d31c0c143 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa.feature.test.tsx @@ -0,0 +1,463 @@ +import { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it, vi } from 'vitest'; + +import { fapiUrl, holdRequests, serveFapi, VERIFICATION_CODE, worker } from '../../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEnvironment, + fapiPhoneNumber, + fapiSession, + fapiUser, +} from '../../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { UserProfileMfaSection } from '../user-profile-mfa-section'; +import { mfaEnvironment, phone, renderMfa } from './mfa-feature-setup'; + +describe('User profile MFA', () => { + it('hides when no second factor strategy is enabled', async () => { + await renderMfa(fapiUser({ id: 'user_1' }), fapiEnvironment()); + expect(screen.queryByText('2-step verification')).toBeNull(); + }); + + it('offers configured methods and creates one authenticator secret', async () => { + await renderMfa(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await waitFor(() => expect(screen.getByRole('button', { name: /Authenticator app/ })).toBeVisible()); + expect(screen.getByRole('button', { name: /SMS verification/ })).toBeVisible(); + expect(screen.queryByRole('button', { name: /Backup codes/ })).toBeNull(); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + expect(await screen.findByRole('img', { name: /QR code/i })).toBeVisible(); + }); + + it('shows enrolled methods with the default SMS phone first', async () => { + const second = fapiPhoneNumber({ + ...phone, + id: 'phone_2', + phone_number: '+15555550202', + reserved_for_second_factor: true, + default_second_factor: true, + }); + const first = fapiPhoneNumber({ + ...phone, + reserved_for_second_factor: true, + }); + await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [first, second], two_factor_enabled: true })); + const rows = screen.getAllByText('SMS verification'); + expect(rows).toHaveLength(2); + expect(rows[0]?.closest('li')).toHaveTextContent('+1 (555) 555-0202'); + expect(rows[0]?.closest('li')).toHaveTextContent('Default'); + expect(rows[1]?.closest('li')).toHaveTextContent('+1 (555) 555-0101'); + expect(rows[1]?.closest('li')).not.toHaveTextContent('Default'); + expect(screen.getByText('+1 (555) 555-0202')).toBeVisible(); + expect(screen.getByText('Default')).toBeVisible(); + }); + + it('hides set-as-default while an authenticator is enrolled', async () => { + const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const second = fapiPhoneNumber({ + ...phone, + id: 'phone_2', + phone_number: '+15555550202', + reserved_for_second_factor: true, + }); + await renderMfa( + fapiUser({ id: 'user_1', phone_numbers: [first, second], totp_enabled: true, two_factor_enabled: true }), + ); + expect(screen.getByText('Authenticator app').closest('li')).toHaveTextContent('Default'); + expect(screen.getAllByText('Default')).toHaveLength(1); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0202' })); + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Remove method' })).toBeVisible()); + expect(screen.queryByRole('menuitem', { name: 'Set as default' })).toBeNull(); + }); + + it('verifies a new authenticator and shows server-issued backup codes', async () => { + const fapi = await renderMfa(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + expect(await screen.findByText('CODE0001')).toBeVisible(); + expect(within(screen.getByRole('list', { name: 'Backup codes' })).getAllByRole('listitem')).toHaveLength(10); + expect(fapi.mfa.totpCreations).toBe(1); + expect(fapi.mfa.totpAttempts).toEqual(['123456']); + expect(fapi.mfa.backupCodeCreations).toBe(0); + }); + + it('keeps the same authenticator secret after switching back to the method picker', async () => { + const fapi = await renderMfa(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await user.click(await screen.findByRole('button', { name: 'Can’t scan? View setup key' })); + expect(screen.getByDisplayValue('SECRET1')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Scan QR code instead' })); + expect(screen.getByRole('img', { name: /QR code/i })).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Back' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + expect(await screen.findByRole('img', { name: /QR code/i })).toBeVisible(); + expect(fapi.mfa.totpCreations).toBe(1); + }); + + it('reserves an existing verified phone without creating another one', async () => { + const fapi = await renderMfa(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + await user.click(screen.getByRole('button', { name: 'Continue' })); + expect(await screen.findByText('CODE0001')).toBeVisible(); + expect(fapi.mfa.phoneCreations).toEqual([]); + expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_1', reserved: true, default: undefined }); + }); + + it('shows the backend error when the last login identifier cannot be reserved', async () => { + const fapi = await renderMfa(); + worker.use( + http.post(fapiUrl('/v1/me/phone_numbers/phone_1'), ({ request }) => + new URL(request.url).searchParams.get('_method') === 'PATCH' + ? HttpResponse.json( + { + errors: [ + { + code: 'identification_update_failed', + message: 'Update failed', + long_message: 'You cannot set your last identification as second factor.', + }, + ], + }, + { status: 400 }, + ) + : undefined, + ), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => + expect(screen.getByText('You cannot set your last identification as second factor.')).toBeVisible(), + ); + expect(screen.getByRole('combobox', { name: /Phone number/ })).toHaveAccessibleDescription( + 'You cannot set your last identification as second factor.', + ); + expect(fapi.mfa.phoneUpdates).toHaveLength(0); + expect(screen.getByRole('button', { name: 'Back' })).toBeVisible(); + }); + + it('protects the last required factor from removal', async () => { + const environment = mfaEnvironment(); + environment.user_settings.sign_up.mfa = { required: true }; + await renderMfa( + fapiUser({ id: 'user_1', totp_enabled: true, two_factor_enabled: true, backup_code_enabled: true }), + environment, + ); + expect(screen.getByText('Authenticator app')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Manage Authenticator app' })).toBeNull(); + }); + + it('hides removal of the last configured SMS factor when TOTP is disabled', async () => { + const environment = mfaEnvironment(); + environment.user_settings.sign_up.mfa = { required: true }; + environment.user_settings.attributes.authenticator_app.used_for_second_factor = false; + const reserved = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + await renderMfa( + fapiUser({ id: 'user_1', phone_numbers: [reserved], totp_enabled: true, two_factor_enabled: true }), + environment, + ); + expect(screen.getByText('SMS verification')).toBeVisible(); + expect(screen.queryByText('Authenticator app')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })).toBeNull(); + }); + + it('keeps backup codes visible when TOTP was the only addable method', async () => { + const environment = mfaEnvironment(); + environment.user_settings.attributes.phone_number.used_for_second_factor = false; + const fapi = await renderMfa(fapiUser({ id: 'user_1' }), environment); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + expect(await screen.findByText('CODE0001')).toBeVisible(); + expect(fapi.mfa.backupCodeCreations).toBe(0); + }); + + it('shows a localized incorrect TOTP code and allows a retry with the same secret', async () => { + const fapi = await renderMfa(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + const code = await screen.findByRole('textbox', { name: 'Verification code' }); + await user.type(code, '000000'); + await waitFor(() => expect(screen.getByText('Incorrect code')).toBeVisible()); + expect(fapi.mfa.totpCreations).toBe(1); + await user.clear(code); + await user.type(code, '123456'); + expect(await screen.findByText('CODE0001')).toBeVisible(); + expect(fapi.mfa.totpCreations).toBe(1); + }); + + it('uses a supplied non-English catalog for coded TOTP errors', async () => { + serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk(, undefined, { + locale: 'es', + messages: { errors: { form_code_incorrect: 'El código no es correcto.' } }, + }); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '000000'); + await waitFor(() => expect(screen.getByText('El código no es correcto.')).toBeVisible()); + }); + + it('verifies an unverified existing SMS phone before reserving it', async () => { + const unverified = fapiPhoneNumber({ ...phone, verification: { ...phone.verification, status: 'unverified' } }); + const fapi = await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [unverified] })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + await user.click(screen.getByRole('button', { name: 'Continue' })); + expect(await screen.findByRole('textbox', { name: 'Verification code' })).toBeVisible(); + expect(fapi.mfa.phonePreparations).toEqual(['phone_1']); + expect(fapi.mfa.phoneUpdates).toEqual([]); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), VERIFICATION_CODE); + expect(await screen.findByText('CODE0001')).toBeVisible(); + expect(fapi.mfa.phoneAttempts).toEqual([{ id: 'phone_1', code: VERIFICATION_CODE }]); + expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_1', reserved: true, default: undefined }); + }); + + it('submits the entered SMS code through the Verify form', async () => { + const unverified = fapiPhoneNumber({ ...phone, verification: { ...phone.verification, status: 'unverified' } }); + const fapi = await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [unverified] })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + await user.click(screen.getByRole('button', { name: 'Continue' })); + const code = await screen.findByRole('textbox', { name: 'Verification code' }); + await user.type(code, '000000'); + await waitFor(() => expect(fapi.mfa.phoneAttempts).toHaveLength(1)); + await user.click(screen.getByRole('button', { name: 'Verify' })); + await waitFor(() => + expect(fapi.mfa.phoneAttempts).toEqual([ + { id: 'phone_1', code: '000000' }, + { id: 'phone_1', code: '000000' }, + ]), + ); + }); + + it('retries SMS reservation without re-verifying a phone whose code already succeeded', async () => { + const unverified = fapiPhoneNumber({ ...phone, verification: { ...phone.verification, status: 'unverified' } }); + const fapi = await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [unverified] })); + let reservations = 0; + worker.use( + http.post(fapiUrl('/v1/me/phone_numbers/phone_1'), ({ request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'PATCH') { + return undefined; + } + reservations += 1; + return reservations === 1 + ? HttpResponse.json( + { + errors: [ + { code: 'unexpected_error', message: 'Reservation failed', long_message: 'Reservation failed' }, + ], + }, + { status: 503 }, + ) + : undefined; + }), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), VERIFICATION_CODE); + await waitFor(() => expect(screen.getByText('Reservation failed')).toBeVisible()); + expect(fapi.mfa.phoneAttempts).toEqual([{ id: 'phone_1', code: VERIFICATION_CODE }]); + await user.click(screen.getByRole('button', { name: 'Verify' })); + await waitFor(() => expect(screen.getByText('CODE0001')).toBeVisible()); + expect(reservations).toBe(2); + expect(fapi.mfa.phoneAttempts).toHaveLength(1); + }); + + it('starts a resend cooldown after sending an SMS verification code', async () => { + const unverified = fapiPhoneNumber({ ...phone, verification: { ...phone.verification, status: 'unverified' } }); + const fapi = await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [unverified] })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await screen.findByRole('textbox', { name: 'Verification code' }); + expect(screen.getByRole('button', { name: /Resend/ })).toBeDisabled(); + expect(fapi.mfa.phonePreparations).toEqual(['phone_1']); + }); + + it('disables verification and Back while resending an SMS code', async () => { + const unverified = fapiPhoneNumber({ ...phone, verification: { ...phone.verification, status: 'unverified' } }); + vi.useFakeTimers({ shouldAdvanceTime: true }); + await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [unverified] })); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + await user.click(screen.getByRole('button', { name: 'Continue' })); + const code = await screen.findByRole('textbox', { name: 'Verification code' }); + const held = holdRequests('post', '/v1/me/phone_numbers/phone_1/prepare_verification'); + try { + for (let second = 0; second < 30; second++) { + await act(async () => vi.advanceTimersByTimeAsync(1000)); + } + await waitFor(() => expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(), { timeout: 2000 }); + await user.click(screen.getByRole('button', { name: /Resend/ })); + await waitFor(() => expect(held.requests).toHaveLength(1)); + expect(code).toHaveAttribute('aria-disabled', 'true'); + expect(code).toHaveAttribute('readonly'); + expect(screen.getByRole('button', { name: 'Verify' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); + } finally { + held.release(); + vi.useRealTimers(); + } + await waitFor(() => expect(screen.getByRole('button', { name: 'Verify' })).toBeEnabled()); + expect(code).not.toHaveAttribute('aria-disabled'); + expect(code).not.toHaveAttribute('readonly'); + }); + + it('keeps a newly created phone when preparing its code fails and retries without duplication', async () => { + const fapi = await renderMfa(fapiUser({ id: 'user_1' })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + await user.type(screen.getByRole('textbox', { name: 'Phone' }), '5555550303'); + const prepare = holdRequests('post', '/v1/me/phone_numbers/phone_1/prepare_verification'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + prepare.fail('phone_number_invalid', 'Unable to send a code.'); + await waitFor(() => expect(screen.getByText('Unable to send a code.')).toBeVisible()); + expect(fapi.mfa.phoneCreations).toHaveLength(1); + serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Send code' })); + await waitFor(() => expect(fapi.mfa.phonePreparations).toHaveLength(1)); + expect(fapi.mfa.phoneCreations).toHaveLength(1); + }); + + it('uses a newly selected phone after going back from SMS verification', async () => { + const first = fapiPhoneNumber({ ...phone, verification: { ...phone.verification, status: 'unverified' } }); + const second = fapiPhoneNumber({ + id: 'phone_2', + phone_number: '+15555550202', + verification: { ...phone.verification, status: 'unverified' }, + }); + const fapi = await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [first, second] })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await screen.findByRole('textbox', { name: 'Verification code' }); + await user.click(screen.getByRole('button', { name: 'Back' })); + await user.click(screen.getByRole('combobox', { name: /Phone number/ })); + await user.click(screen.getAllByRole('option')[1]); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(fapi.mfa.phonePreparations).toEqual(['phone_1', 'phone_2'])); + }); + + it('returns to the entered number after backing out of a new phone verification', async () => { + const fapi = await renderMfa(fapiUser({ id: 'user_1' })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + await user.type(screen.getByRole('textbox', { name: 'Phone' }), '5555550303'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + await screen.findByRole('textbox', { name: 'Verification code' }); + await user.click(screen.getByRole('button', { name: 'Back' })); + expect(screen.getByRole('textbox', { name: 'Phone' })).toHaveValue('(555) 555-0303'); + expect(fapi.mfa.phoneCreations).toHaveLength(1); + }); + + it('creates a new resource when the entered number changes after a prepare failure', async () => { + const fapi = await renderMfa(fapiUser({ id: 'user_1' })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + await user.type(screen.getByRole('textbox', { name: 'Phone' }), '5555550303'); + const prepare = holdRequests('post', '/v1/me/phone_numbers/phone_1/prepare_verification'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + prepare.fail('phone_number_invalid', 'Unable to send a code.'); + await waitFor(() => expect(screen.getByText('Unable to send a code.')).toBeVisible()); + serveFapi(fapi); + const number = screen.getByRole('textbox', { name: 'Phone' }); + await user.clear(number); + await user.type(number, '5555550404'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + await waitFor(() => expect(fapi.mfa.phoneCreations).toHaveLength(2)); + expect(await screen.findByRole('textbox', { name: 'Verification code' })).toBeVisible(); + expect(fapi.mfa.phoneCreations).toEqual(['+15555550303', '+15555550404']); + }); + + it.each([ + { country: 'United States', input: '+15555550303', changed: '+15555550404', canonical: '+15555550303' }, + { country: 'United Kingdom', input: '+447400123456', changed: '+447400123457', canonical: '+447400123456' }, + ])( + 'reuses the original $country phone after editing and restoring a failed SMS setup', + async ({ input, changed, canonical }) => { + const fapi = await renderMfa(fapiUser({ id: 'user_1' })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + const number = screen.getByRole('textbox', { name: 'Phone' }); + await user.click(number); + await user.paste(input); + const prepare = holdRequests('post', '/v1/me/phone_numbers/phone_1/prepare_verification'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + prepare.fail('phone_number_invalid', 'Unable to send a code.'); + await waitFor(() => expect(screen.getByText('Unable to send a code.')).toBeVisible()); + serveFapi(fapi); + await user.clear(number); + await user.paste(changed); + await user.clear(number); + await user.click(number); + await user.paste(input); + await user.click(screen.getByRole('button', { name: 'Send code' })); + expect(await screen.findByRole('textbox', { name: 'Verification code' })).toBeVisible(); + expect(fapi.mfa.phoneCreations).toEqual([canonical]); + expect(fapi.mfa.phonePreparations).toEqual(['phone_1']); + }, + ); + + it('reuses the original phone after two different SMS preparations fail', async () => { + const fapi = await renderMfa(fapiUser({ id: 'user_1' })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /SMS verification/ })); + const number = screen.getByRole('textbox', { name: 'Phone' }); + for (const [input, id] of [ + ['5555550303', 'phone_1'], + ['5555550404', 'phone_2'], + ] as const) { + await user.clear(number); + await user.type(number, input); + const prepare = holdRequests('post', `/v1/me/phone_numbers/${id}/prepare_verification`); + await user.click(screen.getByRole('button', { name: 'Send code' })); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + prepare.fail('phone_number_invalid', 'Unable to send a code.'); + await waitFor(() => expect(screen.getByText('Unable to send a code.')).toBeVisible()); + await waitFor(() => expect(number).not.toHaveAttribute('readonly')); + serveFapi(fapi); + } + await user.clear(number); + await user.type(number, '5555550303'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + expect(await screen.findByRole('textbox', { name: 'Verification code' })).toBeVisible(); + expect(fapi.mfa.phoneCreations).toEqual(['+15555550303', '+15555550404']); + expect(fapi.mfa.phonePreparations).toEqual(['phone_1']); + await user.type(screen.getByRole('textbox', { name: 'Verification code' }), VERIFICATION_CODE); + expect(await screen.findByText('CODE0001')).toBeVisible(); + expect(fapi.mfa.phoneUpdates).toEqual([{ id: 'phone_1', reserved: true, default: undefined }]); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.messages.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-add-authenticator.messages.ts rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.messages.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.styles.ts similarity index 85% rename from packages/mosaic/src/features/user-profile/user-profile-add-authenticator.styles.ts rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.styles.ts index 421f8cc6100..790e587ef3c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, space } from '../../tokens.stylex'; +import { colorVars, space } from '../../../tokens.stylex'; export const styles = stylex.create({ verification: { diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.view.tsx similarity index 89% rename from packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.view.tsx index e3356206f22..835976f7e9e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.view.tsx @@ -1,12 +1,12 @@ import { useId } from 'react'; -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 { 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 { UserProfileAuthenticatorSetupView } from './user-profile-authenticator-setup.view'; @@ -110,7 +110,7 @@ export function UserProfileAddAuthenticatorView({ variant='outline' color='neutral' fullWidth - disabled={Boolean(setup) && isPending} + disabled={isPending} onClick={onBack} > {m.back} diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-mfa.dialog.tsx similarity index 57% rename from packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-mfa.dialog.tsx index de412504706..8fc36ae6d1b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-mfa.dialog.tsx @@ -1,17 +1,18 @@ 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'; +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; + hideTrigger?: boolean; triggerRef?: Ref; } @@ -20,6 +21,7 @@ export function UserProfileAddMfaDialog({ onOpenChange, children, disabled, + hideTrigger, triggerRef: triggerRefProp, }: UserProfileAddMfaDialogProps) { const m = useMessages('userProfileMfa'); @@ -30,25 +32,27 @@ export function UserProfileAddMfaDialog({ open={open} onOpenChange={onOpenChange} > - + } + > + - } - > - - {m.add} - + {m.add} + + ) : null} void; onCopy: () => void; onDownload: () => void; + onPrint?: () => void; pendingAction?: 'generate' | 'copy' | 'download'; errorMessage?: string; } @@ -28,12 +29,14 @@ export function UserProfileBackupCodesView({ onRetry, onCopy, onDownload, + onPrint, pendingAction, errorMessage, }: UserProfileBackupCodesViewProps) { const m = useMessages('userProfileBackupCodes'); const actionRef = useFlowAutoFocus(); const hasCodes = codes.length > 0 && pendingAction !== 'generate'; + const showSaveActions = hasCodes || pendingAction === 'generate'; return ( <> @@ -90,15 +93,27 @@ export function UserProfileBackupCodesView({ ) : null} - {hasCodes ? ( + {showSaveActions ? ( <> + {onPrint ? ( + + ) : null} @@ -108,46 +123,37 @@ export function UserProfileBackupCodesView({ /> {m.download} - - - {m.copyAndClose} - ) : ( - <> - - - {m.retry} - - + )} + + {showSaveActions ? ( + + ) : null} + {showSaveActions ? m.copyAndClose : 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-section/user-profile-mfa-row.view.tsx similarity index 68% rename from packages/mosaic/src/features/user-profile/user-profile-mfa-row.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx index 69e73ff4b09..9b031d955ae 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx @@ -1,12 +1,13 @@ +import { stringToFormattedPhoneString } from '@clerk/shared/phone'; import type { Ref } from 'react'; -import type { ActionMenuAction } from '../../components/action-menu'; -import { ActionMenu } from '../../components/action-menu'; -import { Badge } from '../../components/badge'; -import { Section } from '../../components/section'; -import { fill, useMessages } from '../../localization'; +import type { ActionMenuAction } from '../../../components/action-menu'; +import { ActionMenu } from '../../../components/action-menu'; +import { Badge } from '../../../components/badge'; +import { Section } from '../../../components/section'; +import { fill, useMessages } from '../../../localization'; +import { UserProfileSecurityIcon } from '../user-profile-security-icon'; import type { UserProfileMfaMethod, UserProfileMfaSectionViewProps } from './user-profile-mfa-section.view'; -import { UserProfileSecurityIcon } from './user-profile-security-icon'; export function UserProfileMfaRowView({ method, @@ -22,9 +23,11 @@ export function UserProfileMfaRowView({ }) { const m = useMessages('userProfileMfa'); const label = method.label ?? m.methods[method.type]; + const description = + method.type === 'sms' && method.description ? stringToFormattedPhoneString(method.description) : method.description; const manageLabel = - method.type === 'sms' && method.description - ? fill(m.manageSms, { label, phoneNumber: method.description }) + method.type === 'sms' && description + ? fill(m.manageSms, { label, phoneNumber: description }) : fill(m.manage, { label }); const actions: ActionMenuAction[] = []; @@ -48,7 +51,7 @@ export function UserProfileMfaRowView({ {label} {method.isDefault ? {m.default} : null} - {method.description ? {method.description} : null} + {description ? {description} : null} {actions.length > 0 ? ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section-leaf.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section-leaf.controller.ts new file mode 100644 index 00000000000..8f2609c9a45 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section-leaf.controller.ts @@ -0,0 +1,52 @@ +import { useRef, useState } from 'react'; + +import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; +import { usePendingAction } from '../../../hooks/use-pending-action'; +import { useMessages } from '../../../localization'; +import type { UserProfileMfaMethod } from './user-profile-mfa-section.types'; +import type { UserProfileMfaSectionViewProps } from './user-profile-mfa-section.view'; + +export function useUserProfileMfaSectionLeafController({ + methods, + onRemove, + onSetDefault, +}: UserProfileMfaSectionViewProps) { + const m = useMessages('userProfileMfa'); + const sectionRef = useRef(null); + const removalFocus = useListRemovalFocus({ + ids: methods.map(method => method.id), + onRemove, + fallback: () => + sectionRef.current?.querySelector('button:not([disabled])') ?? sectionRef.current, + }); + const [pickerOpen, setPickerOpen] = useState(false); + const defaultAction = usePendingAction<'default'>({ errorFallback: m.setDefaultError }); + + const setDefault = (id: string) => { + const method = methods.find(method => method.id === id); + if (!onSetDefault || method?.type !== 'sms' || !method.canSetDefault || method.isDefault) { + return; + } + void defaultAction.run('default', () => onSetDefault(id)); + }; + + return { + sectionRef, + pickerOpen, + onPickerOpenChange: setPickerOpen, + closePicker: () => setPickerOpen(false), + registerTrigger: removalFocus.registerTrigger, + openRemoval: (open: () => void) => { + if (defaultAction.isPending) { + return; + } + defaultAction.reset(); + open(); + }, + confirmRemoval: (method: UserProfileMfaMethod) => removalFocus.remove(method.id), + finalRemovalFocus: removalFocus.finalFocus, + isSettingDefault: defaultAction.isPending, + defaultError: defaultAction.error, + setDefault, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts new file mode 100644 index 00000000000..6d62580e132 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts @@ -0,0 +1,550 @@ +import { useEffect } from 'react'; + +import { type ErrorDescription, fill, type MosaicMessages, useErrorText, useMessages } from '../../../localization'; +import { setup } from '../../../machine/setup'; +import type { DoneInvokeEvent } from '../../../machine/types'; +import { useMachine } from '../../../machine/use-machine'; +import { toLocalizableError } from '../../../utils/errors'; +import type { UserProfileMfaAddableMethod, UserProfileMfaModel } from './user-profile-mfa-section.types'; +import type { UserProfileMfaSectionViewProps } from './user-profile-mfa-section.view'; +import type { UserProfileMfaSetupViewProps } from './user-profile-mfa-setup.view'; + +type ReadyModel = Extract; +type AuthenticatorSetup = { secret: string; uri: string }; +type MfaAction = 'prepareAuthenticator' | 'verifyAuthenticator' | 'sms' | 'resend' | 'generate' | 'copy'; +type MfaFlow = + | { kind: 'closed' } + | { kind: 'select' } + | { + kind: 'authenticator'; + setup?: AuthenticatorSetup; + code: string; + error?: ErrorDescription; + setupError?: ErrorDescription; + } + | { + kind: 'sms'; + step: 'select' | 'phone' | 'verify'; + origin: 'existing' | 'new'; + selectedPhoneId: string; + phoneId?: string; + phoneNumber: string; + code: string; + error?: ErrorDescription; + } + | { kind: 'backup'; codes: readonly string[]; error?: ErrorDescription }; + +interface MfaContext { + flow: MfaFlow; + savedSetup?: AuthenticatorSetup; + pending?: MfaAction; + dismissed: boolean; + resendSeconds: number; + run: () => Promise; + resolve: () => void; + reject: (error: unknown) => void; +} + +type MfaEvent = + | { type: 'OPEN' } + | { type: 'CLOSE' } + | { type: 'SELECT'; method: UserProfileMfaAddableMethod; phoneId: string; hasPhones: boolean } + | { type: 'BACK'; hasPhones: boolean } + | { type: 'EDIT_PHONE_ID'; value: string } + | { type: 'EDIT_PHONE_NUMBER'; value: string } + | { type: 'EDIT_CODE'; value: string } + | { type: 'PHONE_RESOLVED'; id: string; phoneNumber: string } + | { type: 'PRINT_FAILED' } + | { type: 'TICK' } + | { type: 'RUN'; key: MfaAction; run: () => Promise; resolve: () => void; reject: (error: unknown) => void }; + +const { createMachine, assign, fromPromise } = setup(); +const RESEND_COOLDOWN_SECONDS = 30; +const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; + +const mfaMachine = createMachine({ + id: 'userProfileMfa', + initial: 'idle', + context: { + flow: { kind: 'closed' }, + dismissed: false, + resendSeconds: 0, + run: () => Promise.resolve({ kind: 'closed' }), + resolve: () => undefined, + reject: () => undefined, + }, + states: { + idle: { + on: { + OPEN: { actions: assign(() => ({ flow: { kind: 'select' } })) }, + CLOSE: { + actions: assign(() => ({ flow: { kind: 'closed' }, savedSetup: undefined, resendSeconds: 0 })), + }, + TICK: tick, + SELECT: { + actions: assign((context, event) => ({ + flow: + event.method === 'sms' + ? { + kind: 'sms', + step: event.hasPhones ? 'select' : 'phone', + origin: event.hasPhones ? 'existing' : 'new', + selectedPhoneId: event.phoneId, + phoneNumber: '', + code: '', + } + : event.method === 'backup-codes' + ? { kind: 'backup', codes: [] } + : { kind: 'authenticator', setup: context.savedSetup, code: '' }, + })), + }, + BACK: { + actions: assign((context, event) => { + const flow = context.flow; + if (flow.kind === 'sms' && flow.step === 'verify') { + return { + flow: { ...flow, step: flow.origin === 'new' ? 'phone' : 'select', code: '', error: undefined }, + }; + } + if (flow.kind === 'sms' && flow.step === 'phone' && event.hasPhones) { + return { flow: { ...flow, step: 'select', error: undefined } }; + } + return { + flow: { kind: 'select' }, + savedSetup: flow.kind === 'authenticator' ? flow.setup : context.savedSetup, + }; + }), + }, + EDIT_PHONE_ID: { + actions: assign((context, event) => ({ + flow: + context.flow.kind === 'sms' + ? { ...context.flow, selectedPhoneId: event.value, phoneId: undefined, origin: 'existing' } + : context.flow, + })), + }, + EDIT_PHONE_NUMBER: { + actions: assign((context, event) => ({ + flow: + context.flow.kind === 'sms' + ? { + ...context.flow, + phoneNumber: event.value, + phoneId: event.value === context.flow.phoneNumber ? context.flow.phoneId : undefined, + } + : context.flow, + })), + }, + EDIT_CODE: { + actions: assign((context, event) => ({ + flow: + context.flow.kind === 'sms' || context.flow.kind === 'authenticator' + ? { ...context.flow, code: event.value } + : context.flow, + })), + }, + PRINT_FAILED: { + actions: assign(context => ({ + flow: + context.flow.kind === 'backup' + ? { ...context.flow, error: { code: 'mfa_print_unavailable' } } + : context.flow, + })), + }, + RUN: { + target: 'busy', + actions: assign((_, event) => ({ + pending: event.key, + dismissed: false, + run: event.run, + resolve: event.resolve, + reject: event.reject, + })), + }, + }, + }, + busy: { + on: { + OPEN: { actions: assign(() => ({ flow: { kind: 'select' }, dismissed: true })) }, + CLOSE: { + actions: assign(() => ({ + flow: { kind: 'closed' }, + savedSetup: undefined, + resendSeconds: 0, + dismissed: true, + })), + }, + TICK: tick, + PHONE_RESOLVED: { + actions: assign((context, event) => ({ + flow: + context.flow.kind === 'sms' + ? { ...context.flow, phoneId: event.id, phoneNumber: event.phoneNumber } + : context.flow, + })), + }, + }, + invoke: fromPromise(context => context.run(), { + onDone: { + target: 'idle', + actions: [ + context => context.resolve(), + assign>((context, event) => { + if (context.dismissed && event.output.kind !== 'backup') { + return { pending: undefined, dismissed: false }; + } + return { + flow: event.output, + savedSetup: + event.output.kind === 'authenticator' && event.output.setup + ? event.output.setup + : event.output.kind === 'closed' + ? undefined + : context.savedSetup, + pending: undefined, + dismissed: false, + resendSeconds: + context.pending === 'resend' || + (context.pending === 'sms' && event.output.kind === 'sms' && event.output.step === 'verify') + ? RESEND_COOLDOWN_SECONDS + : context.resendSeconds, + }; + }), + ], + }, + onError: { + target: 'idle', + actions: [ + (context, event) => context.reject(event.error), + assign((context, event) => { + const flow = context.flow; + if (context.dismissed) { + return { pending: undefined, dismissed: false }; + } + if (flow.kind !== 'authenticator' && flow.kind !== 'sms' && flow.kind !== 'backup') { + return { pending: undefined }; + } + const error = toLocalizableError(event.error); + return { + flow: + flow.kind === 'authenticator' + ? { + ...flow, + ...(context.pending === 'prepareAuthenticator' ? { setupError: error } : { error }), + } + : { ...flow, error }, + pending: undefined, + }; + }), + ], + }, + }), + }, + }, +}); + +type MfaSend = (event: MfaEvent) => void; +type RunMfaAction = (key: MfaAction, action: () => Promise) => Promise; + +function createAuthenticatorActions(model: ReadyModel, flow: MfaFlow, run: RunMfaAction) { + const prepareAuthenticator = () => { + const createAuthenticator = model.createAuthenticator; + if (!createAuthenticator) { + return; + } + void run('prepareAuthenticator', async () => { + const setup = await createAuthenticator(); + return { kind: 'authenticator', setup, code: '' }; + }).catch(() => undefined); + }; + + const verifyAuthenticator = (code: string) => { + const verify = model.verifyAuthenticator; + if (!verify || flow.kind !== 'authenticator' || !flow.setup) { + return; + } + void run('verifyAuthenticator', async () => { + const result = await verify(code); + return result.backupCodes.length ? { kind: 'backup', codes: result.backupCodes } : { kind: 'closed' }; + }).catch(() => undefined); + }; + + return { prepareAuthenticator, verifyAuthenticator }; +} + +function createSmsActions(model: ReadyModel, flow: MfaFlow, resendSeconds: number, run: RunMfaAction, send: MfaSend) { + const submitSms = (code?: string) => { + const enrollSms = model.enrollSms; + const findOrCreatePhone = model.findOrCreatePhone; + if (!enrollSms || flow.kind !== 'sms') { + return; + } + const current = flow; + void run('sms', async () => { + let phoneId = current.phoneId ?? current.selectedPhoneId; + if (current.step === 'phone' && !phoneId) { + if (!findOrCreatePhone) { + return current; + } + const phone = await findOrCreatePhone(current.phoneNumber); + phoneId = phone.id; + send({ type: 'PHONE_RESOLVED', id: phone.id, phoneNumber: phone.phoneNumber }); + } + const result = await enrollSms(phoneId, code ?? (current.step === 'verify' ? current.code : undefined)); + if (result.status === 'needsVerification') { + return { ...current, step: 'verify', phoneId, phoneNumber: result.phone.phoneNumber, error: undefined }; + } + return result.status === 'complete' && result.backupCodes.length + ? { kind: 'backup', codes: result.backupCodes } + : { kind: 'closed' }; + }).catch(() => undefined); + }; + + const resendSms = () => { + const resend = model.resendSms; + if (flow.kind !== 'sms' || !flow.phoneId || !resend || resendSeconds > 0) { + return; + } + const current = flow; + void run('resend', async () => { + await resend(current.phoneId ?? ''); + return { ...current, error: undefined }; + }).catch(() => undefined); + }; + + return { submitSms, resendSms }; +} + +function createBackupCodeActions( + model: ReadyModel, + flow: MfaFlow, + run: RunMfaAction, + send: MfaSend, + m: MosaicMessages['userProfileBackupCodes'], +) { + const fileValues = { applicationName: model.applicationName, identifier: model.identifier }; + const generateBackupCodes = () => { + const generate = model.generateBackupCodes; + if (!generate) { + return; + } + send({ type: 'SELECT', method: 'backup-codes', phoneId: '', hasPhones: false }); + void run('generate', async () => { + const codes = await generate(); + return { kind: 'backup', codes }; + }).catch(() => undefined); + }; + + const copy = () => { + if (flow.kind !== 'backup' || !flow.codes.length) { + return; + } + const codes = flow.codes; + void run('copy', async () => { + await navigator.clipboard.writeText(codes.join('\n')); + return { kind: 'closed' }; + }).catch(() => undefined); + }; + + const download = () => { + if (flow.kind !== 'backup' || !flow.codes.length) { + return; + } + const content = [fill(m.fileIntro, fileValues), m.fileInstructions, '', ...flow.codes].join('\n'); + const url = URL.createObjectURL(new Blob([content], { type: 'text/plain' })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fill(m.fileName, fileValues); + anchor.click(); + URL.revokeObjectURL(url); + }; + + const print = () => { + if (flow.kind !== 'backup' || !flow.codes.length) { + return; + } + const titleText = fill(m.printTitle, fileValues); + const printable = window.open('', '_blank', 'width=640,height=720'); + if (!printable) { + send({ type: 'PRINT_FAILED' }); + return; + } + try { + printable.document.title = titleText; + const title = printable.document.createElement('h1'); + title.textContent = titleText; + const codes = printable.document.createElement('pre'); + codes.textContent = flow.codes.join('\n'); + printable.document.body.append(title, codes); + printable.addEventListener('afterprint', () => printable.close(), { once: true }); + printable.setTimeout(() => printable.close(), 60_000); + printable.focus(); + printable.print(); + } catch { + printable.close(); + send({ type: 'PRINT_FAILED' }); + } + }; + + return { generateBackupCodes, copy, download, print }; +} + +interface MfaSetupActions { + select: (method: UserProfileMfaAddableMethod) => void; + back: () => void; + close: () => void; + submitSms: (code?: string) => void; + resendSms: () => void; + prepareAuthenticator: () => void; + verifyAuthenticator: (code: string) => void; + generateBackupCodes: () => void; + copy: () => void; + download: () => void; + print: () => void; +} + +function createSetupProps( + model: ReadyModel, + context: MfaContext, + send: MfaSend, + actions: MfaSetupActions, + errorText: ReturnType, +): UserProfileMfaSetupViewProps { + const flow = context.flow; + const sms = flow.kind === 'sms' ? flow : undefined; + const authenticator = flow.kind === 'authenticator' ? flow : undefined; + const backup = flow.kind === 'backup' ? flow : undefined; + return { + step: flow.kind === 'closed' ? 'select' : flow.kind === 'backup' ? 'backup-codes' : flow.kind, + methods: model.addableMethods, + onSelect: actions.select, + onBack: actions.back, + onCancel: actions.close, + sms: { + step: sms?.step ?? 'select', + phoneNumbers: model.phones, + selectedPhoneId: sms?.selectedPhoneId ?? '', + onSelectedPhoneIdChange: value => send({ type: 'EDIT_PHONE_ID', value }), + onAddPhone: () => send({ type: 'SELECT', method: 'sms', phoneId: '', hasPhones: false }), + onBack: actions.back, + phoneNumber: sms?.phoneNumber ?? '', + onPhoneNumberChange: value => send({ type: 'EDIT_PHONE_NUMBER', value }), + code: sms?.code ?? '', + onCodeChange: value => send({ type: 'EDIT_CODE', value }), + onSubmit: actions.submitSms, + onResend: actions.resendSms, + isPending: context.pending === 'sms', + isResending: context.pending === 'resend', + resendSeconds: context.resendSeconds, + errorMessage: sms?.error ? errorText(sms.error) : undefined, + }, + authenticator: { + setup: authenticator?.setup, + setupErrorMessage: authenticator?.setupError ? errorText(authenticator.setupError) : undefined, + onRetry: actions.prepareAuthenticator, + code: authenticator?.code ?? '', + onCodeChange: value => send({ type: 'EDIT_CODE', value }), + onSubmit: actions.verifyAuthenticator, + isPending: context.pending === 'prepareAuthenticator' || context.pending === 'verifyAuthenticator', + errorMessage: authenticator?.error ? errorText(authenticator.error) : undefined, + }, + backupCodes: { + codes: backup?.codes ?? [], + onRetry: actions.generateBackupCodes, + onCopy: actions.copy, + onDownload: actions.download, + onPrint: actions.print, + pendingAction: context.pending === 'generate' || context.pending === 'copy' ? context.pending : undefined, + errorMessage: backup?.error ? errorText(backup.error) : undefined, + }, + }; +} + +export function useUserProfileMfaController(model: ReadyModel) { + const errorText = useErrorText(); + const backupCodeMessages = useMessages('userProfileBackupCodes'); + const [{ context, value }, send] = useMachine(mfaMachine); + const isBusy = value === 'busy'; + const run: RunMfaAction = (key, action) => { + if (isBusy) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + send({ type: 'RUN', key, run: action, resolve, reject }); + }); + }; + const { resendSeconds } = context; + const flow = context.flow; + const dialogOpen = flow.kind !== 'closed'; + useEffect(() => { + if (!dialogOpen || resendSeconds === 0) { + return; + } + const timer = setTimeout(() => send({ type: 'TICK' }), 1000); + return () => clearTimeout(timer); + }, [dialogOpen, resendSeconds, send]); + const { prepareAuthenticator, verifyAuthenticator } = createAuthenticatorActions(model, flow, run); + const { submitSms, resendSms } = createSmsActions(model, flow, resendSeconds, run, send); + const { generateBackupCodes, copy, download, print } = createBackupCodeActions( + model, + flow, + run, + send, + backupCodeMessages, + ); + + const close = () => send({ type: 'CLOSE' }); + const back = () => send({ type: 'BACK', hasPhones: model.phones.length > 0 }); + + const select = (method: UserProfileMfaAddableMethod) => { + if (isBusy) { + return; + } + send({ type: 'SELECT', method, phoneId: model.phones[0]?.id ?? '', hasPhones: model.phones.length > 0 }); + if (method === 'authenticator' && !context.savedSetup) { + prepareAuthenticator(); + } + if (method === 'backup-codes') { + generateBackupCodes(); + } + }; + + const setupProps = createSetupProps( + model, + context, + send, + { + select, + back, + close, + submitSms, + resendSms, + prepareAuthenticator, + verifyAuthenticator, + generateBackupCodes, + copy, + download, + print, + }, + errorText, + ); + + const sectionProps: UserProfileMfaSectionViewProps = { + methods: [...model.methods], + addableMethods: model.addableMethods, + onRegenerateBackupCodes: model.generateBackupCodes ? generateBackupCodes : undefined, + onRemove: model.remove, + onSetDefault: model.setDefault, + }; + + const showAddTrigger = model.addableMethods.length > 0; + + return { + dialogOpen, + showAddTrigger, + showAddControl: showAddTrigger || dialogOpen, + onDialogOpenChange: (open: boolean) => send({ type: open ? 'OPEN' : 'CLOSE' }), + sectionProps, + setupProps, + }; +} 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/user-profile-mfa-section.messages.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.messages.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.model.ts new file mode 100644 index 00000000000..555826f6d5b --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.model.ts @@ -0,0 +1,255 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { getIdentifier } from '@clerk/shared/internal/clerk-js/user'; +import { useClerk, useSession, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, PhoneNumberResource, UserResource } from '@clerk/shared/types'; + +import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; +import type { + MfaEnrollmentResult, + MfaPhone, + SmsEnrollmentResult, + UserProfileMfaAddableMethod, + UserProfileMfaMethod, + UserProfileMfaModel, +} from './user-profile-mfa-section.types'; + +type ReadyModel = Extract; +type MfaSnapshotData = Pick; + +function configuredFactors(environment: EnvironmentResource | undefined): string[] { + return Object.values(environment?.userSettings.attributes ?? {}).flatMap(attribute => + attribute?.used_for_second_factor ? attribute.second_factors : [], + ); +} + +function verifiedReservedPhones(user: UserResource): PhoneNumberResource[] { + return user.phoneNumbers.filter(phone => phone.reservedForSecondFactor && phone.verification.status === 'verified'); +} + +function usableFactorCount(user: UserResource, factors: readonly string[]): number { + return ( + Number(factors.includes('totp') && user.totpEnabled) + + (factors.includes('phone_code') ? verifiedReservedPhones(user).length : 0) + ); +} + +function summarizePhone(phone: PhoneNumberResource): MfaPhone { + return { + id: phone.id, + phoneNumber: phone.phoneNumber, + verified: phone.verification.status === 'verified', + }; +} + +function phoneUnavailable(): ClerkRuntimeError { + return new ClerkRuntimeError('This phone number is unavailable.', { code: 'mfa_phone_unavailable' }); +} + +function requireIdentity(clerk: ReturnType, userId: string, sessionId: string): UserResource { + const current = clerk.user; + if (!current || current.id !== userId || clerk.session?.id !== sessionId) { + throw new ClerkRuntimeError('This account changed. Please try again.', { code: 'mfa_account_changed' }); + } + return current; +} + +function requireRemovable(user: UserResource, environment: EnvironmentResource): void { + if (environment.userSettings.signUp.mfa?.required && usableFactorCount(user, configuredFactors(environment)) <= 1) { + throw new ClerkRuntimeError('This method cannot be removed.', { code: 'mfa_method_cannot_remove' }); + } +} + +function describeMfa(user: UserResource, environment: EnvironmentResource): MfaSnapshotData { + const secondFactors = configuredFactors(environment); + const smsEnabled = secondFactors.includes('phone_code'); + const totpEnabled = secondFactors.includes('totp'); + const backupEnabled = secondFactors.includes('backup_code'); + const reservedPhones = verifiedReservedPhones(user); + const usableCount = usableFactorCount(user, secondFactors); + const canRemove = !environment.userSettings.signUp.mfa?.required || usableCount > 1; + const authenticatorEnrolled = totpEnabled && user.totpEnabled; + const methods: UserProfileMfaMethod[] = []; + if (authenticatorEnrolled) { + methods.push({ id: 'authenticator', type: 'authenticator', isDefault: true, canRemove }); + } + for (const phone of smsEnabled + ? [...reservedPhones].sort((left, right) => Number(right.defaultSecondFactor) - Number(left.defaultSecondFactor)) + : []) { + methods.push({ + id: phone.id, + type: 'sms', + description: phone.phoneNumber, + isDefault: !authenticatorEnrolled && phone.defaultSecondFactor, + canRemove, + canSetDefault: !authenticatorEnrolled && !phone.defaultSecondFactor, + }); + } + if (backupEnabled && user.backupCodeEnabled) { + methods.push({ id: 'backup-codes', type: 'backup-codes' }); + } + const addableMethods: UserProfileMfaAddableMethod[] = []; + if (smsEnabled) { + addableMethods.push('sms'); + } + if (totpEnabled && !user.totpEnabled) { + addableMethods.push('authenticator'); + } + if (backupEnabled && usableCount > 0 && !user.backupCodeEnabled) { + addableMethods.push('backup-codes'); + } + + return { + methods, + addableMethods, + phones: user.phoneNumbers.filter(phone => !phone.reservedForSecondFactor).map(summarizePhone), + }; +} + +function createSmsActions( + clerk: ReturnType, + environment: EnvironmentResource, + userId: string, + sessionId: string, + refresh: () => Promise, +): Pick { + const currentPhone = (phoneId: string) => { + const current = requireIdentity(clerk, userId, sessionId); + const phone = current.phoneNumbers.find(item => item.id === phoneId); + if (!phone) { + throw phoneUnavailable(); + } + return phone; + }; + + return { + findOrCreatePhone: async phoneNumber => { + const current = requireIdentity(clerk, userId, sessionId); + const existing = current.phoneNumbers.find( + phone => !phone.reservedForSecondFactor && phone.phoneNumber === phoneNumber, + ); + return summarizePhone(existing ?? (await current.createPhoneNumber({ phoneNumber }))); + }, + enrollSms: async (phoneId, code): Promise => { + let phone = currentPhone(phoneId); + if (phone.verification.status !== 'verified') { + if (!code) { + await phone.prepareVerification(); + return { status: 'needsVerification', phone: summarizePhone(phone) }; + } + await phone.attemptVerification({ code }); + phone = currentPhone(phoneId); + } + if (phone.reservedForSecondFactor || !configuredFactors(environment).includes('phone_code')) { + throw phoneUnavailable(); + } + const result = await phone.setReservedForSecondFactor({ reserved: true }); + await refresh().catch(() => undefined); + return { status: 'complete', backupCodes: result.backupCodes ?? [] }; + }, + resendSms: async phoneId => { + await currentPhone(phoneId).prepareVerification(); + }, + setDefault: async phoneId => { + const phone = currentPhone(phoneId); + if (phone.verification.status !== 'verified' || !phone.reservedForSecondFactor) { + throw phoneUnavailable(); + } + await phone.makeDefaultSecondFactor(); + await refresh().catch(() => undefined); + }, + }; +} + +export function useUserProfileMfaModel(): UserProfileMfaModel { + const clerk = useClerk(); + const { isLoaded: userLoaded, user } = useUser(); + const { isLoaded: sessionLoaded, session } = useSession(); + const environment = useMosaicEnvironment(); + + if (!userLoaded || !sessionLoaded || !environment) { + return { status: 'loading' }; + } + if (!user || !session) { + return { status: 'hidden' }; + } + + const secondFactors = configuredFactors(environment); + const smsEnabled = secondFactors.includes('phone_code'); + const totpEnabled = secondFactors.includes('totp'); + const backupEnabled = secondFactors.includes('backup_code'); + if (!smsEnabled && !totpEnabled) { + return { status: 'hidden' }; + } + + const userId = user.id; + const sessionId = session.id; + const { methods, addableMethods, phones } = describeMfa(user, environment); + const usableCount = usableFactorCount(user, secondFactors); + const refresh = async () => { + const current = requireIdentity(clerk, userId, sessionId); + await current.reload(); + }; + + const smsActions = smsEnabled ? createSmsActions(clerk, environment, userId, sessionId, refresh) : {}; + + return { + status: 'ready', + userId, + sessionId, + applicationName: environment.displayConfig.applicationName, + identifier: getIdentifier(user), + methods, + addableMethods, + phones, + ...smsActions, + createAuthenticator: totpEnabled + ? async () => { + const current = requireIdentity(clerk, userId, sessionId); + if (current.totpEnabled || !configuredFactors(environment).includes('totp')) { + throw new ClerkRuntimeError('Authenticator is unavailable.', { code: 'mfa_authenticator_unavailable' }); + } + const result = await current.createTOTP(); + return { secret: result.secret ?? '', uri: result.uri ?? '' }; + } + : undefined, + verifyAuthenticator: totpEnabled + ? async (code): Promise => { + const result = await requireIdentity(clerk, userId, sessionId).verifyTOTP({ code }); + const backupCodes = result.backupCodes ?? []; + await refresh().catch(() => undefined); + return { backupCodes }; + } + : undefined, + generateBackupCodes: + backupEnabled && usableCount > 0 + ? async () => { + const current = requireIdentity(clerk, userId, sessionId); + const currentFactors = configuredFactors(environment); + if (!currentFactors.includes('backup_code') || usableFactorCount(current, currentFactors) === 0) { + throw new ClerkRuntimeError('Set up a verification method first.', { code: 'mfa_setup_factor_first' }); + } + const result = await current.createBackupCode(); + await refresh().catch(() => undefined); + return result.codes; + } + : undefined, + remove: async methodId => { + const current = requireIdentity(clerk, userId, sessionId); + if (methodId === 'authenticator') { + if (!current.totpEnabled) { + throw new ClerkRuntimeError('This method cannot be removed.', { code: 'mfa_method_cannot_remove' }); + } + requireRemovable(current, environment); + await current.disableTOTP(); + } else { + const phone = current.phoneNumbers.find(item => item.id === methodId); + if (!phone?.reservedForSecondFactor || phone.verification.status !== 'verified') { + throw new ClerkRuntimeError('This method cannot be removed.', { code: 'mfa_method_cannot_remove' }); + } + requireRemovable(current, environment); + await phone.setReservedForSecondFactor({ reserved: false }); + } + await refresh().catch(() => undefined); + }, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.tsx new file mode 100644 index 00000000000..e7f725a7988 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react'; + +import { UserProfileAddMfaDialog } from './user-profile-add-mfa.dialog'; +import { useUserProfileMfaController } from './user-profile-mfa-section.controller'; +import { useUserProfileMfaModel } from './user-profile-mfa-section.model'; +import type { UserProfileMfaModel } from './user-profile-mfa-section.types'; +import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; +import { UserProfileMfaSetupView } from './user-profile-mfa-setup.view'; + +export interface UserProfileMfaSectionProps { + fallback?: ReactNode; +} + +export function UserProfileMfaSection(props: UserProfileMfaSectionProps = {}) { + const model = useUserProfileMfaModel(); + return mfaSectionNode(model, props.fallback); +} + +export function mfaSectionNode(model: UserProfileMfaModel, fallback: ReactNode = null): ReactNode { + if (model.status === 'loading') { + return fallback ?? null; + } + if (model.status === 'hidden') { + return null; + } + return ( + + ); +} + +function MfaEditor({ model }: { model: Extract }) { + const controller = useUserProfileMfaController(model); + + return ( + + + + ) : undefined + } + /> + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.types.ts new file mode 100644 index 00000000000..43426e3812a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.types.ts @@ -0,0 +1,47 @@ +import type { PhoneNumberResource } from '@clerk/shared/types'; + +export interface UserProfileMfaMethod { + id: string; + type: 'sms' | 'authenticator' | 'backup-codes'; + label?: string; + description?: string; + isDefault?: boolean; + canRemove?: boolean; + canSetDefault?: boolean; +} + +export type UserProfileMfaAddableMethod = 'sms' | 'authenticator' | 'backup-codes'; + +export interface MfaPhone extends Pick { + verified: boolean; +} + +export interface MfaEnrollmentResult { + backupCodes: readonly string[]; +} + +export type SmsEnrollmentResult = + | { status: 'needsVerification'; phone: MfaPhone } + | ({ status: 'complete' } & MfaEnrollmentResult); + +export type UserProfileMfaModel = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + userId: string; + sessionId: string; + applicationName: string; + identifier: string; + methods: readonly UserProfileMfaMethod[]; + addableMethods: readonly UserProfileMfaAddableMethod[]; + phones: readonly MfaPhone[]; + findOrCreatePhone?: (phoneNumber: string) => Promise; + enrollSms?: (phoneId: string, code?: string) => Promise; + resendSms?: (phoneId: string) => Promise; + createAuthenticator?: () => Promise<{ secret: string; uri: string }>; + verifyAuthenticator?: (code: string) => Promise; + generateBackupCodes?: () => Promise; + remove: (methodId: string) => Promise; + setDefault?: (phoneId: string) => Promise; + }; 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/user-profile-mfa-section.view.tsx similarity index 52% rename from packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx index 2d52ccb45a6..4efa3afe2c8 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/user-profile-mfa-section.view.tsx @@ -1,26 +1,17 @@ -import { type ReactNode, type Ref, useMemo, useRef, useState } from 'react'; +import { stringToFormattedPhoneString } from '@clerk/shared/phone'; +import { type ReactNode, type Ref, useMemo } from 'react'; -import { Confirmation } from '../../blocks/confirmation'; -import { Section } from '../../components/section'; -import { useListRemovalFocus } from '../../hooks/use-list-removal-focus'; -import { usePendingAction } from '../../hooks/use-pending-action'; -import { fill, type MosaicMessages, useMessages } from '../../localization'; +import { Confirmation } from '../../../blocks/confirmation'; +import { Section } from '../../../components/section'; +import { fill, type MosaicMessages, useMessages } from '../../../localization'; +import { UserProfileSecurityList } from '../user-profile-security-list'; 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'; +import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.types'; +import { useUserProfileMfaSectionLeafController } from './user-profile-mfa-section-leaf.controller'; -export interface UserProfileMfaMethod { - id: string; - type: 'sms' | 'authenticator' | 'backup-codes'; - label?: string; - description?: string; - isDefault?: boolean; - canRemove?: boolean; - canSetDefault?: boolean; -} - -export type UserProfileMfaAddableMethod = 'sms' | 'authenticator' | 'backup-codes'; +export type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.types'; export interface UserProfileMfaSectionViewProps { methods: UserProfileMfaMethod[]; @@ -44,37 +35,26 @@ export function UserProfileMfaSectionView({ onSetDefault, }: UserProfileMfaSectionViewProps) { const m = useMessages('userProfileMfa'); - const section = useRef(null); - const removalFocus = useListRemovalFocus({ - ids: methods.map(method => method.id), - onRemove, - fallback: () => section.current?.querySelector('button:not([disabled])') ?? section.current, - }); + const controller = useUserProfileMfaSectionLeafController({ methods, onRemove, onSetDefault }); const removeMethod = useMemo(() => Confirmation.createHandle(), []); - const [pickerOpen, setPickerOpen] = useState(false); - const setDefault = usePendingAction({ errorFallback: m.setDefaultError }); - const canSetDefault = (id: string) => { - const method = methods.find(method => method.id === id); - return method?.type === 'sms' && method.canSetDefault && !method.isDefault; - }; return ( <> { onAdd(type); - setPickerOpen(false); + controller.closePicker(); }} /> @@ -89,30 +69,22 @@ export function UserProfileMfaSectionView({ removeMethod.open(method) : undefined} - onSetDefault={ - onSetDefault && !setDefault.isPending - ? id => { - if (canSetDefault(id)) { - void setDefault.run('set-default', () => onSetDefault(id)); - } - } - : undefined - } + triggerRef={controller.registerTrigger(method.id)} + onRemove={onRemove ? () => controller.openRemoval(() => removeMethod.open(method)) : undefined} + onSetDefault={onSetDefault && !controller.isSettingDefault ? controller.setDefault : undefined} onRegenerateBackupCodes={onRegenerateBackupCodes} /> ))} - {setDefault.error} + {controller.defaultError} {onRemove ? ( (method.type === 'sms' ? m.removeDialog.smsTitle : m.removeDialog.authenticatorTitle)} description={method => describeMethodRemoval(method, m)} actionLabel={m.removeDialog.confirm} - finalFocus={removalFocus.finalFocus} - onConfirm={method => removalFocus.remove(method.id)} + finalFocus={controller.finalRemovalFocus} + onConfirm={controller.confirmRemoval} /> ) : null} @@ -122,7 +94,7 @@ export function UserProfileMfaSectionView({ function describeMethodRemoval(method: UserProfileMfaMethod, m: MosaicMessages['userProfileMfa']) { if (method.type === 'sms') { return method.description - ? fill(m.removeDialog.smsDescription, { phoneNumber: method.description }) + ? fill(m.removeDialog.smsDescription, { phoneNumber: stringToFormattedPhoneString(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-section/user-profile-mfa-setup.view.tsx similarity index 97% rename from packages/mosaic/src/features/user-profile/user-profile-mfa-setup.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-setup.view.tsx index 77225ac39bd..81407ef63bd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-setup.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-setup.view.tsx @@ -1,4 +1,4 @@ -import { Flow } from '../../components/flow'; +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'; @@ -7,7 +7,7 @@ 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'; +import type { UserProfileMfaAddableMethod } from './user-profile-mfa-section.types'; export interface UserProfileMfaSetupViewProps extends UserProfileAddMfaViewProps { step: UserProfileMfaAddableMethod | 'select'; 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 4ed712b6bd0..9c6d7405d99 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 @@ -4,8 +4,11 @@ import { Panel } from '../../components/panel'; import { Section } from '../../components/section'; import { themeProps } from '../../props'; import type { UserProfileDevice } from './user-profile-active-devices-section/user-profile-active-devices-section.view'; -import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view'; -import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; +import type { + UserProfileMfaAddableMethod, + UserProfileMfaMethod, +} from './user-profile-mfa-section/user-profile-mfa-section.view'; +import { UserProfileMfaSectionView } from './user-profile-mfa-section/user-profile-mfa-section.view'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey }; @@ -13,6 +16,7 @@ export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMeth export interface UserProfileSecurityPanelViewProps { passwordSlot?: ReactNode; passkeysSlot?: ReactNode; + mfaSlot?: ReactNode; mfaMethods?: UserProfileMfaMethod[]; addableMfaMethods?: readonly UserProfileMfaAddableMethod[]; mfaAddControl?: ReactNode; @@ -26,6 +30,7 @@ export interface UserProfileSecurityPanelViewProps { export function UserProfileSecurityPanelView({ passwordSlot, passkeysSlot, + mfaSlot, mfaMethods, addableMfaMethods, mfaAddControl, @@ -35,7 +40,21 @@ export function UserProfileSecurityPanelView({ onRemoveMfaMethod, onSetDefaultMfaMethod, }: UserProfileSecurityPanelViewProps): ReactElement { - const hasAuthentication = passwordSlot != null || Boolean(passkeysSlot) || mfaMethods !== undefined; + const selectedMfaNode = + mfaSlot !== undefined ? ( + mfaSlot + ) : mfaMethods !== undefined ? ( + + ) : null; + const hasAuthentication = passwordSlot != null || Boolean(passkeysSlot) || Boolean(selectedMfaNode); return ( }> @@ -45,17 +64,7 @@ export function UserProfileSecurityPanelView({ {passwordSlot} {passkeysSlot} - {mfaMethods !== undefined ? ( - - ) : null} + {selectedMfaNode} ) : null} {activeDevicesSlot} diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 82d227f3d06..03481fa7476 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -32,6 +32,12 @@ export const errorMessages: { readonly generic: string } & Readonly +
+
+

2-step verification

+

Manage the verification methods for your signed-in account.

+
+ {!isLoaded ?

Loading…

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

+ + Sign in + {' '} + to manage your verification methods. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+ + ); +} diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index 593de3de7c2..2e291d764aa 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -14,6 +14,7 @@ const liveRoutes = new Map([ ['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/user-profile/user-profile-account-section', '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/live/account-section'], ['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/user-profile/user-profile-password-section', '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/live/password'], ['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/user-profile/user-profile-active-devices-section', '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/live/active-devices'], + ['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/user-profile/user-profile-mfa-section', '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/live/mfa'], ['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/user-profile/user-profile-connected-accounts-section', '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/live/connected-accounts'], ['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/user-profile/user-profile-enterprise-accounts-section', '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/live/enterprise-accounts'], ['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/user-profile/user-profile-danger-section', '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/live/user-danger'], diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx index 2782a480678..0a5e4aa3f58 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx @@ -1,5 +1,5 @@ -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 { UserProfileAddMfaDialog } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section/user-profile-add-mfa.dialog'; +import { UserProfileMfaSetupView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section/user-profile-mfa-setup.view'; import type { UserProfileSecurityPanelViewProps } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; import { useUserProfileMfaFixture } from './user-profile-mfa'; diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts index 381182477f9..d51fc6d64b8 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts @@ -2,9 +2,8 @@ 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'; +} from '@clerk/mosaic/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view'; +import type { UserProfileMfaSetupViewProps } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section/user-profile-mfa-setup.view'; import { useEffect, useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -129,7 +128,7 @@ export function useUserProfileMfaFixture({ return { id: phone.id, type: 'sms' as const, - description: stringToFormattedPhoneString(phone.phoneNumber), + description: phone.phoneNumber, isDefault, canSetDefault: !isDefault, }; 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 33631c6bb02..90a694421f6 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -1,4 +1,4 @@ -import { UserProfileMfaSectionView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section.view'; +import { UserProfileMfaSectionView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view'; import type { StoryMeta } from '@/lib/types'; @@ -9,11 +9,10 @@ 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' }, - source: 'packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx', + source: 'packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx', }; export function Default() {