From 0ae492d4fcc45618fdb09c5e044b16771c8142db Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 13:31:30 -0600 Subject: [PATCH 01/26] test(mosaic): add stateful MFA feature fixtures --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 4 + .../src/__tests__/feature/fake-fapi/mfa.ts | 280 ++++++++++++++++++ packages/mosaic/src/__tests__/feature/fapi.ts | 13 + .../mosaic/src/__tests__/feature/render.tsx | 9 +- 4 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 62940b66714..a71c2ef8d7e 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -21,6 +21,7 @@ import { setupWorker } from 'msw/browser'; import { enterpriseHandlers, type FakeEnterpriseLinking } from './fake-fapi/enterprise'; import { type FakePasskeysSeed, passkeyHandlers } from './fake-fapi/passkeys'; import { envelope, error, findSession, missing, updateUser } from './fake-fapi/shared'; +import { createMfaState, type FakeMfaState, mfaHandlers } from './fake-fapi/mfa'; import { createVerificationState, type FakeVerificationSeed, @@ -60,6 +61,7 @@ export interface FakeFapiState { passwordUpdates: URLSearchParams[]; enterpriseConnections: EnterpriseConnectionJSON[]; enterpriseLinking: FakeEnterpriseLinking; + mfa: FakeMfaState; activeDevices?: ActiveDeviceRecord[]; deviceTrackingEnabled: boolean; } @@ -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..cbdb2517b57 --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts @@ -0,0 +1,280 @@ +import type { ClientJSON, PhoneNumberJSON, UserJSON } from '@clerk/shared/types'; +import { http, HttpResponse } from 'msw'; + +import { type FapiEnvironment, fapiPhoneNumber, fapiVerification } from '../fapi'; + +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 active = () => state.client.sessions.find(session => session.id === state.client.last_active_session_id); + 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 respond = (response: object) => HttpResponse.json({ response, client: state.client }); + const missing = () => + HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); + const updateUser = (transform: (user: UserJSON) => UserJSON) => { + const session = active(); + if (!session) { + return undefined; + } + const user = transform(session.user); + state.client = { + ...state.client, + sessions: state.client.sessions.map(item => (item.id === session.id ? { ...item, user } : item)), + }; + return user; + }; + const updatePhone = (id: string, transform: (phone: PhoneNumberJSON) => PhoneNumberJSON) => { + let updated: PhoneNumberJSON | undefined; + updateUser(user => ({ + ...user, + phone_numbers: user.phone_numbers.map(phone => { + if (phone.id !== id) { + return phone; + } + updated = transform(phone); + return updated; + }), + })); + return updated; + }; + + return [ + http.get(fapiUrl('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/v1/me'), () => { + const session = active(); + return session ? respond(session.user) : missing(); + }), + http.post(fapiUrl('/v1/me/totp'), ({ request }) => { + if (!active()) { + return missing(); + } + if (new URL(request.url).searchParams.get('_method') === 'DELETE') { + const currentUser = active()?.user; + 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; + const user = updateUser(current => ({ + ...current, + totp_enabled: false, + two_factor_enabled: current.phone_numbers.some(phone => phone.reserved_for_second_factor), + backup_code_enabled: + current.phone_numbers.some(phone => phone.reserved_for_second_factor) && current.backup_code_enabled, + })); + return user ? respond({ object: 'deleted', id: 'totp_1' }) : missing(); + } + if ( + active()?.user.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 respond({ + 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(), + }); + }), + http.post(fapiUrl('/v1/me/totp/attempt_verification'), async ({ request }) => { + if (state.mfa.totpCreations === 0 || active()?.user.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() && !active()?.user.backup_code_enabled; + const user = updateUser(current => ({ + ...current, + totp_enabled: true, + two_factor_enabled: true, + backup_code_enabled: current.backup_code_enabled || newCodes, + })); + if (!user) { + return missing(); + } + return respond({ + object: 'totp', + id: 'totp_1', + verified: true, + ...(newCodes ? { backup_codes: state.mfa.codes } : {}), + created_at: Date.now(), + updated_at: Date.now(), + }); + }), + http.post(fapiUrl('/v1/me/backup_codes/'), () => { + const currentUser = active()?.user; + 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')}`, + ); + const user = updateUser(current => ({ ...current, backup_code_enabled: true })); + return user + ? respond({ + object: 'backup_code', + id: 'backup_1', + codes: state.mfa.codes, + created_at: Date.now(), + updated_at: Date.now(), + }) + : missing(); + }), + http.post(fapiUrl('/v1/me/phone_numbers/'), async ({ request }) => { + const number = new URLSearchParams(await request.text()).get('phone_number') ?? ''; + state.mfa.phoneCreations.push(number); + const existingIds = new Set(active()?.user.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'), + }); + const user = updateUser(current => ({ ...current, phone_numbers: [...current.phone_numbers, phone] })); + return user ? respond(phone) : missing(); + }), + http.post(fapiUrl('/v1/me/phone_numbers/:id/prepare_verification'), ({ params }) => { + const id = String(params.id); + state.mfa.phonePreparations.push(id); + const phone = updatePhone(id, current => ({ ...current, verification: fapiVerification('phone_code') })); + return phone ? respond(phone) : missing(); + }), + http.post(fapiUrl('/v1/me/phone_numbers/:id/attempt_verification'), async ({ params, request }) => { + const id = String(params.id); + const code = new URLSearchParams(await request.text()).get('code') ?? ''; + state.mfa.phoneAttempts.push({ id, code }); + if (code !== '123456') { + return HttpResponse.json( + { errors: [{ code: 'form_code_incorrect', message: 'Incorrect code' }] }, + { status: 422 }, + ); + } + const phone = updatePhone(id, current => ({ + ...current, + verification: fapiVerification('phone_code', { status: 'verified' }), + })); + return phone ? respond(phone) : 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 previous = active()?.user; + const existingPhone = previous?.phone_numbers.find(phone => phone.id === id); + if (reserved === true && (!existingPhone || 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 && + previous && + usableFactors(previous) <= 1 + ) { + return error('second_factor_deletion_not_allowed', 'This method cannot be removed.'); + } + const firstReservedPhone = !previous?.phone_numbers.some(item => item.reserved_for_second_factor); + const newCodes = reserved && backupEnabled() && !previous?.backup_code_enabled; + const phone = updatePhone(id, current => ({ + ...current, + reserved_for_second_factor: reserved ?? current.reserved_for_second_factor, + default_second_factor: + isDefault ?? + (reserved && firstReservedPhone ? true : reserved === false ? false : current.default_second_factor), + backup_codes: newCodes ? state.mfa.codes : undefined, + })); + if (isDefault) { + updateUser(user => ({ + ...user, + phone_numbers: user.phone_numbers.map(item => + item.id === id ? item : { ...item, default_second_factor: false }, + ), + })); + } + if (reserved !== undefined) { + updateUser(user => ({ + ...user, + two_factor_enabled: user.totp_enabled || user.phone_numbers.some(item => item.reserved_for_second_factor), + backup_code_enabled: reserved + ? user.backup_code_enabled || Boolean(newCodes) + : user.totp_enabled || user.phone_numbers.some(item => item.reserved_for_second_factor) + ? user.backup_code_enabled + : false, + })); + if (reserved === false && previous?.phone_numbers.find(item => item.id === id)?.default_second_factor) { + updateUser(user => { + const next = user.phone_numbers.find(item => item.reserved_for_second_factor); + return { + ...user, + phone_numbers: user.phone_numbers.map(item => + item.id === next?.id ? { ...item, default_second_factor: true } : item, + ), + }; + }); + } + } + return phone ? respond(phone) : missing(); + }), + ]; +} diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index f53462b5f2e..1629e585c39 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -344,6 +344,19 @@ export function fapiUser(overrides: Partial & Pick): U }; } +export function fapiPhoneNumber( + overrides: Partial & Pick, +): PhoneNumberJSON { + return { + object: 'phone_number', + reserved_for_second_factor: false, + default_second_factor: false, + linked_to: [], + verification: fapiVerification('phone_code', { status: 'verified' }), + ...overrides, + }; +} + export function fapiEnterpriseAccount( overrides: Partial & Pick, connection: Partial = {}, 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; From b8b9dc6dd3f9acbd4a1586ba683bfd1471dc7090 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 13:31:37 -0600 Subject: [PATCH 02/26] feat(mosaic): add the Clerk MFA model --- .../user-profile-mfa-section.messages.ts | 9 + .../user-profile-mfa-section.model.ts | 317 ++++++++++++++++++ .../user-profile-mfa-section.types.ts | 51 +++ 3 files changed, 377 insertions(+) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-mfa-section.model.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-mfa-section.types.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts index 3463921b362..cde6b01da5d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.messages.ts @@ -11,6 +11,15 @@ export const userProfileMfaMessages = { default: 'Default', setDefault: 'Set as default', setDefaultError: 'Unable to set this method as default. Please try again.', + errors: { + accountChanged: 'This account changed. Please reopen verification.', + authenticatorUnavailable: 'Authenticator is unavailable.', + phoneUnavailable: 'This phone number is unavailable.', + methodCannotRemove: 'This method cannot be removed.', + setupFactorFirst: 'Set up a verification method first.', + unexpectedResponse: 'The verification response was unexpected. Please try again.', + busy: 'Another verification action is in progress.', + }, remove: 'Remove method', regenerate: 'Regenerate', manage: 'Manage {label}', diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.model.ts new file mode 100644 index 00000000000..e2830e11330 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.model.ts @@ -0,0 +1,317 @@ +import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; +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 { useErrorText, useMessages } from '../../localization'; +import { useReverificationWithState } from '../reverification/use-reverification-with-state'; +import { + MfaCancelledError, + type MfaEnrollmentResult, + type MfaPhone, + type SmsEnrollmentResult, + type UserProfileMfaAddableMethod, + type UserProfileMfaMethod, + type UserProfileMfaModel, +} from './user-profile-mfa-section.types'; + +type ProtectedOperation = + | { kind: 'createAuthenticator'; userId: string; sessionId: string } + | { kind: 'reservePhone'; userId: string; sessionId: string; phoneId: string } + | { kind: 'removeAuthenticator'; userId: string; sessionId: string } + | { kind: 'removePhone'; userId: string; sessionId: string; phoneId: string } + | { kind: 'generateBackupCodes'; userId: string; sessionId: string }; + +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 errorMessage(error: unknown, localize: ReturnType): Error { + if (error instanceof MfaCancelledError) { + return error; + } + if (isClerkAPIResponseError(error)) { + const first = error.errors[0]; + if (first) { + return new Error( + localize({ code: first.code, paramName: first.meta?.paramName, message: first.longMessage || first.message }), + ); + } + } + return new Error(localize({ message: error instanceof Error ? error.message : undefined })); +} + +export function useUserProfileMfaModel(): UserProfileMfaModel { + const clerk = useClerk(); + const { isLoaded: userLoaded, user } = useUser(); + const { isLoaded: sessionLoaded, session } = useSession(); + const environment = useMosaicEnvironment(); + const localize = useErrorText(); + const m = useMessages('userProfileMfa'); + + const requireIdentity = (userId: string, sessionId: string): UserResource => { + const current = clerk.user; + if (!current || current.id !== userId || clerk.session?.id !== sessionId) { + throw new Error(m.errors.accountChanged); + } + return current; + }; + + const [protectedAction, reverification, resetReverification] = useReverificationWithState( + async (operation: ProtectedOperation) => { + const current = requireIdentity(operation.userId, operation.sessionId); + const currentSecondFactors = configuredFactors(environment); + switch (operation.kind) { + case 'createAuthenticator': { + if (current.totpEnabled || !currentSecondFactors.includes('totp')) { + throw new Error(m.errors.authenticatorUnavailable); + } + const result = await current.createTOTP(); + return { kind: 'authenticatorSetup', secret: result.secret ?? '', uri: result.uri ?? '' } as const; + } + case 'reservePhone': { + const phone = current.phoneNumbers.find(item => item.id === operation.phoneId); + if ( + !phone || + phone.verification.status !== 'verified' || + phone.reservedForSecondFactor || + !currentSecondFactors.includes('phone_code') + ) { + throw new Error(m.errors.phoneUnavailable); + } + const result = await phone.setReservedForSecondFactor({ reserved: true }); + return { kind: 'enrollment', backupCodes: result.backupCodes ?? [] } as const; + } + case 'removeAuthenticator': { + const usableCount = usableFactorCount(current, currentSecondFactors); + if (!current.totpEnabled || (environment?.userSettings.signUp.mfa?.required && usableCount <= 1)) { + throw new Error(m.errors.methodCannotRemove); + } + await current.disableTOTP(); + return { kind: 'done' } as const; + } + case 'removePhone': { + const phone = current.phoneNumbers.find(item => item.id === operation.phoneId); + const usableCount = usableFactorCount(current, currentSecondFactors); + if ( + !phone?.reservedForSecondFactor || + phone.verification.status !== 'verified' || + (environment?.userSettings.signUp.mfa?.required && usableCount <= 1) + ) { + throw new Error(m.errors.methodCannotRemove); + } + await phone.setReservedForSecondFactor({ reserved: false }); + return { kind: 'done' } as const; + } + case 'generateBackupCodes': { + if (!currentSecondFactors.includes('backup_code') || usableFactorCount(current, currentSecondFactors) === 0) { + throw new Error(m.errors.setupFactorFirst); + } + const result = await current.createBackupCode(); + return { kind: 'backupCodes', codes: result.codes } as const; + } + } + }, + ); + + if (!userLoaded || !sessionLoaded || !environment) { + return { status: 'loading', reverification, resetReverification }; + } + if (!user || !session) { + return { status: 'hidden', reverification, resetReverification }; + } + + 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', reverification, resetReverification }; + } + + const userId = user.id; + const sessionId = session.id; + const reservedPhones = verifiedReservedPhones(user); + const usableCount = usableFactorCount(user, secondFactors); + const canRemove = !environment.userSettings.signUp.mfa?.required || usableCount > 1; + const methods: UserProfileMfaMethod[] = []; + if (totpEnabled && user.totpEnabled) { + 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: !(totpEnabled && user.totpEnabled) && phone.defaultSecondFactor, + canRemove, + canSetDefault: !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'); + } + + const refresh = async () => { + const current = requireIdentity(userId, sessionId); + await current.reload(); + }; + const runProtected = async (operation: ProtectedOperation) => { + try { + return await protectedAction(operation); + } catch (error) { + if (isReverificationCancelledError(error)) { + throw new MfaCancelledError('Verification was cancelled.'); + } + throw errorMessage(error, localize); + } + }; + const currentPhone = (phoneId: string) => { + const current = requireIdentity(userId, sessionId); + const phone = current.phoneNumbers.find(item => item.id === phoneId); + if (!phone) { + throw new Error(m.errors.phoneUnavailable); + } + return phone; + }; + + return { + status: 'ready', + userId, + sessionId, + methods, + addableMethods, + phones: user.phoneNumbers.filter(phone => !phone.reservedForSecondFactor).map(summarizePhone), + reverification, + resetReverification, + createPhone: smsEnabled + ? async phoneNumber => { + try { + const created = await requireIdentity(userId, sessionId).createPhoneNumber({ phoneNumber }); + return summarizePhone(created); + } catch (error) { + throw errorMessage(error, localize); + } + } + : undefined, + enrollSms: smsEnabled + ? async (phoneId, code): Promise => { + try { + 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); + } + const result = await runProtected({ kind: 'reservePhone', userId, sessionId, phoneId }); + if (result.kind !== 'enrollment') { + throw new Error(m.errors.unexpectedResponse); + } + await refresh().catch(() => undefined); + return { status: 'complete', backupCodes: result.backupCodes }; + } catch (error) { + throw errorMessage(error, localize); + } + } + : undefined, + resendSms: smsEnabled + ? async phoneId => { + try { + await currentPhone(phoneId).prepareVerification(); + } catch (error) { + throw errorMessage(error, localize); + } + } + : undefined, + createAuthenticator: totpEnabled + ? async () => { + const result = await runProtected({ kind: 'createAuthenticator', userId, sessionId }); + if (result.kind !== 'authenticatorSetup') { + throw new Error(m.errors.unexpectedResponse); + } + return { secret: result.secret, uri: result.uri }; + } + : undefined, + verifyAuthenticator: totpEnabled + ? async (code): Promise => { + try { + const result = await requireIdentity(userId, sessionId).verifyTOTP({ code }); + const backupCodes = result.backupCodes ?? []; + await refresh().catch(() => undefined); + return { backupCodes }; + } catch (error) { + throw errorMessage(error, localize); + } + } + : undefined, + generateBackupCodes: + backupEnabled && usableCount > 0 + ? async () => { + const result = await runProtected({ kind: 'generateBackupCodes', userId, sessionId }); + if (result.kind !== 'backupCodes') { + throw new Error(m.errors.unexpectedResponse); + } + await refresh().catch(() => undefined); + return result.codes; + } + : undefined, + remove: async methodId => { + const operation: ProtectedOperation = + methodId === 'authenticator' + ? { kind: 'removeAuthenticator', userId, sessionId } + : { kind: 'removePhone', userId, sessionId, phoneId: methodId }; + await runProtected(operation); + await refresh().catch(() => undefined); + }, + setDefault: smsEnabled + ? async phoneId => { + try { + const phone = currentPhone(phoneId); + if (phone.verification.status !== 'verified' || !phone.reservedForSecondFactor) { + throw new Error(m.errors.phoneUnavailable); + } + await phone.makeDefaultSecondFactor(); + await refresh().catch(() => undefined); + } catch (error) { + throw errorMessage(error, localize); + } + } + : undefined, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.types.ts new file mode 100644 index 00000000000..20a7e5a7198 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.types.ts @@ -0,0 +1,51 @@ +import type { ReverificationState } from '../reverification/reverification.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 { + id: string; + phoneNumber: string; + verified: boolean; +} + +export class MfaCancelledError extends Error {} + +export interface MfaEnrollmentResult { + backupCodes: readonly string[]; +} + +export type SmsEnrollmentResult = + | { status: 'needsVerification'; phone: MfaPhone } + | ({ status: 'complete' } & MfaEnrollmentResult); + +export type UserProfileMfaModel = + | { status: 'loading'; reverification: ReverificationState; resetReverification: () => void } + | { status: 'hidden'; reverification: ReverificationState; resetReverification: () => void } + | { + status: 'ready'; + userId: string; + sessionId: string; + methods: readonly UserProfileMfaMethod[]; + addableMethods: readonly UserProfileMfaAddableMethod[]; + phones: readonly MfaPhone[]; + reverification: ReverificationState; + resetReverification: () => void; + createPhone?: (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; + }; From 99d98535953d69fd122499d2dbe568b1e2eb6539 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 13:31:59 -0600 Subject: [PATCH 03/26] feat(mosaic): add MFA enrollment and management controllers --- .../user-profile-add-authenticator.view.tsx | 2 +- .../user-profile-add-mfa.dialog.tsx | 38 +- .../user-profile-backup-codes.messages.ts | 2 + .../user-profile-backup-codes.view.tsx | 13 + ...ser-profile-mfa-section-leaf.controller.ts | 124 +++++ .../user-profile-mfa-section.controller.ts | 473 ++++++++++++++++++ .../user-profile-mfa-section.view.tsx | 69 +-- .../user-profile-mfa-setup.view.tsx | 2 +- 8 files changed, 657 insertions(+), 66 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-mfa-section-leaf.controller.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-mfa-section.controller.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx index e3356206f22..63934350a55 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-authenticator.view.tsx @@ -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-add-mfa.dialog.tsx index de412504706..ad5aef59179 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-add-mfa.dialog.tsx @@ -12,6 +12,7 @@ export interface UserProfileAddMfaDialogProps { 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?: (title: string, unavailableMessage: string) => void; pendingAction?: 'generate' | 'copy' | 'download'; errorMessage?: string; } @@ -28,6 +29,7 @@ export function UserProfileBackupCodesView({ onRetry, onCopy, onDownload, + onPrint, pendingAction, errorMessage, }: UserProfileBackupCodesViewProps) { @@ -92,6 +94,17 @@ export function UserProfileBackupCodesView({ {hasCodes ? ( <> + {onPrint ? ( + + ) : null} (null); + const triggers = useRef(new Map()); + const lastRemovalId = useRef(); + 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 [state, setState] = useState({ kind: 'idle' }); + const operation = useRef<'idle' | 'default' | 'remove'>('idle'); + + const setDefault = async (id: string) => { + const method = methods.find(method => method.id === id); + if ( + !onSetDefault || + method?.type !== 'sms' || + !method.canSetDefault || + method.isDefault || + operation.current !== 'idle' + ) { + return; + } + operation.current = 'default'; + setState({ kind: 'settingDefault' }); + try { + await onSetDefault(id); + setState({ kind: 'idle' }); + } catch (error) { + setState({ kind: 'idle', defaultError: error instanceof Error ? error.message : m.setDefaultError }); + } finally { + operation.current = 'idle'; + } + }; + + const confirmRemoval = async () => { + if (state.kind !== 'confirmingRemoval' || operation.current !== 'idle') { + return; + } + const method = state.method; + operation.current = 'remove'; + setState({ kind: 'removing', method }); + try { + await removalFocus.remove(method.id); + setState({ kind: 'idle' }); + } catch (error) { + if (error instanceof MfaCancelledError) { + setState({ kind: 'idle' }); + } else { + setState({ kind: 'confirmingRemoval', method, error: error instanceof Error ? error.message : errors.generic }); + } + } finally { + operation.current = 'idle'; + } + }; + + const removal = + state.kind === 'confirmingRemoval' || state.kind === 'removing' + ? ({ + method: state.method, + status: state.kind === 'removing' ? 'pending' : 'confirming', + error: state.kind === 'confirmingRemoval' ? state.error : undefined, + } as const) + : undefined; + + return { + sectionRef, + pickerOpen, + onPickerOpenChange: setPickerOpen, + closePicker: () => setPickerOpen(false), + removal, + onRemovalOpenChange: (open: boolean) => { + if (!open && operation.current === 'idle') { + setState({ kind: 'idle' }); + } + }, + openRemoval: (method: UserProfileMfaMethod) => { + if (operation.current === 'idle') { + lastRemovalId.current = method.id; + setState({ kind: 'confirmingRemoval', method }); + } + }, + confirmRemoval, + finalRemovalFocus: () => + removalFocus.finalFocus() ?? + (lastRemovalId.current ? triggers.current.get(lastRemovalId.current) : undefined) ?? + sectionRef.current, + registerTrigger: (id: string) => { + const registerRemovalTrigger = removalFocus.registerTrigger(id); + return (element: HTMLButtonElement | null) => { + registerRemovalTrigger(element); + if (element) { + triggers.current.set(id, element); + } else { + triggers.current.delete(id); + } + }; + }, + isSettingDefault: state.kind === 'settingDefault', + defaultError: state.kind === 'idle' ? state.defaultError : undefined, + setDefault, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.controller.ts new file mode 100644 index 00000000000..c79e1f2cc91 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.controller.ts @@ -0,0 +1,473 @@ +import { useEffect, useRef, useState } from 'react'; + +import { useMessages } from '../../localization'; +import { setup } from '../../machine/setup'; +import { useMachine } from '../../machine/use-machine'; +import { + MfaCancelledError, + type UserProfileMfaAddableMethod, + type 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 MfaFlow = + | { kind: 'closed' } + | { kind: 'select' } + | { kind: 'authenticator'; setup?: AuthenticatorSetup; code: string; error?: string; setupError?: string } + | { + kind: 'sms'; + step: 'select' | 'phone' | 'verify'; + origin: 'existing' | 'new'; + selectedPhoneId: string; + phoneId?: string; + phoneNumber: string; + code: string; + error?: string; + } + | { kind: 'backup'; codes: readonly string[]; error?: string }; + +interface MfaContext { + flow: MfaFlow; + savedSetup?: AuthenticatorSetup; + pending?: string; + resendAvailableAt?: 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_CREATED'; id: string; phoneNumber: string } + | { type: 'PRINT_FAILED'; message: string } + | { type: 'RUN'; key: string; run: () => Promise; resolve: () => void; reject: (error: unknown) => void }; + +const { createMachine, assign, fromPromise } = setup(); +const RESEND_COOLDOWN_MS = 30_000; + +const mfaMachine = createMachine({ + id: 'userProfileMfa', + initial: 'idle', + context: { + flow: { kind: 'closed' }, + 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, resendAvailableAt: undefined })), + }, + 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, event) => ({ + flow: context.flow.kind === 'backup' ? { ...context.flow, error: event.message } : context.flow, + })), + }, + RUN: { + target: 'busy', + actions: assign((_, event) => ({ + pending: event.key, + run: event.run, + resolve: event.resolve, + reject: event.reject, + })), + }, + }, + }, + busy: { + on: { + PHONE_CREATED: { + 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) => ({ + flow: event.output, + savedSetup: + event.output.kind === 'authenticator' && event.output.setup + ? event.output.setup + : event.output.kind === 'closed' + ? undefined + : context.savedSetup, + pending: undefined, + resendAvailableAt: + context.pending === 'resend' || + (context.pending === 'sms' && event.output.kind === 'sms' && event.output.step === 'verify') + ? Date.now() + RESEND_COOLDOWN_MS + : context.resendAvailableAt, + })), + ], + }, + onError: { + target: 'idle', + actions: [ + (context, event) => context.reject(event.error), + assign((context, event) => { + if (event.error instanceof MfaCancelledError) { + return { flow: { kind: 'closed' }, savedSetup: undefined, pending: undefined }; + } + const message = + event.error instanceof Error ? event.error.message : 'This action could not be completed.'; + const flow = context.flow; + return { + flow: + flow.kind === 'authenticator' + ? { + ...flow, + ...(context.pending === 'prepareAuthenticator' ? { setupError: message } : { error: message }), + } + : flow.kind === 'sms' || flow.kind === 'backup' + ? { ...flow, error: message } + : flow, + pending: undefined, + }; + }), + ], + }, + }), + }, + }, +}); + +export function useUserProfileMfaController(model: ReadyModel) { + const m = useMessages('userProfileMfa'); + const [{ context, value }, send] = useMachine(mfaMachine); + const locked = useRef(false); + const [now, setNow] = useState(() => Date.now()); + const resendAvailableAt = context.resendAvailableAt; + const resendSeconds = resendAvailableAt ? Math.max(0, Math.ceil((resendAvailableAt - now) / 1000)) : 0; + useEffect(() => { + if (!resendSeconds) { + return; + } + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [resendSeconds]); + const flow = context.flow; + const run = (key: string, action: () => Promise): Promise => { + if (locked.current || value === 'busy') { + return Promise.reject(new Error(m.errors.busy)); + } + locked.current = true; + return new Promise((resolve, reject) => { + send({ + type: 'RUN', + key, + run: action, + resolve: () => { + locked.current = false; + resolve(); + }, + reject: error => { + locked.current = false; + reject(error instanceof Error ? error : new Error(m.errors.unexpectedResponse)); + }, + }); + }); + }; + + const close = () => send({ type: 'CLOSE' }); + const back = () => send({ type: 'BACK', hasPhones: model.phones.length > 0 }); + + const prepareAuthenticator = () => { + if (!model.createAuthenticator) { + return; + } + void run('prepareAuthenticator', async () => { + const setup = await model.createAuthenticator?.(); + return setup ? { kind: 'authenticator', setup, code: '' } : { kind: 'select' }; + }).catch(() => undefined); + }; + + const generateBackupCodes = () => { + if (!model.generateBackupCodes) { + return; + } + send({ type: 'SELECT', method: 'backup-codes', phoneId: '', hasPhones: false }); + void run('generate', async () => { + const codes = await model.generateBackupCodes?.(); + return { kind: 'backup', codes: codes ?? [] }; + }).catch(() => undefined); + }; + + const select = (method: UserProfileMfaAddableMethod) => { + if (locked.current) { + 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 verifyAuthenticator = (code: string) => { + if (!model.verifyAuthenticator || flow.kind !== 'authenticator' || !flow.setup) { + return; + } + void run('verifyAuthenticator', async () => { + const result = await model.verifyAuthenticator?.(code); + return result?.backupCodes.length ? { kind: 'backup', codes: result.backupCodes } : { kind: 'closed' }; + }).catch(() => undefined); + }; + + const submitSms = (code?: string) => { + if (!model.enrollSms || flow.kind !== 'sms') { + return; + } + const current = flow; + void run('sms', async () => { + let phoneId = current.phoneId ?? current.selectedPhoneId; + if (current.step === 'phone' && !phoneId) { + const created = await model.createPhone?.(current.phoneNumber); + if (!created) { + return current; + } + phoneId = created.id; + send({ type: 'PHONE_CREATED', id: created.id, phoneNumber: created.phoneNumber }); + } + const result = await model.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 = () => { + if ( + flow.kind !== 'sms' || + !flow.phoneId || + !model.resendSms || + (resendAvailableAt && Date.now() < resendAvailableAt) + ) { + return; + } + const current = flow; + void run('resend', async () => { + await model.resendSms?.(current.phoneId ?? ''); + return { ...current, error: undefined }; + }).catch(() => undefined); + }; + + const remove = (id: string) => + run('remove', async () => { + await model.remove(id); + return flow; + }); + const setDefault = model.setDefault + ? (id: string) => + run('default', async () => { + await model.setDefault?.(id); + return flow; + }) + : 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 url = URL.createObjectURL(new Blob([flow.codes.join('\n')], { type: 'text/plain' })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'clerk-backup-codes.txt'; + anchor.click(); + URL.revokeObjectURL(url); + }; + + const print = (titleText: string, unavailableMessage: string) => { + if (flow.kind !== 'backup' || !flow.codes.length) { + return; + } + const printable = window.open('', '_blank', 'width=640,height=720'); + if (!printable) { + send({ type: 'PRINT_FAILED', message: unavailableMessage }); + 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', message: unavailableMessage }); + } + }; + + const setupProps: UserProfileMfaSetupViewProps = { + step: + flow.kind === 'closed' || flow.kind === 'backup' + ? flow.kind === 'backup' + ? 'backup-codes' + : 'select' + : flow.kind, + methods: model.addableMethods, + onSelect: select, + onBack: back, + onCancel: close, + sms: { + step: flow.kind === 'sms' ? flow.step : 'select', + phoneNumbers: model.phones, + selectedPhoneId: flow.kind === 'sms' ? flow.selectedPhoneId : '', + onSelectedPhoneIdChange: value => send({ type: 'EDIT_PHONE_ID', value }), + onAddPhone: () => send({ type: 'SELECT', method: 'sms', phoneId: '', hasPhones: false }), + onBack: back, + phoneNumber: flow.kind === 'sms' ? flow.phoneNumber : '', + onPhoneNumberChange: value => send({ type: 'EDIT_PHONE_NUMBER', value }), + code: flow.kind === 'sms' ? flow.code : '', + onCodeChange: value => send({ type: 'EDIT_CODE', value }), + onSubmit: submitSms, + onResend: resendSms, + isPending: context.pending === 'sms', + isResending: context.pending === 'resend', + resendSeconds, + errorMessage: flow.kind === 'sms' ? flow.error : undefined, + }, + authenticator: { + setup: flow.kind === 'authenticator' ? flow.setup : undefined, + setupErrorMessage: flow.kind === 'authenticator' ? flow.setupError : undefined, + onRetry: prepareAuthenticator, + code: flow.kind === 'authenticator' ? flow.code : '', + onCodeChange: value => send({ type: 'EDIT_CODE', value }), + onSubmit: verifyAuthenticator, + isPending: context.pending === 'prepareAuthenticator' || context.pending === 'verifyAuthenticator', + errorMessage: flow.kind === 'authenticator' ? flow.error : undefined, + }, + backupCodes: { + codes: flow.kind === 'backup' ? flow.codes : [], + onRetry: generateBackupCodes, + onCopy: copy, + onDownload: download, + onPrint: print, + pendingAction: context.pending === 'generate' ? 'generate' : context.pending === 'copy' ? 'copy' : undefined, + errorMessage: flow.kind === 'backup' ? flow.error : undefined, + }, + }; + + const sectionProps: UserProfileMfaSectionViewProps = { + methods: [...model.methods], + addableMethods: model.addableMethods, + onRegenerateBackupCodes: model.generateBackupCodes ? generateBackupCodes : undefined, + onRemove: remove, + onSetDefault: setDefault, + }; + + return { + dialogOpen: flow.kind !== 'closed', + showAddTrigger: model.addableMethods.length > 0, + onDialogOpenChange: (open: boolean) => { + if (!open && model.reverification.phase === 'active') { + model.reverification.cancel(); + return; + } + send({ type: open ? 'OPEN' : 'CLOSE' }); + }, + sectionProps, + setupProps, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx index 2d52ccb45a6..ad431a2c155 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx @@ -1,26 +1,16 @@ -import { type ReactNode, type Ref, useMemo, useRef, useState } from 'react'; +import { type ReactNode, type Ref } 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 { UserProfileAddMfaDialog } from './user-profile-add-mfa.dialog'; import { UserProfileAddMfaView } from './user-profile-add-mfa.view'; import { UserProfileMfaRowView } from './user-profile-mfa-row.view'; +import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.types'; +import { useUserProfileMfaSectionLeafController } from './user-profile-mfa-section-leaf.controller'; import { UserProfileSecurityList } from './user-profile-security-list'; -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 +34,25 @@ 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 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; - }; + const controller = useUserProfileMfaSectionLeafController({ methods, onRemove, onSetDefault }); return ( <> { onAdd(type); - setPickerOpen(false); + controller.closePicker(); }} /> @@ -89,30 +67,27 @@ export function UserProfileMfaSectionView({ removeMethod.open(method) : undefined} + triggerRef={controller.registerTrigger(method.id)} + onRemove={onRemove ? () => controller.openRemoval(method) : undefined} onSetDefault={ - onSetDefault && !setDefault.isPending - ? id => { - if (canSetDefault(id)) { - void setDefault.run('set-default', () => onSetDefault(id)); - } - } - : undefined + onSetDefault && !controller.isSettingDefault ? id => void controller.setDefault(id) : undefined } onRegenerateBackupCodes={onRegenerateBackupCodes} /> ))} - {setDefault.error} - {onRemove ? ( + {controller.defaultError} + {onRemove && controller.removal ? ( (method.type === 'sms' ? m.removeDialog.smsTitle : m.removeDialog.authenticatorTitle)} - description={method => describeMethodRemoval(method, m)} + open + onOpenChange={controller.onRemovalOpenChange} + title={controller.removal.method.type === 'sms' ? m.removeDialog.smsTitle : m.removeDialog.authenticatorTitle} + description={describeMethodRemoval(controller.removal.method, m)} actionLabel={m.removeDialog.confirm} - finalFocus={removalFocus.finalFocus} - onConfirm={method => removalFocus.remove(method.id)} + finalFocus={controller.finalRemovalFocus} + onConfirm={controller.confirmRemoval} + isConfirming={controller.removal.status === 'pending'} + errorMessage={controller.removal.error} /> ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-setup.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-setup.view.tsx index 77225ac39bd..7cb6e55f2fd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-setup.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-setup.view.tsx @@ -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'; From b392afc8a6d3420ebc47064400e40bc7b3e51f83 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 13:32:24 -0600 Subject: [PATCH 04/26] feat(mosaic): connect the MFA section with feature coverage --- .changeset/bright-mfa-panel.md | 2 + .../__tests__/mfa-feature-setup.tsx | 30 + .../user-profile/__tests__/mfa-test-utils.tsx | 52 -- ...er-profile-add-authenticator.view.test.tsx | 151 ----- .../user-profile-add-sms.view.test.tsx | 156 ----- ...-profile-authenticator-setup.view.test.tsx | 56 -- .../user-profile-backup-codes.view.test.tsx | 124 ---- .../user-profile-mfa-cards.view.test.tsx | 61 -- ...-profile-mfa-interactions.feature.test.tsx | 186 ++++++ ...er-profile-mfa-management.feature.test.tsx | 592 ++++++++++++++++++ .../user-profile-mfa.feature.test.tsx | 366 +++++++++++ ...ser-profile-mfa-section-leaf.controller.ts | 8 +- .../user-profile/user-profile-mfa-section.tsx | 83 +++ .../user-profile-security-panel.view.tsx | 9 +- 14 files changed, 1271 insertions(+), 605 deletions(-) create mode 100644 .changeset/bright-mfa-panel.md create mode 100644 packages/mosaic/src/features/user-profile/__tests__/mfa-feature-setup.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/mfa-test-utils.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-add-authenticator.view.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.view.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-authenticator-setup.view.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.view.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-cards.view.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-interactions.feature.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-mfa-section.tsx 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/features/user-profile/__tests__/mfa-feature-setup.tsx b/packages/mosaic/src/features/user-profile/__tests__/mfa-feature-setup.tsx new file mode 100644 index 00000000000..400e64cd135 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/mfa-feature-setup.tsx @@ -0,0 +1,30 @@ +import { serveFapi } 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'; + +export const phone = fapiPhoneNumber({ id: 'phone_1', phone_number: '+15555550101' }); + +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/__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-interactions.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-interactions.feature.test.tsx new file mode 100644 index 00000000000..ae8f3499ab7 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-interactions.feature.test.tsx @@ -0,0 +1,186 @@ +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('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 write = vi + .spyOn(navigator.clipboard, 'writeText') + .mockRejectedValueOnce(new Error('Clipboard unavailable')) + .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'); + 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('Clipboard unavailable')); + 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/__tests__/user-profile-mfa-management.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx new file mode 100644 index 00000000000..499a8188a2c --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx @@ -0,0 +1,592 @@ +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, fapiPhoneNumber, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { UserProfileMfaSection, useUserProfileMfaSlot } from '../user-profile-mfa-section'; +import { useUserProfilePasswordSlot } from '../user-profile-password-section/user-profile-password-section'; +import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; +import { mfaEnvironment, phone, renderMfa } from './mfa-feature-setup'; + +describe('User profile MFA management', () => { + 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 +15555550101' })); + 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 +15555550101. The phone number will remain on your account.', + ); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByText('+15555550101')).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('shows second-factor reverification for removal with no addable methods', async () => { + const environment = mfaEnvironment(); + environment.user_settings.attributes.phone_number.used_for_second_factor = false; + const fapi = serveFapi({ + environment, + client: fapiClient([ + fapiSession({ + id: 'sess_1', + user: fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), + }), + ]), + verification: { secondFactors: [{ strategy: 'totp' }], secrets: { totp: '654321' } }, + }); + await renderWithClerk(); + let attempts = 0; + worker.use( + http.post(fapiUrl('/v1/me/totp'), ({ request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'DELETE') { + return undefined; + } + attempts += 1; + return attempts === 1 || fapi.verification.status !== 'complete' + ? HttpResponse.json( + { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, + { 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.getByText('Verification required')).toBeVisible()); + expect(fapi.mfa.totpRemovals).toBe(0); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '654321'); + await waitFor(() => expect(fapi.mfa.totpRemovals).toBe(1)); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + }); + + it('leaves the enrolled method intact when removal reverification is cancelled', async () => { + const environment = mfaEnvironment(); + environment.user_settings.attributes.phone_number.used_for_second_factor = false; + const fapi = serveFapi({ + environment, + client: fapiClient([ + fapiSession({ + id: 'sess_1', + user: fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), + }), + ]), + verification: { secondFactors: [{ strategy: 'totp' }], secrets: { totp: '654321' } }, + }); + await renderWithClerk(); + worker.use( + http.post(fapiUrl('/v1/me/totp'), ({ request }) => + new URL(request.url).searchParams.get('_method') === 'DELETE' + ? HttpResponse.json( + { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, + { 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.getByText('Verification required')).toBeVisible()); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByText('Verification required')).toBeNull()); + expect(fapi.mfa.totpRemovals).toBe(0); + expect(screen.getByText('Authenticator app')).toBeVisible(); + expect(screen.queryByRole('alert')).toBeNull(); + serveFapi(fapi); + 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)); + }); + + 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 +15555550101' }); + 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('places a connected MFA slot after password in the security panel', async () => { + function Panel() { + const mfaSlot = useUserProfileMfaSlot(); + const passwordSlot = useUserProfilePasswordSlot(); + 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 = useUserProfileMfaSlot(); + 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 = useUserProfileMfaSlot(); + return ( + + ); + } + serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]) }); + await renderWithClerk(); + expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); + }); + + it('reverifies the first factor before creating an authenticator secret', async () => { + const fapi = await renderMfa(); + fapi.verification.secrets.password = 'hunter2'; + let attempts = 0; + worker.use( + http.post(fapiUrl('/v1/me/totp'), () => { + attempts += 1; + return attempts === 1 + ? HttpResponse.json( + { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, + { status: 403 }, + ) + : 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.getByText('Verification required')).toBeVisible()); + expect(fapi.mfa.totpCreations).toBe(0); + const password = await screen.findByLabelText('Password'); + await waitFor(() => expect(password).toBeVisible()); + await user.type(password, 'hunter2{Enter}'); + await waitFor(() => expect(fapi.verification.status).toBe('complete')); + await waitFor(() => expect(fapi.mfa.totpCreations).toBe(1)); + expect(await screen.findByRole('img', { name: /QR code/i }, { timeout: 3000 })).toBeVisible(); + expect(fapi.mfa.totpCreations).toBe(1); + }); + + it('waits for enrolled second-factor verification before regenerating backup codes', async () => { + const fapi = serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([ + fapiSession({ + id: 'sess_1', + user: fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), + }), + ]), + verification: { secondFactors: [{ strategy: 'totp' }], secrets: { totp: '654321' } }, + }); + await renderWithClerk(); + let attempts = 0; + worker.use( + http.post(fapiUrl('/v1/me/backup_codes/'), () => { + attempts += 1; + return attempts === 1 || fapi.verification.status !== 'complete' + ? HttpResponse.json( + { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, + { status: 403 }, + ) + : undefined; + }), + ); + 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('Verification required')).toBeVisible()); + expect(fapi.mfa.backupCodeCreations).toBe(0); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '654321'); + await waitFor(() => expect(fapi.verification.status).toBe('complete')); + await waitFor(() => expect(screen.getByText('CODE0100')).toBeVisible()); + expect(fapi.mfa.backupCodeCreations).toBe(1); + }); + + it('cancels authenticator enrollment without showing an error or creating a secret', async () => { + const fapi = await renderMfa(); + worker.use( + http.post(fapiUrl('/v1/me/totp'), () => + HttpResponse.json( + { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, + { 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.getByText('Verification required')).toBeVisible()); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(screen.queryByRole('alert')).toBeNull(); + expect(fapi.mfa.totpCreations).toBe(0); + }); + + it('does not retry a pending authenticator challenge after the active account changes', 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(); + worker.use( + http.post(fapiUrl('/v1/me/totp'), () => + HttpResponse.json( + { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, + { 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.getByText('Verification required')).toBeVisible()); + await clerk.setActive({ session: 'sess_2' }); + await waitFor(() => expect(fapi.client.last_active_session_id).toBe('sess_2')); + await waitFor(() => expect(screen.queryByText('Verification required')).toBeNull()); + expect(fapi.mfa.totpCreations).toBe(0); + }); + + it('rechecks phone eligibility after reverification before reserving it', async () => { + const fapi = serveFapi({ + environment: mfaEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', phone_numbers: [phone] }) })]), + verification: { secrets: { password: 'hunter2' } }, + }); + await renderWithClerk(); + let attempts = 0; + worker.use( + http.post(fapiUrl('/v1/me/phone_numbers/phone_1'), ({ request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'PATCH') { + return undefined; + } + attempts += 1; + return HttpResponse.json( + { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, + { status: 403 }, + ); + }), + ); + 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('Verification required')).toBeVisible()); + const password = await screen.findByLabelText('Password'); + await waitFor(() => expect(password).toBeVisible()); + const session = fapi.client.sessions.find(item => item.id === 'sess_1'); + const seededPhone = session?.user?.phone_numbers.find(item => item.id === 'phone_1'); + if (!seededPhone) { + throw new Error('Missing seeded phone'); + } + seededPhone.reserved_for_second_factor = true; + await user.type(password, 'hunter2{Enter}'); + await waitFor(() => expect(screen.getByText('This phone number is unavailable.')).toBeVisible()); + expect(attempts).toBe(1); + expect(fapi.mfa.phoneUpdates).toHaveLength(0); + }); + + 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()); + expect(screen.queryByRole('button', { name: 'Copy and close' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Download' })).toBeNull(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus()); + held.release(); + await waitFor(() => expect(screen.getByText('CODE0100')).toBeVisible()); + }); + + 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 only the displayed server-issued backup codes', async () => { + const fapi = await renderMfa(); + 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(fapi.mfa.codes.join('\n')); + expect(click).toHaveBeenCalledOnce(); + expect(click.mock.instances[0]?.download).toBe('clerk-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); + }); + + 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({ 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 +15555550202' })); + 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('+15555550202'), + ); + expect(screen.getAllByText('SMS verification')[0]?.closest('li')).toHaveTextContent('Default'); + }); + + it('keeps method menus available but hides default changes while one is pending', async () => { + const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const second = fapiPhoneNumber({ 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('post', '/v1/me/phone_numbers/phone_2'); + const user = userEvent.setup(); + const selected = screen.getByRole('button', { name: 'Manage SMS verification +15555550202' }); + await user.click(selected); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + await waitFor(() => expect(held.requests).toHaveLength(1)); + try { + await user.click(selected); + if (selected.getAttribute('aria-expanded') !== 'true') { + await user.click(selected); + } + expect(screen.queryByRole('menuitem', { name: 'Set as default' })).toBeNull(); + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Remove method' })).toBeVisible()); + } finally { + held.release(); + } + await waitFor(() => expect(screen.getByText('+15555550202').closest('li')).toHaveTextContent('Default')); + }); + + 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); + }); + + 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('offers printing for newly issued backup codes', async () => { + 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(); + const open = vi.spyOn(window, 'open').mockReturnValue(null); + await user.click(screen.getByRole('button', { name: 'Print' })); + expect(open).toHaveBeenCalledOnce(); + expect(screen.getByRole('alert')).toHaveTextContent('Printing is unavailable in this browser.'); + open.mockRestore(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx new file mode 100644 index 00000000000..ab786b334a8 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx @@ -0,0 +1,366 @@ +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, 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({ + 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('+15555550202'); + expect(rows[0]?.closest('li')).toHaveTextContent('Default'); + expect(rows[1]?.closest('li')).toHaveTextContent('+15555550101'); + expect(rows[1]?.closest('li')).not.toHaveTextContent('Default'); + expect(screen.getByText('+15555550202')).toBeVisible(); + expect(screen.getByText('Default')).toBeVisible(); + }); + + 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(fapi.mfa.phoneCreations).toEqual([]); + expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_1', reserved: true, default: undefined }); + expect(await screen.findByText('CODE0001')).toBeVisible(); + }); + + 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 +15555550101' })).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(fapi.mfa.phonePreparations).toEqual(['phone_1']); + expect(fapi.mfa.phoneUpdates).toEqual([]); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + expect(await screen.findByText('CODE0001')).toBeVisible(); + expect(fapi.mfa.phoneAttempts).toEqual([{ id: 'phone_1', code: '123456' }]); + 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' }), '123456'); + await waitFor(() => expect(screen.getByText('Reservation failed')).toBeVisible()); + expect(fapi.mfa.phoneAttempts).toEqual([{ id: 'phone_1', code: '123456' }]); + 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' } }); + 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' }); + const held = holdRequests('post', '/v1/me/phone_numbers/phone_1/prepare_verification'); + const later = Date.now() + 31_000; + const clock = vi.spyOn(Date, 'now').mockReturnValue(later); + try { + 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(screen.getByRole('button', { name: 'Verify' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); + } finally { + held.release(); + clock.mockRestore(); + } + }); + + 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(fapi.mfa.phoneCreations).toEqual(['+15555550303', '+15555550404']); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section-leaf.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section-leaf.controller.ts index 22a75e533fd..c7473b6bd23 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section-leaf.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section-leaf.controller.ts @@ -1,7 +1,8 @@ import { useRef, useState } from 'react'; import { useListRemovalFocus } from '../../hooks/use-list-removal-focus'; -import { useMessages } from '../../localization'; +import { useErrorText, useMessages } from '../../localization'; +import { toLocalizableError } from '../../utils/errors'; import { MfaCancelledError, type UserProfileMfaMethod } from './user-profile-mfa-section.types'; import type { UserProfileMfaSectionViewProps } from './user-profile-mfa-section.view'; @@ -18,6 +19,7 @@ export function useUserProfileMfaSectionLeafController({ }: UserProfileMfaSectionViewProps) { const m = useMessages('userProfileMfa'); const errors = useMessages('errors'); + const errorText = useErrorText(); const sectionRef = useRef(null); const triggers = useRef(new Map()); const lastRemovalId = useRef(); @@ -48,7 +50,7 @@ export function useUserProfileMfaSectionLeafController({ await onSetDefault(id); setState({ kind: 'idle' }); } catch (error) { - setState({ kind: 'idle', defaultError: error instanceof Error ? error.message : m.setDefaultError }); + setState({ kind: 'idle', defaultError: errorText(toLocalizableError(error), m.setDefaultError) }); } finally { operation.current = 'idle'; } @@ -68,7 +70,7 @@ export function useUserProfileMfaSectionLeafController({ if (error instanceof MfaCancelledError) { setState({ kind: 'idle' }); } else { - setState({ kind: 'confirmingRemoval', method, error: error instanceof Error ? error.message : errors.generic }); + setState({ kind: 'confirmingRemoval', method, error: errorText(toLocalizableError(error), errors.generic) }); } } finally { operation.current = 'idle'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.tsx new file mode 100644 index 00000000000..b5de5adbf58 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.tsx @@ -0,0 +1,83 @@ +import type { ReactNode } from 'react'; + +import { Reverification } from '../reverification/reverification'; +import { useReverificationController } from '../reverification/reverification.controller'; +import { useReverificationModel } from '../reverification/reverification.model'; +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 interface UserProfileMfaSlot { + content: ReactNode; +} + +export function UserProfileMfaSection(props: UserProfileMfaSectionProps = {}) { + return useUserProfileMfaSlot(props)?.content ?? null; +} + +export function useUserProfileMfaSlot({ fallback = null }: UserProfileMfaSectionProps = {}): UserProfileMfaSlot | null { + const model = useUserProfileMfaModel(); + if (model.status === 'loading') { + return fallback ? { content: fallback } : null; + } + if (model.status === 'hidden') { + return null; + } + return { + content: ( + + ), + }; +} + +function MfaEditor({ model }: { model: Extract }) { + const controller = useUserProfileMfaController(model); + const reverificationModel = useReverificationModel(model.reverification); + const reverificationController = useReverificationController(reverificationModel, model.resetReverification); + const separateReverification = + !controller.dialogOpen && (model.reverification.phase === 'active' || model.reverification.phase === 'retrying'); + + return ( + <> + + {model.reverification.phase === 'active' || model.reverification.phase === 'retrying' ? ( + + ) : ( + + )} + + ) : undefined + } + /> + { + if (!open && model.reverification.phase === 'active') { + model.reverification.cancel(); + } + }} + hideTrigger + > + + + + ); +} 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..98d4c07c2c3 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,6 +4,7 @@ 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 { UserProfileMfaSlot } from './user-profile-mfa-section'; import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view'; import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; @@ -13,6 +14,7 @@ export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMeth export interface UserProfileSecurityPanelViewProps { passwordSlot?: ReactNode; passkeysSlot?: ReactNode; + mfaSlot?: UserProfileMfaSlot | null; mfaMethods?: UserProfileMfaMethod[]; addableMfaMethods?: readonly UserProfileMfaAddableMethod[]; mfaAddControl?: ReactNode; @@ -26,6 +28,7 @@ export interface UserProfileSecurityPanelViewProps { export function UserProfileSecurityPanelView({ passwordSlot, passkeysSlot, + mfaSlot, mfaMethods, addableMfaMethods, mfaAddControl, @@ -35,7 +38,8 @@ export function UserProfileSecurityPanelView({ onRemoveMfaMethod, onSetDefaultMfaMethod, }: UserProfileSecurityPanelViewProps): ReactElement { - const hasAuthentication = passwordSlot != null || Boolean(passkeysSlot) || mfaMethods !== undefined; + const hasAuthentication = + passwordSlot != null || Boolean(passkeysSlot) || (mfaSlot !== undefined ? Boolean(mfaSlot) : mfaMethods !== undefined); return ( }> @@ -45,7 +49,8 @@ export function UserProfileSecurityPanelView({ {passwordSlot} {passkeysSlot} - {mfaMethods !== undefined ? ( + {mfaSlot?.content} + {mfaSlot === undefined && mfaMethods !== undefined ? ( Date: Sat, 3 Oct 2026 13:32:32 -0600 Subject: [PATCH 05/26] feat(swingset): add the live MFA sandbox --- .../src/app/(clerk)/live/mfa/page.tsx | 34 +++++++++++++++++++ packages/swingset/src/lib/live-navigation.ts | 1 + .../user-profile-mfa-section.stories.tsx | 1 - 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 packages/swingset/src/app/(clerk)/live/mfa/page.tsx diff --git a/packages/swingset/src/app/(clerk)/live/mfa/page.tsx b/packages/swingset/src/app/(clerk)/live/mfa/page.tsx new file mode 100644 index 00000000000..9fb93b26b1d --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/mfa/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { UserProfileMfaSection } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section'; +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function MfaLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

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/user-profile-mfa-section.stories.tsx b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx index 33631c6bb02..034f8338bec 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -9,7 +9,6 @@ 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' }, From 2ea17254ba81ada033fddb74d9ac34eb4f2bb64c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:36:35 -0600 Subject: [PATCH 06/26] test(mosaic): cover SMS retries after phone input edits --- .../src/__tests__/feature/fake-fapi/mfa.ts | 15 +++++ .../user-profile-mfa.feature.test.tsx | 61 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts index cbdb2517b57..0a360d0977b 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts @@ -172,6 +172,21 @@ export function mfaHandlers( http.post(fapiUrl('/v1/me/phone_numbers/'), async ({ request }) => { const number = new URLSearchParams(await request.text()).get('phone_number') ?? ''; state.mfa.phoneCreations.push(number); + if (active()?.user.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(active()?.user.phone_numbers.map(phone => phone.id)); let nextPhoneNumber = state.mfa.phoneCreations.length; while (existingIds.has(`phone_${nextPhoneNumber}`)) { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx index ab786b334a8..776a9f75989 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx @@ -363,4 +363,65 @@ describe('User profile MFA', () => { await waitFor(() => expect(fapi.mfa.phoneCreations).toHaveLength(2)); 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()); + 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' }), '123456'); + expect(await screen.findByText('CODE0001')).toBeVisible(); + expect(fapi.mfa.phoneUpdates).toEqual([{ id: 'phone_1', reserved: true, default: undefined }]); + }); }); From 9845afd16d23588312b975a2ee76c8954f2da898 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:36:42 -0600 Subject: [PATCH 07/26] fix(mosaic): reuse account phones when retrying SMS setup --- .changeset/bright-mfa-panel.md | 3 +++ .../user-profile-mfa-section.controller.ts | 12 ++++++------ .../user-profile/user-profile-mfa-section.model.ts | 9 ++++++--- .../user-profile/user-profile-mfa-section.types.ts | 2 +- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.changeset/bright-mfa-panel.md b/.changeset/bright-mfa-panel.md index a845151cc84..dc7fb65bb08 100644 --- a/.changeset/bright-mfa-panel.md +++ b/.changeset/bright-mfa-panel.md @@ -1,2 +1,5 @@ --- +'@clerk/mosaic': patch --- + +Add UserProfile management for authenticator apps, SMS verification, and backup codes. SMS setup can retry after a failed code delivery, including after editing and restoring the phone number, without creating duplicate phone numbers. diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.controller.ts index c79e1f2cc91..b0927660d1b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.controller.ts @@ -47,7 +47,7 @@ type MfaEvent = | { type: 'EDIT_PHONE_ID'; value: string } | { type: 'EDIT_PHONE_NUMBER'; value: string } | { type: 'EDIT_CODE'; value: string } - | { type: 'PHONE_CREATED'; id: string; phoneNumber: string } + | { type: 'PHONE_RESOLVED'; id: string; phoneNumber: string } | { type: 'PRINT_FAILED'; message: string } | { type: 'RUN'; key: string; run: () => Promise; resolve: () => void; reject: (error: unknown) => void }; @@ -150,7 +150,7 @@ const mfaMachine = createMachine({ }, busy: { on: { - PHONE_CREATED: { + PHONE_RESOLVED: { actions: assign((context, event) => ({ flow: context.flow.kind === 'sms' @@ -304,12 +304,12 @@ export function useUserProfileMfaController(model: ReadyModel) { void run('sms', async () => { let phoneId = current.phoneId ?? current.selectedPhoneId; if (current.step === 'phone' && !phoneId) { - const created = await model.createPhone?.(current.phoneNumber); - if (!created) { + const phone = await model.findOrCreatePhone?.(current.phoneNumber); + if (!phone) { return current; } - phoneId = created.id; - send({ type: 'PHONE_CREATED', id: created.id, phoneNumber: created.phoneNumber }); + phoneId = phone.id; + send({ type: 'PHONE_RESOLVED', id: phone.id, phoneNumber: phone.phoneNumber }); } const result = await model.enrollSms?.(phoneId, code ?? (current.step === 'verify' ? current.code : undefined)); if (result?.status === 'needsVerification') { diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.model.ts index e2830e11330..6eeaf334ca4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.model.ts @@ -217,11 +217,14 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { phones: user.phoneNumbers.filter(phone => !phone.reservedForSecondFactor).map(summarizePhone), reverification, resetReverification, - createPhone: smsEnabled + findOrCreatePhone: smsEnabled ? async phoneNumber => { try { - const created = await requireIdentity(userId, sessionId).createPhoneNumber({ phoneNumber }); - return summarizePhone(created); + const current = requireIdentity(userId, sessionId); + const existing = current.phoneNumbers.find( + phone => !phone.reservedForSecondFactor && phone.phoneNumber === phoneNumber, + ); + return summarizePhone(existing ?? (await current.createPhoneNumber({ phoneNumber }))); } catch (error) { throw errorMessage(error, localize); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.types.ts index 20a7e5a7198..52dfe0ae395 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.types.ts @@ -40,7 +40,7 @@ export type UserProfileMfaModel = phones: readonly MfaPhone[]; reverification: ReverificationState; resetReverification: () => void; - createPhone?: (phoneNumber: string) => Promise; + findOrCreatePhone?: (phoneNumber: string) => Promise; enrollSms?: (phoneId: string, code?: string) => Promise; resendSms?: (phoneId: string) => Promise; createAuthenticator?: () => Promise<{ secret: string; uri: string }>; From a21fa7a21b6b03b90b8456674c34bb347bef4f3d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 15:20:54 -0600 Subject: [PATCH 08/26] test(mosaic): wait for SMS retry input to become editable --- .../user-profile/__tests__/user-profile-mfa.feature.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx index 776a9f75989..945a2f86c41 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx @@ -412,6 +412,7 @@ describe('User profile MFA', () => { 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).toBeEnabled()); serveFapi(fapi); } await user.clear(number); From fc48381adda1da034fd5ff206a6960f12e96302d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 15:48:44 -0600 Subject: [PATCH 09/26] test(mosaic): await completed SMS preparations before ending tests --- .../user-profile/__tests__/user-profile-mfa.feature.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx index 945a2f86c41..76bbbbded86 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx @@ -196,6 +196,7 @@ describe('User profile MFA', () => { 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' }), '123456'); @@ -361,6 +362,7 @@ describe('User profile MFA', () => { 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']); }); From 0990598bf6083f0856be2e672202114fb8477e54 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 5 Oct 2026 23:02:26 -0600 Subject: [PATCH 10/26] refactor(mosaic): group MFA section files --- .../user-profile/__tests__/mfa-feature-setup.tsx | 2 +- ...ser-profile-mfa-interactions.feature.test.tsx | 2 +- .../user-profile-mfa-management.feature.test.tsx | 2 +- .../__tests__/user-profile-mfa.feature.test.tsx | 2 +- .../user-profile-add-authenticator.messages.ts | 0 .../user-profile-add-authenticator.styles.ts | 2 +- .../user-profile-add-authenticator.view.tsx | 14 +++++++------- .../user-profile-add-mfa.dialog.tsx | 10 +++++----- .../user-profile-add-mfa.view.tsx | 10 +++++----- .../user-profile-add-sms.messages.ts | 0 .../user-profile-add-sms.view.tsx | 14 +++++++------- .../user-profile-authenticator-setup.messages.ts | 0 .../user-profile-authenticator-setup.styles.ts | 0 .../user-profile-authenticator-setup.view.tsx | 12 ++++++------ .../user-profile-backup-codes.messages.ts | 0 .../user-profile-backup-codes.styles.ts | 2 +- .../user-profile-backup-codes.view.tsx | 16 ++++++++-------- .../user-profile-mfa-row.view.tsx | 12 ++++++------ .../user-profile-mfa-section-leaf.controller.ts | 6 +++--- .../user-profile-mfa-section.controller.ts | 6 +++--- .../user-profile-mfa-section.messages.ts | 0 .../user-profile-mfa-section.model.ts | 6 +++--- .../user-profile-mfa-section.tsx | 6 +++--- .../user-profile-mfa-section.types.ts | 2 +- .../user-profile-mfa-section.view.tsx | 8 ++++---- .../user-profile-mfa-setup.view.tsx | 2 +- .../user-profile-security-panel.view.tsx | 9 ++++++--- packages/mosaic/src/localization/registry.ts | 10 +++++----- packages/mosaic/src/styles/index.ts | 4 ++-- .../swingset/src/app/(clerk)/live/mfa/page.tsx | 2 +- .../fixtures/user-profile-mfa-example.tsx | 4 ++-- .../src/stories/fixtures/user-profile-mfa.ts | 4 ++-- .../stories/user-profile-mfa-section.stories.tsx | 4 ++-- 33 files changed, 88 insertions(+), 85 deletions(-) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-add-authenticator.messages.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-add-authenticator.styles.ts (85%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-add-authenticator.view.tsx (90%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-add-mfa.dialog.tsx (85%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-add-mfa.view.tsx (86%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-add-sms.messages.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-add-sms.view.tsx (93%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-authenticator-setup.messages.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-authenticator-setup.styles.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-authenticator-setup.view.tsx (86%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-backup-codes.messages.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-backup-codes.styles.ts (98%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-backup-codes.view.tsx (90%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-mfa-row.view.tsx (83%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-mfa-section-leaf.controller.ts (95%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-mfa-section.controller.ts (99%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-mfa-section.messages.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-mfa-section.model.ts (98%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-mfa-section.tsx (91%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-mfa-section.types.ts (95%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-mfa-section.view.tsx (93%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/user-profile-mfa-setup.view.tsx (98%) diff --git a/packages/mosaic/src/features/user-profile/__tests__/mfa-feature-setup.tsx b/packages/mosaic/src/features/user-profile/__tests__/mfa-feature-setup.tsx index 400e64cd135..33a6411dd86 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/mfa-feature-setup.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/mfa-feature-setup.tsx @@ -1,7 +1,7 @@ import { serveFapi } 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 { UserProfileMfaSection } from '../user-profile-mfa-section/user-profile-mfa-section'; export const phone = fapiPhoneNumber({ id: 'phone_1', phone_number: '+15555550101' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-interactions.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-interactions.feature.test.tsx index ae8f3499ab7..a184a781f1b 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-interactions.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-interactions.feature.test.tsx @@ -6,7 +6,7 @@ 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 { UserProfileMfaSection } from '../user-profile-mfa-section/user-profile-mfa-section'; import { mfaEnvironment, renderMfa } from './mfa-feature-setup'; describe('User profile MFA interaction boundaries', () => { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx index 499a8188a2c..74f55be5429 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; 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 { UserProfileMfaSection, useUserProfileMfaSlot } from '../user-profile-mfa-section'; +import { UserProfileMfaSection, useUserProfileMfaSlot } from '../user-profile-mfa-section/user-profile-mfa-section'; import { useUserProfilePasswordSlot } from '../user-profile-password-section/user-profile-password-section'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; import { mfaEnvironment, phone, renderMfa } from './mfa-feature-setup'; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx index 76bbbbded86..d39e97955b8 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { fapiUrl, holdRequests, serveFapi, 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 { UserProfileMfaSection } from '../user-profile-mfa-section/user-profile-mfa-section'; import { mfaEnvironment, phone, renderMfa } from './mfa-feature-setup'; describe('User profile MFA', () => { 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 90% 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 63934350a55..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'; 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 85% 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 ad5aef59179..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,11 +1,11 @@ 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; diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-mfa.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-mfa.view.tsx similarity index 86% rename from packages/mosaic/src/features/user-profile/user-profile-add-mfa.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-mfa.view.tsx index 1b36a61399a..96ea7a19752 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-mfa.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-mfa.view.tsx @@ -1,8 +1,8 @@ -import { Card } from '../../components/card'; -import { useFlowAutoFocus } from '../../components/flow'; -import { Icon, IconFrame } from '../../components/icon'; -import { Item } from '../../components/item'; -import { useMessages } from '../../localization'; +import { Card } from '../../../components/card'; +import { useFlowAutoFocus } from '../../../components/flow'; +import { Icon, IconFrame } from '../../../components/icon'; +import { Item } from '../../../components/item'; +import { useMessages } from '../../../localization'; import type { UserProfileMfaAddableMethod } from './user-profile-mfa-section.view'; export interface UserProfileAddMfaViewProps { diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-sms.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-sms.messages.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-add-sms.messages.ts rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-sms.messages.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-add-sms.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-sms.view.tsx similarity index 93% rename from packages/mosaic/src/features/user-profile/user-profile-add-sms.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-sms.view.tsx index 49e30381f56..d3653871c4b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-add-sms.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-sms.view.tsx @@ -3,13 +3,13 @@ import { useMergeRefs } from '@floating-ui/react'; import type { Ref } from 'react'; import { useId } from 'react'; -import { Button, SubmitButton } from '../../components/button'; -import { Card } from '../../components/card'; -import { Field } from '../../components/field'; -import { Flow, type FlowDirection, useFlowAutoFocus } from '../../components/flow'; -import { Select } from '../../components/select'; -import { useMessages } from '../../localization'; -import { EnterPhoneStep, VerifyPhoneStep } from './user-profile-phone.steps'; +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Field } from '../../../components/field'; +import { Flow, type FlowDirection, useFlowAutoFocus } from '../../../components/flow'; +import { Select } from '../../../components/select'; +import { useMessages } from '../../../localization'; +import { EnterPhoneStep, VerifyPhoneStep } from '../user-profile-phone.steps'; export interface UserProfileAddSmsViewProps { step: 'select' | 'phone' | 'verify'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-authenticator-setup.messages.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.messages.ts rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-authenticator-setup.messages.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-authenticator-setup.styles.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.styles.ts rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-authenticator-setup.styles.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-authenticator-setup.view.tsx similarity index 86% rename from packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-authenticator-setup.view.tsx index e02aa14ed38..2a137e6c2e6 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-authenticator-setup.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-authenticator-setup.view.tsx @@ -2,12 +2,12 @@ import * as stylex from '@stylexjs/stylex'; import { QRCodeSVG } from 'qrcode.react'; import { useState } from 'react'; -import { Button } from '../../components/button'; -import { Card } from '../../components/card'; -import { CopyButton } from '../../components/copy-button'; -import { Field } from '../../components/field'; -import { InputGroup } from '../../components/input-group'; -import { useMessages } from '../../localization'; +import { Button } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { CopyButton } from '../../../components/copy-button'; +import { Field } from '../../../components/field'; +import { InputGroup } from '../../../components/input-group'; +import { useMessages } from '../../../localization'; import { styles } from './user-profile-authenticator-setup.styles'; export interface UserProfileAuthenticatorSetupViewProps { diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.messages.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-backup-codes.messages.ts rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.messages.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.styles.ts similarity index 98% rename from packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.styles.ts index de07661a95f..12c334b5d1b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, easingVars, radiusVars, space } from '../../tokens.stylex'; +import { colorVars, easingVars, radiusVars, space } from '../../../tokens.stylex'; const pulse = stylex.keyframes({ '50%': { opacity: 0.5 }, diff --git a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx similarity index 90% rename from packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx index c41f7f74390..56a03b99b76 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-backup-codes.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx @@ -1,13 +1,13 @@ import * as stylex from '@stylexjs/stylex'; -import { Button, SubmitButton } from '../../components/button'; -import { Card } from '../../components/card'; -import { useFlowAutoFocus } from '../../components/flow'; -import { Icon } from '../../components/icon'; -import { Text } from '../../components/text'; -import { useMessages } from '../../localization'; -import { mergeStyleProps, themeProps } from '../../props'; -import { reset } from '../../styles/reset.styles'; +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { useFlowAutoFocus } from '../../../components/flow'; +import { Icon } from '../../../components/icon'; +import { Text } from '../../../components/text'; +import { useMessages } from '../../../localization'; +import { mergeStyleProps, themeProps } from '../../../props'; +import { reset } from '../../../styles/reset.styles'; import { styles } from './user-profile-backup-codes.styles'; export interface UserProfileBackupCodesViewProps { 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 83% 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..56c28c62873 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,12 @@ 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, diff --git a/packages/mosaic/src/features/user-profile/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 similarity index 95% rename from packages/mosaic/src/features/user-profile/user-profile-mfa-section-leaf.controller.ts rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section-leaf.controller.ts index c7473b6bd23..36a88684b4b 100644 --- a/packages/mosaic/src/features/user-profile/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 @@ -1,8 +1,8 @@ import { useRef, useState } from 'react'; -import { useListRemovalFocus } from '../../hooks/use-list-removal-focus'; -import { useErrorText, useMessages } from '../../localization'; -import { toLocalizableError } from '../../utils/errors'; +import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; +import { useErrorText, useMessages } from '../../../localization'; +import { toLocalizableError } from '../../../utils/errors'; import { MfaCancelledError, type UserProfileMfaMethod } from './user-profile-mfa-section.types'; import type { UserProfileMfaSectionViewProps } from './user-profile-mfa-section.view'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts similarity index 99% rename from packages/mosaic/src/features/user-profile/user-profile-mfa-section.controller.ts rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts index b0927660d1b..5ccc82c8c49 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts @@ -1,8 +1,8 @@ import { useEffect, useRef, useState } from 'react'; -import { useMessages } from '../../localization'; -import { setup } from '../../machine/setup'; -import { useMachine } from '../../machine/use-machine'; +import { useMessages } from '../../../localization'; +import { setup } from '../../../machine/setup'; +import { useMachine } from '../../../machine/use-machine'; import { MfaCancelledError, type UserProfileMfaAddableMethod, 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.model.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.model.ts similarity index 98% rename from packages/mosaic/src/features/user-profile/user-profile-mfa-section.model.ts rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.model.ts index 6eeaf334ca4..3a4d4847dde 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.model.ts @@ -2,9 +2,9 @@ import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/ 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 { useErrorText, useMessages } from '../../localization'; -import { useReverificationWithState } from '../reverification/use-reverification-with-state'; +import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; +import { useErrorText, useMessages } from '../../../localization'; +import { useReverificationWithState } from '../../reverification/use-reverification-with-state'; import { MfaCancelledError, type MfaEnrollmentResult, diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.tsx similarity index 91% rename from packages/mosaic/src/features/user-profile/user-profile-mfa-section.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.tsx index b5de5adbf58..2937ed47838 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react'; -import { Reverification } from '../reverification/reverification'; -import { useReverificationController } from '../reverification/reverification.controller'; -import { useReverificationModel } from '../reverification/reverification.model'; +import { Reverification } from '../../reverification/reverification'; +import { useReverificationController } from '../../reverification/reverification.controller'; +import { useReverificationModel } from '../../reverification/reverification.model'; import { UserProfileAddMfaDialog } from './user-profile-add-mfa.dialog'; import { useUserProfileMfaController } from './user-profile-mfa-section.controller'; import { useUserProfileMfaModel } from './user-profile-mfa-section.model'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.types.ts similarity index 95% rename from packages/mosaic/src/features/user-profile/user-profile-mfa-section.types.ts rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.types.ts index 52dfe0ae395..3078653a347 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.types.ts @@ -1,4 +1,4 @@ -import type { ReverificationState } from '../reverification/reverification.types'; +import type { ReverificationState } from '../../reverification/reverification.types'; export interface UserProfileMfaMethod { id: string; 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 93% 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 ad431a2c155..25485faeb34 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,14 +1,14 @@ import { type ReactNode, type Ref } from 'react'; -import { Confirmation } from '../../blocks/confirmation'; -import { Section } from '../../components/section'; -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 type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.types'; import { useUserProfileMfaSectionLeafController } from './user-profile-mfa-section-leaf.controller'; -import { UserProfileSecurityList } from './user-profile-security-list'; export type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.types'; 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 98% 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 7cb6e55f2fd..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'; 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 98d4c07c2c3..3db4e993278 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,9 +4,12 @@ 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 { UserProfileMfaSlot } from './user-profile-mfa-section'; -import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view'; -import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; +import type { UserProfileMfaSlot } from './user-profile-mfa-section/user-profile-mfa-section'; +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 }; diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index c9632ad9c3c..bb44ea2e420 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -14,14 +14,14 @@ import { userProfileAccountSectionMessages } from '../features/user-profile/user import { userProfileAddEmailMessages } from '../features/user-profile/user-profile-account-section/user-profile-add-email.messages'; import { userProfileAddPhoneMessages } from '../features/user-profile/user-profile-account-section/user-profile-add-phone.messages'; import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages'; -import { userProfileAddAuthenticatorMessages } from '../features/user-profile/user-profile-add-authenticator.messages'; -import { userProfileAddSmsMessages } from '../features/user-profile/user-profile-add-sms.messages'; -import { userProfileAuthenticatorSetupMessages } from '../features/user-profile/user-profile-authenticator-setup.messages'; -import { userProfileBackupCodesMessages } from '../features/user-profile/user-profile-backup-codes.messages'; import { userProfileConnectedAccountsMessages } from '../features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.messages'; import { userProfileDangerSectionMessages } from '../features/user-profile/user-profile-danger-section/user-profile-danger-section.messages'; import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages'; -import { userProfileMfaMessages } from '../features/user-profile/user-profile-mfa-section.messages'; +import { userProfileAddAuthenticatorMessages } from '../features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.messages'; +import { userProfileAddSmsMessages } from '../features/user-profile/user-profile-mfa-section/user-profile-add-sms.messages'; +import { userProfileAuthenticatorSetupMessages } from '../features/user-profile/user-profile-mfa-section/user-profile-authenticator-setup.messages'; +import { userProfileBackupCodesMessages } from '../features/user-profile/user-profile-mfa-section/user-profile-backup-codes.messages'; +import { userProfileMfaMessages } from '../features/user-profile/user-profile-mfa-section/user-profile-mfa-section.messages'; import { userProfilePasskeysMessages } from '../features/user-profile/user-profile-passkeys-section.messages'; import { userProfilePasswordSectionMessages } from '../features/user-profile/user-profile-password-section/user-profile-password-section.messages'; import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.messages'; diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index 680c6f35707..4bde7fd1e02 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -224,8 +224,8 @@ export type SpacingVarName = keyof typeof spacingVars; export type TargetVarName = keyof typeof targetVars; export type TypeScaleVarName = keyof typeof typeScaleVars; export { mergeStyleProps, themeProps } from '../props'; -export { UserProfileMfaSectionView } from '../features/user-profile/user-profile-mfa-section.view'; -export { UserProfileMfaSetupView } from '../features/user-profile/user-profile-mfa-setup.view'; +export { UserProfileMfaSectionView } from '../features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view'; +export { UserProfileMfaSetupView } from '../features/user-profile/user-profile-mfa-section/user-profile-mfa-setup.view'; export { APIKeysTableView } from '../features/api-keys/api-keys-table.view'; export { UserProfileEnterpriseAccountsSectionView } from '../features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view'; diff --git a/packages/swingset/src/app/(clerk)/live/mfa/page.tsx b/packages/swingset/src/app/(clerk)/live/mfa/page.tsx index 9fb93b26b1d..c95035d7ef8 100644 --- a/packages/swingset/src/app/(clerk)/live/mfa/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/mfa/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { UserProfileMfaSection } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section'; +import { UserProfileMfaSection } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section/user-profile-mfa-section'; import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; import { useUser } from '@clerk/nextjs'; import Link from 'next/link'; 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..7d808b20fdc 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts @@ -2,8 +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'; +} 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 { stringToFormattedPhoneString } from '@clerk/shared/phone'; import { useEffect, useState } from 'react'; 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 034f8338bec..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'; @@ -12,7 +12,7 @@ export const meta: StoryMeta = { 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() { From 654dc91425a2a756f98477ebc3035f22aaed9764 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 5 Oct 2026 23:02:28 -0600 Subject: [PATCH 11/26] refactor(mosaic): simplify MFA composition and cover recovery --- .changeset/mfa-section-feedback.md | 2 + ...er-profile-mfa-management.feature.test.tsx | 128 +++++++++++- .../user-profile-mfa-section.view.test.tsx | 193 ------------------ .../user-profile-mfa-section.controller.ts | 22 +- .../user-profile-mfa-section.model.ts | 19 +- .../user-profile-mfa-section.tsx | 48 ++--- .../user-profile-security-panel.view.tsx | 33 +-- 7 files changed, 186 insertions(+), 259 deletions(-) create mode 100644 .changeset/mfa-section-feedback.md delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx diff --git a/.changeset/mfa-section-feedback.md b/.changeset/mfa-section-feedback.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mfa-section-feedback.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx index 74f55be5429..1ee95f326ae 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx @@ -1,17 +1,53 @@ -import { screen, waitFor, within } from '@testing-library/react'; +import { render, 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 { UserProfileMfaSection, useUserProfileMfaSlot } from '../user-profile-mfa-section/user-profile-mfa-section'; +import { MosaicProvider } from '../../../mosaic-provider'; +import { mfaSectionNode, UserProfileMfaSection } from '../user-profile-mfa-section/user-profile-mfa-section'; +import { useUserProfileMfaModel } from '../user-profile-mfa-section/user-profile-mfa-section.model'; +import { UserProfileMfaSectionView } from '../user-profile-mfa-section/user-profile-mfa-section.view'; import { useUserProfilePasswordSlot } from '../user-profile-password-section/user-profile-password-section'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; import { mfaEnvironment, phone, renderMfa } from './mfa-feature-setup'; describe('User profile MFA management', () => { + 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('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 })); @@ -28,6 +64,26 @@ describe('User profile MFA management', () => { expect(fapi.client.sessions[0]?.user.phone_numbers).toHaveLength(1); }); + it('moves focus through remaining MFA rows and then to Add after sequential removals', async () => { + const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const second = fapiPhoneNumber({ 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 +15555550101' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + const remaining = await screen.findByRole('button', { name: 'Manage SMS verification +15555550202' }); + await waitFor(() => expect(remaining).toHaveFocus()); + + await user.keyboard('{Enter}'); + 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('button', { name: 'Add verification method' })).toHaveFocus()); + expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_1', reserved: false, default: undefined }); + expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_2', reserved: false, default: undefined }); + }); + it('shows second-factor reverification for removal with no addable methods', async () => { const environment = mfaEnvironment(); environment.user_settings.attributes.phone_number.used_for_second_factor = false; @@ -123,7 +179,7 @@ describe('User profile MFA management', () => { it('places a connected MFA slot after password in the security panel', async () => { function Panel() { - const mfaSlot = useUserProfileMfaSlot(); + const mfaSlot = mfaSectionNode(useUserProfileMfaModel()); const passwordSlot = useUserProfilePasswordSlot(); return ( { it('uses the connected slot instead of duplicate injected MFA props', async () => { function Panel() { - const mfaSlot = useUserProfileMfaSlot(); + const mfaSlot = mfaSectionNode(useUserProfileMfaModel()); return ( { it('suppresses injected MFA when the connected slot is hidden', async () => { function Panel() { - const mfaSlot = useUserProfileMfaSlot(); + const mfaSlot = mfaSectionNode(useUserProfileMfaModel()); return ( { expect(screen.getAllByText('SMS verification')[0]?.closest('li')).toHaveTextContent('Default'); }); + 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({ 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 +15555550202' }); + + 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); + }); + + 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 user = userEvent.setup(); + render( + + Promise.reject(cause)} + /> + , + ); + + await user.click(screen.getByRole('button', { name: 'Manage SMS verification' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(message)); + }); + it('keeps method menus available but hides default changes while one is pending', async () => { const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); const second = fapiPhoneNumber({ id: 'phone_2', phone_number: '+15555550202', reserved_for_second_factor: true }); 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/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 index 5ccc82c8c49..d49e0d05570 100644 --- 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 @@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from 'react'; import { useMessages } from '../../../localization'; import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/use-machine'; +import { useReverificationController } from '../../reverification/reverification.controller'; +import type { ReverificationModel } from '../../reverification/reverification.model'; import { MfaCancelledError, type UserProfileMfaAddableMethod, @@ -212,8 +214,9 @@ const mfaMachine = createMachine({ }, }); -export function useUserProfileMfaController(model: ReadyModel) { +export function useUserProfileMfaController(model: ReadyModel, reverificationModel: ReverificationModel) { const m = useMessages('userProfileMfa'); + const reverificationProps = useReverificationController(reverificationModel, model.resetReverification); const [{ context, value }, send] = useMachine(mfaMachine); const locked = useRef(false); const [now, setNow] = useState(() => Date.now()); @@ -457,9 +460,22 @@ export function useUserProfileMfaController(model: ReadyModel) { onSetDefault: setDefault, }; + const dialogOpen = flow.kind !== 'closed'; + const showAddTrigger = model.addableMethods.length > 0; + const showReverification = model.reverification.phase === 'active' || model.reverification.phase === 'retrying'; + return { - dialogOpen: flow.kind !== 'closed', - showAddTrigger: model.addableMethods.length > 0, + dialogOpen, + showAddTrigger, + showAddControl: showAddTrigger || dialogOpen, + showReverification, + separateReverificationOpen: !dialogOpen && showReverification, + onSeparateReverificationOpenChange: (open: boolean) => { + if (!open && model.reverification.phase === 'active') { + model.reverification.cancel(); + } + }, + reverificationProps, onDialogOpenChange: (open: boolean) => { if (!open && model.reverification.phase === 'active') { model.reverification.cancel(); 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 index 3a4d4847dde..50925063be3 100644 --- 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 @@ -4,6 +4,7 @@ import type { EnvironmentResource, PhoneNumberResource, UserResource } from '@cl import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useErrorText, useMessages } from '../../../localization'; +import { SaveError } from '../../../utils/errors'; import { useReverificationWithState } from '../../reverification/use-reverification-with-state'; import { MfaCancelledError, @@ -73,7 +74,7 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { const requireIdentity = (userId: string, sessionId: string): UserResource => { const current = clerk.user; if (!current || current.id !== userId || clerk.session?.id !== sessionId) { - throw new Error(m.errors.accountChanged); + throw new SaveError({ global: { code: 'mfa_account_changed', message: m.errors.accountChanged } }); } return current; }; @@ -203,7 +204,7 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { const current = requireIdentity(userId, sessionId); const phone = current.phoneNumbers.find(item => item.id === phoneId); if (!phone) { - throw new Error(m.errors.phoneUnavailable); + throw new SaveError({ global: { code: 'mfa_phone_unavailable', message: m.errors.phoneUnavailable } }); } return phone; }; @@ -304,16 +305,12 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { }, setDefault: smsEnabled ? async phoneId => { - try { - const phone = currentPhone(phoneId); - if (phone.verification.status !== 'verified' || !phone.reservedForSecondFactor) { - throw new Error(m.errors.phoneUnavailable); - } - await phone.makeDefaultSecondFactor(); - await refresh().catch(() => undefined); - } catch (error) { - throw errorMessage(error, localize); + const phone = currentPhone(phoneId); + if (phone.verification.status !== 'verified' || !phone.reservedForSecondFactor) { + throw new SaveError({ global: { code: 'mfa_phone_unavailable', message: m.errors.phoneUnavailable } }); } + await phone.makeDefaultSecondFactor(); + await refresh().catch(() => undefined); } : 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 index 2937ed47838..de974fadb17 100644 --- 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 @@ -1,7 +1,6 @@ import type { ReactNode } from 'react'; import { Reverification } from '../../reverification/reverification'; -import { useReverificationController } from '../../reverification/reverification.controller'; import { useReverificationModel } from '../../reverification/reverification.model'; import { UserProfileAddMfaDialog } from './user-profile-add-mfa.dialog'; import { useUserProfileMfaController } from './user-profile-mfa-section.controller'; @@ -14,52 +13,43 @@ export interface UserProfileMfaSectionProps { fallback?: ReactNode; } -export interface UserProfileMfaSlot { - content: ReactNode; -} - export function UserProfileMfaSection(props: UserProfileMfaSectionProps = {}) { - return useUserProfileMfaSlot(props)?.content ?? null; + const model = useUserProfileMfaModel(); + return mfaSectionNode(model, props.fallback); } -export function useUserProfileMfaSlot({ fallback = null }: UserProfileMfaSectionProps = {}): UserProfileMfaSlot | null { - const model = useUserProfileMfaModel(); +export function mfaSectionNode(model: UserProfileMfaModel, fallback: ReactNode = null): ReactNode { if (model.status === 'loading') { - return fallback ? { content: fallback } : null; + return fallback || null; } if (model.status === 'hidden') { return null; } - return { - content: ( - - ), - }; + return ( + + ); } function MfaEditor({ model }: { model: Extract }) { - const controller = useUserProfileMfaController(model); const reverificationModel = useReverificationModel(model.reverification); - const reverificationController = useReverificationController(reverificationModel, model.resetReverification); - const separateReverification = - !controller.dialogOpen && (model.reverification.phase === 'active' || model.reverification.phase === 'retrying'); + const controller = useUserProfileMfaController(model, reverificationModel); return ( <> - {model.reverification.phase === 'active' || model.reverification.phase === 'retrying' ? ( - + {controller.showReverification ? ( + ) : ( )} @@ -68,15 +58,11 @@ function MfaEditor({ model }: { model: Extract { - if (!open && model.reverification.phase === 'active') { - model.reverification.cancel(); - } - }} + open={controller.separateReverificationOpen} + onOpenChange={controller.onSeparateReverificationOpenChange} hideTrigger > - + ); 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 3db4e993278..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,7 +4,6 @@ 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 { UserProfileMfaSlot } from './user-profile-mfa-section/user-profile-mfa-section'; import type { UserProfileMfaAddableMethod, UserProfileMfaMethod, @@ -17,7 +16,7 @@ export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMeth export interface UserProfileSecurityPanelViewProps { passwordSlot?: ReactNode; passkeysSlot?: ReactNode; - mfaSlot?: UserProfileMfaSlot | null; + mfaSlot?: ReactNode; mfaMethods?: UserProfileMfaMethod[]; addableMfaMethods?: readonly UserProfileMfaAddableMethod[]; mfaAddControl?: ReactNode; @@ -41,8 +40,21 @@ export function UserProfileSecurityPanelView({ onRemoveMfaMethod, onSetDefaultMfaMethod, }: UserProfileSecurityPanelViewProps): ReactElement { - const hasAuthentication = - passwordSlot != null || Boolean(passkeysSlot) || (mfaSlot !== undefined ? Boolean(mfaSlot) : mfaMethods !== undefined); + const selectedMfaNode = + mfaSlot !== undefined ? ( + mfaSlot + ) : mfaMethods !== undefined ? ( + + ) : null; + const hasAuthentication = passwordSlot != null || Boolean(passkeysSlot) || Boolean(selectedMfaNode); return ( }> @@ -52,18 +64,7 @@ export function UserProfileSecurityPanelView({ {passwordSlot} {passkeysSlot} - {mfaSlot?.content} - {mfaSlot === undefined && mfaMethods !== undefined ? ( - - ) : null} + {selectedMfaNode} ) : null} {activeDevicesSlot} From 6f06018e26453b4473fa0a03497eff77dfe94477 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 6 Oct 2026 10:41:06 -0600 Subject: [PATCH 12/26] fix(mosaic): preserve safe localized MFA errors --- .changeset/mfa-section-feedback.md | 3 ++ ...er-profile-mfa-management.feature.test.tsx | 48 +++++++++++++++++++ .../user-profile-mfa-section.model.ts | 44 +++++++++-------- 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/.changeset/mfa-section-feedback.md b/.changeset/mfa-section-feedback.md index a845151cc84..46875c33440 100644 --- a/.changeset/mfa-section-feedback.md +++ b/.changeset/mfa-section-feedback.md @@ -1,2 +1,5 @@ --- +'@clerk/mosaic': patch --- + +Keep localized MFA error messages when a verification method cannot be removed, and show a generic message for unexpected setup failures. diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx index 1ee95f326ae..f7f9851899e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx @@ -16,6 +16,54 @@ import { UserProfileSecurityPanelView } from '../user-profile-security-panel.vie import { mfaEnvironment, phone, renderMfa } from './mfa-feature-setup'; describe('User profile MFA management', () => { + 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('renders a plain MFA node in Authentication over legacy methods', async () => { serveFapi({ environment: mfaEnvironment(), 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 index 50925063be3..5287ac7aa1c 100644 --- 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 @@ -1,10 +1,10 @@ -import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; +import { isReverificationCancelledError } from '@clerk/shared/error'; 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 { useErrorText, useMessages } from '../../../localization'; -import { SaveError } from '../../../utils/errors'; +import { isLocalizableError, useErrorText, useMessages } from '../../../localization'; +import { SaveError, toLocalizableError } from '../../../utils/errors'; import { useReverificationWithState } from '../../reverification/use-reverification-with-state'; import { MfaCancelledError, @@ -52,15 +52,13 @@ function errorMessage(error: unknown, localize: ReturnType) if (error instanceof MfaCancelledError) { return error; } - if (isClerkAPIResponseError(error)) { - const first = error.errors[0]; - if (first) { - return new Error( - localize({ code: first.code, paramName: first.meta?.paramName, message: first.longMessage || first.message }), - ); - } - } - return new Error(localize({ message: error instanceof Error ? error.message : undefined })); + const description = toLocalizableError(error); + return new SaveError({ + global: { + ...(isLocalizableError(description) ? description : { code: 'generic' }), + message: localize(description), + }, + }); } export function useUserProfileMfaModel(): UserProfileMfaModel { @@ -86,7 +84,9 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { switch (operation.kind) { case 'createAuthenticator': { if (current.totpEnabled || !currentSecondFactors.includes('totp')) { - throw new Error(m.errors.authenticatorUnavailable); + throw new SaveError({ + global: { code: 'mfa_authenticator_unavailable', message: m.errors.authenticatorUnavailable }, + }); } const result = await current.createTOTP(); return { kind: 'authenticatorSetup', secret: result.secret ?? '', uri: result.uri ?? '' } as const; @@ -99,7 +99,7 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { phone.reservedForSecondFactor || !currentSecondFactors.includes('phone_code') ) { - throw new Error(m.errors.phoneUnavailable); + throw new SaveError({ global: { code: 'mfa_phone_unavailable', message: m.errors.phoneUnavailable } }); } const result = await phone.setReservedForSecondFactor({ reserved: true }); return { kind: 'enrollment', backupCodes: result.backupCodes ?? [] } as const; @@ -107,7 +107,7 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { case 'removeAuthenticator': { const usableCount = usableFactorCount(current, currentSecondFactors); if (!current.totpEnabled || (environment?.userSettings.signUp.mfa?.required && usableCount <= 1)) { - throw new Error(m.errors.methodCannotRemove); + throw new SaveError({ global: { code: 'mfa_method_cannot_remove', message: m.errors.methodCannotRemove } }); } await current.disableTOTP(); return { kind: 'done' } as const; @@ -120,14 +120,14 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { phone.verification.status !== 'verified' || (environment?.userSettings.signUp.mfa?.required && usableCount <= 1) ) { - throw new Error(m.errors.methodCannotRemove); + throw new SaveError({ global: { code: 'mfa_method_cannot_remove', message: m.errors.methodCannotRemove } }); } await phone.setReservedForSecondFactor({ reserved: false }); return { kind: 'done' } as const; } case 'generateBackupCodes': { if (!currentSecondFactors.includes('backup_code') || usableFactorCount(current, currentSecondFactors) === 0) { - throw new Error(m.errors.setupFactorFirst); + throw new SaveError({ global: { code: 'mfa_setup_factor_first', message: m.errors.setupFactorFirst } }); } const result = await current.createBackupCode(); return { kind: 'backupCodes', codes: result.codes } as const; @@ -245,7 +245,9 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { } const result = await runProtected({ kind: 'reservePhone', userId, sessionId, phoneId }); if (result.kind !== 'enrollment') { - throw new Error(m.errors.unexpectedResponse); + throw new SaveError({ + global: { code: 'mfa_unexpected_response', message: m.errors.unexpectedResponse }, + }); } await refresh().catch(() => undefined); return { status: 'complete', backupCodes: result.backupCodes }; @@ -267,7 +269,7 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { ? async () => { const result = await runProtected({ kind: 'createAuthenticator', userId, sessionId }); if (result.kind !== 'authenticatorSetup') { - throw new Error(m.errors.unexpectedResponse); + throw new SaveError({ global: { code: 'mfa_unexpected_response', message: m.errors.unexpectedResponse } }); } return { secret: result.secret, uri: result.uri }; } @@ -289,7 +291,9 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { ? async () => { const result = await runProtected({ kind: 'generateBackupCodes', userId, sessionId }); if (result.kind !== 'backupCodes') { - throw new Error(m.errors.unexpectedResponse); + throw new SaveError({ + global: { code: 'mfa_unexpected_response', message: m.errors.unexpectedResponse }, + }); } await refresh().catch(() => undefined); return result.codes; From d48fe194be152ab4f7d2e912e75b22297d0ea523 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 6 Oct 2026 10:58:51 -0600 Subject: [PATCH 13/26] test(mosaic): await MFA refresh before fixture teardown --- ...er-profile-mfa-management.feature.test.tsx | 86 ++++++++----------- .../user-profile-mfa-section.view.test.tsx | 35 ++++++++ 2 files changed, 72 insertions(+), 49 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx index f7f9851899e..b56c28fb261 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx @@ -1,16 +1,13 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +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 { mfaSectionNode, UserProfileMfaSection } from '../user-profile-mfa-section/user-profile-mfa-section'; import { useUserProfileMfaModel } from '../user-profile-mfa-section/user-profile-mfa-section.model'; -import { UserProfileMfaSectionView } from '../user-profile-mfa-section/user-profile-mfa-section.view'; import { useUserProfilePasswordSlot } from '../user-profile-password-section/user-profile-password-section'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; import { mfaEnvironment, phone, renderMfa } from './mfa-feature-setup'; @@ -211,6 +208,7 @@ describe('User profile MFA management', () => { 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)); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); }); it('restores the selected SMS row action after cancelling removal confirmation', async () => { @@ -610,6 +608,7 @@ describe('User profile MFA management', () => { 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 () => { @@ -626,6 +625,8 @@ describe('User profile MFA management', () => { expect(screen.getAllByText('SMS verification')[0]?.closest('li')).toHaveTextContent('+15555550202'), ); expect(screen.getAllByText('SMS verification')[0]?.closest('li')).toHaveTextContent('Default'); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +15555550101' })); + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as default' })).toBeVisible()); }); it('shows a failed default change and clears its error on retry', async () => { @@ -662,56 +663,42 @@ describe('User profile MFA management', () => { 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 +15555550101' })); + await waitFor(() => expect(screen.getByRole('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 safe default-change error for $message', async ({ cause, message }) => { - const user = userEvent.setup(); - render( - - Promise.reject(cause)} - /> - , - ); - - await user.click(screen.getByRole('button', { name: 'Manage SMS verification' })); - await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); - await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(message)); - }); - - it('keeps method menus available but hides default changes while one is pending', async () => { - const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); - const second = fapiPhoneNumber({ 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('post', '/v1/me/phone_numbers/phone_2'); - const user = userEvent.setup(); - const selected = screen.getByRole('button', { name: 'Manage SMS verification +15555550202' }); - await user.click(selected); - await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); - await waitFor(() => expect(held.requests).toHaveLength(1)); - try { + { phase: 'mutation', method: 'post', path: '/v1/me/phone_numbers/phone_2', pendingPhone: '+15555550202' }, + { phase: 'refresh', method: 'get', path: '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/v1/me', pendingPhone: '+15555550101' }, + ] 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({ 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 +15555550202' }); await user.click(selected); - if (selected.getAttribute('aria-expanded') !== 'true') { - 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()); + } finally { + held.release(); } - expect(screen.queryByRole('menuitem', { name: 'Set as default' })).toBeNull(); - await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Remove method' })).toBeVisible()); - } finally { - held.release(); - } - await waitFor(() => expect(screen.getByText('+15555550202').closest('li')).toHaveTextContent('Default')); - }); + await waitFor(() => expect(screen.getByText('+15555550202').closest('li')).toHaveTextContent('Default')); + await user.keyboard('{Escape}'); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +15555550101' })); + 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( @@ -723,6 +710,7 @@ describe('User profile MFA management', () => { 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 () => { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx new file mode 100644 index 00000000000..acd508ed709 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx @@ -0,0 +1,35 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { clerkApiError } from '../../../__tests__/clerk-errors'; +import { MosaicProvider } from '../../../mosaic-provider'; +import { UserProfileMfaSectionView } from '../user-profile-mfa-section/user-profile-mfa-section.view'; + +describe('User profile MFA section view', () => { + 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 user = userEvent.setup(); + render( + + Promise.reject(cause)} + /> + , + ); + + await user.click(screen.getByRole('button', { name: 'Manage SMS verification' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(message)); + }); +}); From 4d4dabba91afd6e6a18ec47acc47a6e8557f3d08 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 6 Oct 2026 11:16:44 -0600 Subject: [PATCH 14/26] chore: keep one empty changeset --- .changeset/bright-mfa-panel.md | 3 --- .changeset/mfa-section-feedback.md | 5 ----- 2 files changed, 8 deletions(-) delete mode 100644 .changeset/mfa-section-feedback.md diff --git a/.changeset/bright-mfa-panel.md b/.changeset/bright-mfa-panel.md index dc7fb65bb08..a845151cc84 100644 --- a/.changeset/bright-mfa-panel.md +++ b/.changeset/bright-mfa-panel.md @@ -1,5 +1,2 @@ --- -'@clerk/mosaic': patch --- - -Add UserProfile management for authenticator apps, SMS verification, and backup codes. SMS setup can retry after a failed code delivery, including after editing and restoring the phone number, without creating duplicate phone numbers. diff --git a/.changeset/mfa-section-feedback.md b/.changeset/mfa-section-feedback.md deleted file mode 100644 index 46875c33440..00000000000 --- a/.changeset/mfa-section-feedback.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/mosaic': patch ---- - -Keep localized MFA error messages when a verification method cannot be removed, and show a generic message for unexpected setup failures. From 52f53a863da099dc1686c2ebf8196abd8a79f29d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 14:25:00 -0600 Subject: [PATCH 15/26] test(mosaic): use current password renderer in MFA coverage --- .../__tests__/user-profile-mfa-management.feature.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx index b56c28fb261..c035c44fc29 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx @@ -8,7 +8,8 @@ import { fapiClient, fapiPhoneNumber, fapiSession, fapiUser } from '../../../__t import { renderWithClerk } from '../../../__tests__/feature/render'; import { mfaSectionNode, UserProfileMfaSection } from '../user-profile-mfa-section/user-profile-mfa-section'; import { useUserProfileMfaModel } from '../user-profile-mfa-section/user-profile-mfa-section.model'; -import { useUserProfilePasswordSlot } from '../user-profile-password-section/user-profile-password-section'; +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'; import { mfaEnvironment, phone, renderMfa } from './mfa-feature-setup'; @@ -226,7 +227,7 @@ describe('User profile MFA management', () => { it('places a connected MFA slot after password in the security panel', async () => { function Panel() { const mfaSlot = mfaSectionNode(useUserProfileMfaModel()); - const passwordSlot = useUserProfilePasswordSlot(); + const passwordSlot = renderPasswordSection(useUserProfilePasswordModel(), null); return ( Date: Thu, 8 Oct 2026 15:28:18 -0600 Subject: [PATCH 16/26] refactor(mosaic): align MFA with shared flow patterns --- .../feature/fake-fapi-mfa.feature.test.tsx | 31 ++ .../mosaic/src/__tests__/feature/fake-fapi.ts | 2 +- .../src/__tests__/feature/fake-fapi/mfa.ts | 251 ++++++------ .../user-profile-mfa-section.view.test.tsx | 35 -- ...rofile-security-panel-mfa.feature.test.tsx | 104 +++++ .../__tests__/mfa-feature-setup.tsx | 14 +- ...-profile-mfa-interactions.feature.test.tsx | 15 +- ...er-profile-mfa-management.feature.test.tsx | 210 +++++----- .../user-profile-mfa.feature.test.tsx | 20 +- .../user-profile-backup-codes.messages.ts | 1 - .../user-profile-backup-codes.view.tsx | 4 +- ...ser-profile-mfa-section-leaf.controller.ts | 77 ++-- .../user-profile-mfa-section.controller.ts | 372 +++++++++++------- .../user-profile-mfa-section.messages.ts | 9 - .../user-profile-mfa-section.model.ts | 351 ++++++++--------- .../user-profile-mfa-section.types.ts | 6 +- .../user-profile-mfa-section.view.tsx | 12 +- .../src/localization/errors.messages.ts | 8 + 18 files changed, 847 insertions(+), 675 deletions(-) create mode 100644 packages/mosaic/src/__tests__/feature/fake-fapi-mfa.feature.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel-mfa.feature.test.tsx rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/__tests__/mfa-feature-setup.tsx (82%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/__tests__/user-profile-mfa-interactions.feature.test.tsx (95%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/__tests__/user-profile-mfa-management.feature.test.tsx (86%) rename packages/mosaic/src/features/user-profile/{ => user-profile-mfa-section}/__tests__/user-profile-mfa.feature.test.tsx (98%) 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..ced803fdfd5 --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi-mfa.feature.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { fapiUrl, serveFapi } from './fake-fapi'; +import { fapiClient, fapiPhoneNumber, fapiSession, fapiUser } 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' })], + }); + 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 a71c2ef8d7e..3be015a864b 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -19,9 +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 { createMfaState, type FakeMfaState, mfaHandlers } from './fake-fapi/mfa'; import { createVerificationState, type FakeVerificationSeed, diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts index 0a360d0977b..8ede7d216f6 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts @@ -2,6 +2,7 @@ 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 } from './shared'; export interface FakeMfaState { totpCreations: number; @@ -33,7 +34,6 @@ export function mfaHandlers( state: { client: ClientJSON; environment: FapiEnvironment; mfa: FakeMfaState }, fapiUrl: (path: string) => string, ) { - const active = () => state.client.sessions.find(session => session.id === state.client.last_active_session_id); const backupEnabled = () => state.environment.user_settings.attributes.backup_code.enabled && state.environment.user_settings.attributes.backup_code.used_for_second_factor; @@ -43,82 +43,69 @@ export function mfaHandlers( .length; const error = (code: string, message: string, status = 400) => HttpResponse.json({ errors: [{ code, message, long_message: message }] }, { status }); - const respond = (response: object) => HttpResponse.json({ response, client: state.client }); - const missing = () => - HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); - const updateUser = (transform: (user: UserJSON) => UserJSON) => { - const session = active(); - if (!session) { + 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 user = transform(session.user); - state.client = { - ...state.client, - sessions: state.client.sessions.map(item => (item.id === session.id ? { ...item, user } : item)), - }; - return user; - }; - const updatePhone = (id: string, transform: (phone: PhoneNumberJSON) => PhoneNumberJSON) => { - let updated: PhoneNumberJSON | undefined; - updateUser(user => ({ + const updated = transform(phone); + updateUser(state, { ...user, - phone_numbers: user.phone_numbers.map(phone => { - if (phone.id !== id) { - return phone; - } - updated = transform(phone); - return updated; - }), - })); + phone_numbers: user.phone_numbers.map(phone => (phone.id === id ? updated : phone)), + }); return updated; }; return [ - http.get(fapiUrl('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/v1/me'), () => { - const session = active(); - return session ? respond(session.user) : missing(); - }), http.post(fapiUrl('/v1/me/totp'), ({ request }) => { - if (!active()) { + const currentUser = requestUser(state, request); + if (!currentUser) { return missing(); } if (new URL(request.url).searchParams.get('_method') === 'DELETE') { - const currentUser = active()?.user; if ( - !currentUser?.totp_enabled || + !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; - const user = updateUser(current => ({ - ...current, + updateUser(state, { + ...currentUser, totp_enabled: false, - two_factor_enabled: current.phone_numbers.some(phone => phone.reserved_for_second_factor), + two_factor_enabled: currentUser.phone_numbers.some(phone => phone.reserved_for_second_factor), backup_code_enabled: - current.phone_numbers.some(phone => phone.reserved_for_second_factor) && current.backup_code_enabled, - })); - return user ? respond({ object: 'deleted', id: 'totp_1' }) : missing(); + currentUser.phone_numbers.some(phone => phone.reserved_for_second_factor) && + currentUser.backup_code_enabled, + }); + return envelope({ object: 'deleted', id: 'totp_1' }, state.client); } if ( - active()?.user.totp_enabled || + 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 respond({ - 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(), - }); + 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 }) => { - if (state.mfa.totpCreations === 0 || active()?.user.totp_enabled) { + 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') ?? ''; @@ -129,27 +116,27 @@ export function mfaHandlers( { status: 422 }, ); } - const newCodes = backupEnabled() && !active()?.user.backup_code_enabled; - const user = updateUser(current => ({ - ...current, + const newCodes = backupEnabled() && !currentUser.backup_code_enabled; + updateUser(state, { + ...currentUser, totp_enabled: true, two_factor_enabled: true, - backup_code_enabled: current.backup_code_enabled || newCodes, - })); - if (!user) { - return missing(); - } - return respond({ - object: 'totp', - id: 'totp_1', - verified: true, - ...(newCodes ? { backup_codes: state.mfa.codes } : {}), - created_at: Date.now(), - updated_at: Date.now(), + 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/'), () => { - const currentUser = active()?.user; + 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); } @@ -158,21 +145,26 @@ export function mfaHandlers( { length: 10 }, (_, index) => `CODE${String(state.mfa.backupCodeCreations).padStart(2, '0')}${String(index).padStart(2, '0')}`, ); - const user = updateUser(current => ({ ...current, backup_code_enabled: true })); - return user - ? respond({ - object: 'backup_code', - id: 'backup_1', - codes: state.mfa.codes, - created_at: Date.now(), - updated_at: Date.now(), - }) - : missing(); + 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 (active()?.user.phone_numbers.some(phone => phone.phone_number === number)) { + if (currentUser.phone_numbers.some(phone => phone.phone_number === number)) { return HttpResponse.json( { errors: [ @@ -187,7 +179,7 @@ export function mfaHandlers( { status: 422 }, ); } - const existingIds = new Set(active()?.user.phone_numbers.map(phone => phone.id)); + const existingIds = new Set(currentUser.phone_numbers.map(phone => phone.id)); let nextPhoneNumber = state.mfa.phoneCreations.length; while (existingIds.has(`phone_${nextPhoneNumber}`)) { nextPhoneNumber += 1; @@ -197,16 +189,27 @@ export function mfaHandlers( phone_number: number, verification: fapiVerification('phone_code'), }); - const user = updateUser(current => ({ ...current, phone_numbers: [...current.phone_numbers, phone] })); - return user ? respond(phone) : missing(); + 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 }) => { + 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(id, current => ({ ...current, verification: fapiVerification('phone_code') })); - return phone ? respond(phone) : missing(); + 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 }); @@ -216,11 +219,11 @@ export function mfaHandlers( { status: 422 }, ); } - const phone = updatePhone(id, current => ({ + const phone = updatePhone(currentUser, id, current => ({ ...current, verification: fapiVerification('phone_code', { status: 'verified' }), })); - return phone ? respond(phone) : missing(); + 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') { @@ -233,63 +236,51 @@ export function mfaHandlers( : undefined; const isDefault = body.has('default_second_factor') ? body.get('default_second_factor') === 'true' : undefined; state.mfa.phoneUpdates.push({ id, reserved, default: isDefault }); - const previous = active()?.user; - const existingPhone = previous?.phone_numbers.find(phone => phone.id === id); - if (reserved === true && (!existingPhone || existingPhone.verification.status !== 'verified')) { + 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 && - previous && - usableFactors(previous) <= 1 - ) { + 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 = !previous?.phone_numbers.some(item => item.reserved_for_second_factor); - const newCodes = reserved && backupEnabled() && !previous?.backup_code_enabled; - const phone = updatePhone(id, current => ({ - ...current, - reserved_for_second_factor: reserved ?? current.reserved_for_second_factor, + 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 : current.default_second_factor), + (reserved && firstReservedPhone ? true : reserved === false ? false : existingPhone.default_second_factor), backup_codes: newCodes ? state.mfa.codes : undefined, - })); - if (isDefault) { - updateUser(user => ({ - ...user, - phone_numbers: user.phone_numbers.map(item => - item.id === id ? item : { ...item, default_second_factor: false }, - ), - })); - } - if (reserved !== undefined) { - updateUser(user => ({ - ...user, - two_factor_enabled: user.totp_enabled || user.phone_numbers.some(item => item.reserved_for_second_factor), - backup_code_enabled: reserved - ? user.backup_code_enabled || Boolean(newCodes) - : user.totp_enabled || user.phone_numbers.some(item => item.reserved_for_second_factor) - ? user.backup_code_enabled - : false, - })); - if (reserved === false && previous?.phone_numbers.find(item => item.id === id)?.default_second_factor) { - updateUser(user => { - const next = user.phone_numbers.find(item => item.reserved_for_second_factor); - return { - ...user, - phone_numbers: user.phone_numbers.map(item => - item.id === next?.id ? { ...item, default_second_factor: true } : item, - ), - }; - }); + }; + 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)); } - return phone ? respond(phone) : missing(); + 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/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 acd508ed709..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; - -import { clerkApiError } from '../../../__tests__/clerk-errors'; -import { MosaicProvider } from '../../../mosaic-provider'; -import { UserProfileMfaSectionView } from '../user-profile-mfa-section/user-profile-mfa-section.view'; - -describe('User profile MFA section view', () => { - 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 user = userEvent.setup(); - render( - - Promise.reject(cause)} - /> - , - ); - - await user.click(screen.getByRole('button', { name: 'Manage SMS verification' })); - await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); - await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(message)); - }); -}); 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/__tests__/mfa-feature-setup.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/mfa-feature-setup.tsx similarity index 82% rename from packages/mosaic/src/features/user-profile/__tests__/mfa-feature-setup.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/mfa-feature-setup.tsx index 33a6411dd86..2f56d66a89b 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/mfa-feature-setup.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/mfa-feature-setup.tsx @@ -1,7 +1,13 @@ -import { serveFapi } 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/user-profile-mfa-section'; +import { serveFapi } 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'; export const phone = fapiPhoneNumber({ id: 'phone_1', phone_number: '+15555550101' }); diff --git a/packages/mosaic/src/features/user-profile/__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 similarity index 95% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-interactions.feature.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-interactions.feature.test.tsx index a184a781f1b..d5a2de72905 100644 --- a/packages/mosaic/src/features/user-profile/__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 @@ -3,10 +3,10 @@ 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/user-profile-mfa-section'; +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', () => { @@ -162,19 +162,22 @@ describe('User profile MFA interaction boundaries', () => { 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 { - 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'); 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('Clipboard unavailable')); + 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()); diff --git a/packages/mosaic/src/features/user-profile/__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 similarity index 86% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-management.feature.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx index c035c44fc29..739b6c2d28d 100644 --- a/packages/mosaic/src/features/user-profile/__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 @@ -3,17 +3,76 @@ 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, fapiPhoneNumber, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; -import { renderWithClerk } from '../../../__tests__/feature/render'; -import { mfaSectionNode, UserProfileMfaSection } 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'; +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({ 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 +15555550202' })); + 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(), @@ -62,38 +121,6 @@ describe('User profile MFA management', () => { expect(screen.getByText('Authenticator app')).toBeVisible(); }); - 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('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 })); @@ -110,6 +137,32 @@ describe('User profile MFA management', () => { 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 +15555550101' })); + 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('moves focus through remaining MFA rows and then to Add after sequential removals', async () => { const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); const second = fapiPhoneNumber({ id: 'phone_2', phone_number: '+15555550202', reserved_for_second_factor: true }); @@ -201,6 +254,7 @@ describe('User profile MFA management', () => { await waitFor(() => expect(screen.getByText('Verification required')).toBeVisible()); await user.keyboard('{Escape}'); await waitFor(() => expect(screen.queryByText('Verification required')).toBeNull()); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); expect(fapi.mfa.totpRemovals).toBe(0); expect(screen.getByText('Authenticator app')).toBeVisible(); expect(screen.queryByRole('alert')).toBeNull(); @@ -224,64 +278,6 @@ describe('User profile MFA management', () => { expect(action).toHaveFocus(); }); - 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(); - }); - it('reverifies the first factor before creating an authenticator secret', async () => { const fapi = await renderMfa(); fapi.verification.secrets.password = 'hunter2'; @@ -691,6 +687,8 @@ describe('User profile MFA management', () => { } 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(); } @@ -731,8 +729,18 @@ describe('User profile MFA management', () => { await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); }); - it('offers printing for newly issued backup codes', async () => { - await renderMfa(); + 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/ })); @@ -741,7 +749,7 @@ describe('User profile MFA management', () => { const open = vi.spyOn(window, 'open').mockReturnValue(null); await user.click(screen.getByRole('button', { name: 'Print' })); expect(open).toHaveBeenCalledOnce(); - expect(screen.getByRole('alert')).toHaveTextContent('Printing is unavailable in this browser.'); + expect(screen.getByRole('alert')).toHaveTextContent(message); open.mockRestore(); }); }); diff --git a/packages/mosaic/src/features/user-profile/__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 similarity index 98% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa.feature.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa.feature.test.tsx index d39e97955b8..0989e456741 100644 --- a/packages/mosaic/src/features/user-profile/__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 @@ -3,10 +3,16 @@ 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, fapiEnvironment, fapiPhoneNumber, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; -import { renderWithClerk } from '../../../__tests__/feature/render'; -import { UserProfileMfaSection } from '../user-profile-mfa-section/user-profile-mfa-section'; +import { fapiUrl, holdRequests, serveFapi, 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', () => { @@ -281,7 +287,8 @@ describe('User profile MFA', () => { await screen.findByRole('textbox', { name: 'Verification code' }); const held = holdRequests('post', '/v1/me/phone_numbers/phone_1/prepare_verification'); const later = Date.now() + 31_000; - const clock = vi.spyOn(Date, 'now').mockReturnValue(later); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(later); try { await waitFor(() => expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(), { timeout: 2000 }); await user.click(screen.getByRole('button', { name: /Resend/ })); @@ -290,8 +297,9 @@ describe('User profile MFA', () => { expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); } finally { held.release(); - clock.mockRestore(); + vi.useRealTimers(); } + await waitFor(() => expect(screen.getByRole('button', { name: 'Verify' })).toBeEnabled()); }); it('keeps a newly created phone when preparing its code fails and retries without duplication', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.messages.ts index 77137acf99c..23199c87c40 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.messages.ts @@ -4,7 +4,6 @@ export const userProfileBackupCodesMessages = { codesLabel: 'Backup codes', download: 'Download', print: 'Print', - printUnavailable: 'Printing is unavailable in this browser.', copyAndClose: 'Copy and close', cancel: 'Cancel', back: 'Back', diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx index 56a03b99b76..f92c9787cd0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx @@ -17,7 +17,7 @@ export interface UserProfileBackupCodesViewProps { onRetry: () => void; onCopy: () => void; onDownload: () => void; - onPrint?: (title: string, unavailableMessage: string) => void; + onPrint?: (title: string) => void; pendingAction?: 'generate' | 'copy' | 'download'; errorMessage?: string; } @@ -100,7 +100,7 @@ export function UserProfileBackupCodesView({ variant='outline' color='neutral' fullWidth - onClick={() => onPrint(m.codesLabel, m.printUnavailable)} + onClick={() => onPrint(m.codesLabel)} > {m.print} 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 index 36a88684b4b..0b0c8f869ae 100644 --- 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 @@ -1,25 +1,18 @@ import { useRef, useState } from 'react'; +import { useConfirmationController } from '../../../blocks/confirmation/confirmation.controller'; import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; -import { useErrorText, useMessages } from '../../../localization'; -import { toLocalizableError } from '../../../utils/errors'; +import { usePendingAction } from '../../../hooks/use-pending-action'; +import { useMessages } from '../../../localization'; import { MfaCancelledError, type UserProfileMfaMethod } from './user-profile-mfa-section.types'; import type { UserProfileMfaSectionViewProps } from './user-profile-mfa-section.view'; -type LeafState = - | { kind: 'idle'; defaultError?: string } - | { kind: 'settingDefault' } - | { kind: 'confirmingRemoval'; method: UserProfileMfaMethod; error?: string } - | { kind: 'removing'; method: UserProfileMfaMethod }; - export function useUserProfileMfaSectionLeafController({ methods, onRemove, onSetDefault, }: UserProfileMfaSectionViewProps) { const m = useMessages('userProfileMfa'); - const errors = useMessages('errors'); - const errorText = useErrorText(); const sectionRef = useRef(null); const triggers = useRef(new Map()); const lastRemovalId = useRef(); @@ -30,8 +23,10 @@ export function useUserProfileMfaSectionLeafController({ sectionRef.current?.querySelector('button:not([disabled])') ?? sectionRef.current, }); const [pickerOpen, setPickerOpen] = useState(false); - const [state, setState] = useState({ kind: 'idle' }); + const [selectedMethod, setSelectedMethod] = useState(); const operation = useRef<'idle' | 'default' | 'remove'>('idle'); + const defaultAction = usePendingAction<'default'>({ errorFallback: m.setDefaultError }); + const confirmation = useConfirmationController(); const setDefault = async (id: string) => { const method = methods.find(method => method.id === id); @@ -45,62 +40,46 @@ export function useUserProfileMfaSectionLeafController({ return; } operation.current = 'default'; - setState({ kind: 'settingDefault' }); try { - await onSetDefault(id); - setState({ kind: 'idle' }); - } catch (error) { - setState({ kind: 'idle', defaultError: errorText(toLocalizableError(error), m.setDefaultError) }); + await defaultAction.run('default', () => onSetDefault(id)); } finally { operation.current = 'idle'; } }; - const confirmRemoval = async () => { - if (state.kind !== 'confirmingRemoval' || operation.current !== 'idle') { + const confirmRemoval = () => { + if (!selectedMethod || !confirmation.isOpen || operation.current !== 'idle') { return; } - const method = state.method; + const method = selectedMethod; operation.current = 'remove'; - setState({ kind: 'removing', method }); - try { - await removalFocus.remove(method.id); - setState({ kind: 'idle' }); - } catch (error) { - if (error instanceof MfaCancelledError) { - setState({ kind: 'idle' }); - } else { - setState({ kind: 'confirmingRemoval', method, error: errorText(toLocalizableError(error), errors.generic) }); + confirmation.onConfirm(async () => { + try { + await removalFocus.remove(method.id); + } catch (error) { + if (!(error instanceof MfaCancelledError)) { + throw error; + } + } finally { + operation.current = 'idle'; } - } finally { - operation.current = 'idle'; - } + }); }; - const removal = - state.kind === 'confirmingRemoval' || state.kind === 'removing' - ? ({ - method: state.method, - status: state.kind === 'removing' ? 'pending' : 'confirming', - error: state.kind === 'confirmingRemoval' ? state.error : undefined, - } as const) - : undefined; - return { sectionRef, pickerOpen, onPickerOpenChange: setPickerOpen, closePicker: () => setPickerOpen(false), - removal, - onRemovalOpenChange: (open: boolean) => { - if (!open && operation.current === 'idle') { - setState({ kind: 'idle' }); - } - }, + selectedMethod, + confirmation, + onRemovalOpenChange: confirmation.onOpenChange, openRemoval: (method: UserProfileMfaMethod) => { if (operation.current === 'idle') { + defaultAction.reset(); lastRemovalId.current = method.id; - setState({ kind: 'confirmingRemoval', method }); + setSelectedMethod(method); + confirmation.onOpenChange(true); } }, confirmRemoval, @@ -119,8 +98,8 @@ export function useUserProfileMfaSectionLeafController({ } }; }, - isSettingDefault: state.kind === 'settingDefault', - defaultError: state.kind === 'idle' ? state.defaultError : undefined, + 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 index d49e0d05570..a5082006668 100644 --- 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 @@ -1,8 +1,12 @@ -import { useEffect, useRef, useState } from 'react'; +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { useRef } from 'react'; -import { useMessages } from '../../../localization'; +import { useNow } from '../../../hooks/use-now'; +import { type ErrorDescription, useErrorText } 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 { useReverificationController } from '../../reverification/reverification.controller'; import type { ReverificationModel } from '../../reverification/reverification.model'; import { @@ -15,10 +19,25 @@ 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' + | 'remove' + | 'default'; type MfaFlow = | { kind: 'closed' } | { kind: 'select' } - | { kind: 'authenticator'; setup?: AuthenticatorSetup; code: string; error?: string; setupError?: string } + | { + kind: 'authenticator'; + setup?: AuthenticatorSetup; + code: string; + error?: ErrorDescription; + setupError?: ErrorDescription; + } | { kind: 'sms'; step: 'select' | 'phone' | 'verify'; @@ -27,14 +46,14 @@ type MfaFlow = phoneId?: string; phoneNumber: string; code: string; - error?: string; + error?: ErrorDescription; } - | { kind: 'backup'; codes: readonly string[]; error?: string }; + | { kind: 'backup'; codes: readonly string[]; error?: ErrorDescription }; interface MfaContext { flow: MfaFlow; savedSetup?: AuthenticatorSetup; - pending?: string; + pending?: MfaAction; resendAvailableAt?: number; run: () => Promise; resolve: () => void; @@ -50,8 +69,8 @@ type MfaEvent = | { type: 'EDIT_PHONE_NUMBER'; value: string } | { type: 'EDIT_CODE'; value: string } | { type: 'PHONE_RESOLVED'; id: string; phoneNumber: string } - | { type: 'PRINT_FAILED'; message: string } - | { type: 'RUN'; key: string; run: () => Promise; resolve: () => void; reject: (error: unknown) => void }; + | { type: 'PRINT_FAILED' } + | { type: 'RUN'; key: MfaAction; run: () => Promise; resolve: () => void; reject: (error: unknown) => void }; const { createMachine, assign, fromPromise } = setup(); const RESEND_COOLDOWN_MS = 30_000; @@ -135,8 +154,11 @@ const mfaMachine = createMachine({ })), }, PRINT_FAILED: { - actions: assign((context, event) => ({ - flow: context.flow.kind === 'backup' ? { ...context.flow, error: event.message } : context.flow, + actions: assign(context => ({ + flow: + context.flow.kind === 'backup' + ? { ...context.flow, error: { code: 'mfa_print_unavailable' } } + : context.flow, })), }, RUN: { @@ -166,7 +188,7 @@ const mfaMachine = createMachine({ target: 'idle', actions: [ context => context.resolve(), - assign((context, event) => ({ + assign>((context, event) => ({ flow: event.output, savedSetup: event.output.kind === 'authenticator' && event.output.setup @@ -191,19 +213,19 @@ const mfaMachine = createMachine({ if (event.error instanceof MfaCancelledError) { return { flow: { kind: 'closed' }, savedSetup: undefined, pending: undefined }; } - const message = - event.error instanceof Error ? event.error.message : 'This action could not be completed.'; const flow = context.flow; + 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: message } : { error: message }), + ...(context.pending === 'prepareAuthenticator' ? { setupError: error } : { error }), } - : flow.kind === 'sms' || flow.kind === 'backup' - ? { ...flow, error: message } - : flow, + : { ...flow, error }, pending: undefined, }; }), @@ -214,144 +236,96 @@ const mfaMachine = createMachine({ }, }); -export function useUserProfileMfaController(model: ReadyModel, reverificationModel: ReverificationModel) { - const m = useMessages('userProfileMfa'); - const reverificationProps = useReverificationController(reverificationModel, model.resetReverification); - const [{ context, value }, send] = useMachine(mfaMachine); - const locked = useRef(false); - const [now, setNow] = useState(() => Date.now()); - const resendAvailableAt = context.resendAvailableAt; - const resendSeconds = resendAvailableAt ? Math.max(0, Math.ceil((resendAvailableAt - now) / 1000)) : 0; - useEffect(() => { - if (!resendSeconds) { - return; - } - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, [resendSeconds]); - const flow = context.flow; - const run = (key: string, action: () => Promise): Promise => { - if (locked.current || value === 'busy') { - return Promise.reject(new Error(m.errors.busy)); - } - locked.current = true; - return new Promise((resolve, reject) => { - send({ - type: 'RUN', - key, - run: action, - resolve: () => { - locked.current = false; - resolve(); - }, - reject: error => { - locked.current = false; - reject(error instanceof Error ? error : new Error(m.errors.unexpectedResponse)); - }, - }); - }); - }; - - const close = () => send({ type: 'CLOSE' }); - const back = () => send({ type: 'BACK', hasPhones: model.phones.length > 0 }); +type MfaSend = (event: MfaEvent) => void; +type RunMfaAction = (key: MfaAction, action: () => Promise) => Promise; +function createAuthenticatorActions(model: ReadyModel, flow: MfaFlow, run: RunMfaAction) { const prepareAuthenticator = () => { - if (!model.createAuthenticator) { + const createAuthenticator = model.createAuthenticator; + if (!createAuthenticator) { return; } void run('prepareAuthenticator', async () => { - const setup = await model.createAuthenticator?.(); - return setup ? { kind: 'authenticator', setup, code: '' } : { kind: 'select' }; + const setup = await createAuthenticator(); + return { kind: 'authenticator', setup, code: '' }; }).catch(() => undefined); }; - const generateBackupCodes = () => { - if (!model.generateBackupCodes) { - return; - } - send({ type: 'SELECT', method: 'backup-codes', phoneId: '', hasPhones: false }); - void run('generate', async () => { - const codes = await model.generateBackupCodes?.(); - return { kind: 'backup', codes: codes ?? [] }; - }).catch(() => undefined); - }; - - const select = (method: UserProfileMfaAddableMethod) => { - if (locked.current) { - 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 verifyAuthenticator = (code: string) => { - if (!model.verifyAuthenticator || flow.kind !== 'authenticator' || !flow.setup) { + const verify = model.verifyAuthenticator; + if (!verify || flow.kind !== 'authenticator' || !flow.setup) { return; } void run('verifyAuthenticator', async () => { - const result = await model.verifyAuthenticator?.(code); - return result?.backupCodes.length ? { kind: 'backup', codes: result.backupCodes } : { kind: 'closed' }; + 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, + resendAvailableAt: number | undefined, + run: RunMfaAction, + send: MfaSend, +) { const submitSms = (code?: string) => { - if (!model.enrollSms || flow.kind !== 'sms') { + 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) { - const phone = await model.findOrCreatePhone?.(current.phoneNumber); - if (!phone) { + 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 model.enrollSms?.(phoneId, code ?? (current.step === 'verify' ? current.code : undefined)); - if (result?.status === 'needsVerification') { + 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 + return result.status === 'complete' && result.backupCodes.length ? { kind: 'backup', codes: result.backupCodes } : { kind: 'closed' }; }).catch(() => undefined); }; const resendSms = () => { - if ( - flow.kind !== 'sms' || - !flow.phoneId || - !model.resendSms || - (resendAvailableAt && Date.now() < resendAvailableAt) - ) { + const resend = model.resendSms; + if (flow.kind !== 'sms' || !flow.phoneId || !resend || (resendAvailableAt && Date.now() < resendAvailableAt)) { return; } const current = flow; void run('resend', async () => { - await model.resendSms?.(current.phoneId ?? ''); + await resend(current.phoneId ?? ''); return { ...current, error: undefined }; }).catch(() => undefined); }; - const remove = (id: string) => - run('remove', async () => { - await model.remove(id); - return flow; - }); - const setDefault = model.setDefault - ? (id: string) => - run('default', async () => { - await model.setDefault?.(id); - return flow; - }) - : undefined; + return { submitSms, resendSms }; +} + +function createBackupCodeActions(model: ReadyModel, flow: MfaFlow, run: RunMfaAction, send: MfaSend) { + 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) { @@ -376,13 +350,13 @@ export function useUserProfileMfaController(model: ReadyModel, reverificationMod URL.revokeObjectURL(url); }; - const print = (titleText: string, unavailableMessage: string) => { + const print = (titleText: string) => { if (flow.kind !== 'backup' || !flow.codes.length) { return; } const printable = window.open('', '_blank', 'width=640,height=720'); if (!printable) { - send({ type: 'PRINT_FAILED', message: unavailableMessage }); + send({ type: 'PRINT_FAILED' }); return; } try { @@ -398,59 +372,165 @@ export function useUserProfileMfaController(model: ReadyModel, reverificationMod printable.print(); } catch { printable.close(); - send({ type: 'PRINT_FAILED', message: unavailableMessage }); + send({ type: 'PRINT_FAILED' }); } }; - const setupProps: UserProfileMfaSetupViewProps = { - step: - flow.kind === 'closed' || flow.kind === 'backup' - ? flow.kind === 'backup' - ? 'backup-codes' - : 'select' - : flow.kind, + 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: (titleText: string) => void; +} + +function createSetupProps( + model: ReadyModel, + context: MfaContext, + send: MfaSend, + actions: MfaSetupActions, + resendSeconds: number, + 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: select, - onBack: back, - onCancel: close, + onSelect: actions.select, + onBack: actions.back, + onCancel: actions.close, sms: { - step: flow.kind === 'sms' ? flow.step : 'select', + step: sms?.step ?? 'select', phoneNumbers: model.phones, - selectedPhoneId: flow.kind === 'sms' ? flow.selectedPhoneId : '', + selectedPhoneId: sms?.selectedPhoneId ?? '', onSelectedPhoneIdChange: value => send({ type: 'EDIT_PHONE_ID', value }), onAddPhone: () => send({ type: 'SELECT', method: 'sms', phoneId: '', hasPhones: false }), - onBack: back, - phoneNumber: flow.kind === 'sms' ? flow.phoneNumber : '', + onBack: actions.back, + phoneNumber: sms?.phoneNumber ?? '', onPhoneNumberChange: value => send({ type: 'EDIT_PHONE_NUMBER', value }), - code: flow.kind === 'sms' ? flow.code : '', + code: sms?.code ?? '', onCodeChange: value => send({ type: 'EDIT_CODE', value }), - onSubmit: submitSms, - onResend: resendSms, + onSubmit: actions.submitSms, + onResend: actions.resendSms, isPending: context.pending === 'sms', isResending: context.pending === 'resend', resendSeconds, - errorMessage: flow.kind === 'sms' ? flow.error : undefined, + errorMessage: sms?.error ? errorText(sms.error) : undefined, }, authenticator: { - setup: flow.kind === 'authenticator' ? flow.setup : undefined, - setupErrorMessage: flow.kind === 'authenticator' ? flow.setupError : undefined, - onRetry: prepareAuthenticator, - code: flow.kind === 'authenticator' ? flow.code : '', + setup: authenticator?.setup, + setupErrorMessage: authenticator?.setupError ? errorText(authenticator.setupError) : undefined, + onRetry: actions.prepareAuthenticator, + code: authenticator?.code ?? '', onCodeChange: value => send({ type: 'EDIT_CODE', value }), - onSubmit: verifyAuthenticator, + onSubmit: actions.verifyAuthenticator, isPending: context.pending === 'prepareAuthenticator' || context.pending === 'verifyAuthenticator', - errorMessage: flow.kind === 'authenticator' ? flow.error : undefined, + errorMessage: authenticator?.error ? errorText(authenticator.error) : undefined, }, backupCodes: { - codes: flow.kind === 'backup' ? flow.codes : [], - onRetry: generateBackupCodes, - onCopy: copy, - onDownload: download, - onPrint: print, - pendingAction: context.pending === 'generate' ? 'generate' : context.pending === 'copy' ? 'copy' : undefined, - errorMessage: flow.kind === 'backup' ? flow.error : undefined, + 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, }, }; +} + +function useMfaActionRunner(value: string, send: MfaSend) { + const locked = useRef(false); + const run: RunMfaAction = (key, action) => { + if (locked.current || value === 'busy') { + return Promise.reject(new ClerkRuntimeError('Another verification action is in progress.', { code: 'mfa_busy' })); + } + locked.current = true; + return new Promise((resolve, reject) => { + send({ type: 'RUN', key, run: action, resolve, reject }); + }).finally(() => { + locked.current = false; + }); + }; + + return { run, isLocked: () => locked.current }; +} + +export function useUserProfileMfaController(model: ReadyModel, reverificationModel: ReverificationModel) { + const errorText = useErrorText(); + const reverificationProps = useReverificationController(reverificationModel, model.resetReverification); + const [{ context, value }, send] = useMachine(mfaMachine); + const { run, isLocked } = useMfaActionRunner(value, send); + const now = useNow({ updateInterval: 1000 }); + const resendAvailableAt = context.resendAvailableAt; + const resendSeconds = resendAvailableAt ? Math.max(0, Math.ceil((resendAvailableAt - now.getTime()) / 1000)) : 0; + const flow = context.flow; + const { prepareAuthenticator, verifyAuthenticator } = createAuthenticatorActions(model, flow, run); + const { submitSms, resendSms } = createSmsActions(model, flow, resendAvailableAt, run, send); + const { generateBackupCodes, copy, download, print } = createBackupCodeActions(model, flow, run, send); + + const close = () => send({ type: 'CLOSE' }); + const back = () => send({ type: 'BACK', hasPhones: model.phones.length > 0 }); + + const select = (method: UserProfileMfaAddableMethod) => { + if (isLocked()) { + 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 remove = (id: string) => + run('remove', async () => { + await model.remove(id); + return flow; + }); + const setDefaultAction = model.setDefault; + const setDefault = setDefaultAction + ? (id: string) => + run('default', async () => { + await setDefaultAction(id); + return flow; + }) + : undefined; + + const setupProps = createSetupProps( + model, + context, + send, + { + select, + back, + close, + submitSms, + resendSms, + prepareAuthenticator, + verifyAuthenticator, + generateBackupCodes, + copy, + download, + print, + }, + resendSeconds, + errorText, + ); const sectionProps: UserProfileMfaSectionViewProps = { methods: [...model.methods], diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.messages.ts index cde6b01da5d..3463921b362 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.messages.ts @@ -11,15 +11,6 @@ export const userProfileMfaMessages = { default: 'Default', setDefault: 'Set as default', setDefaultError: 'Unable to set this method as default. Please try again.', - errors: { - accountChanged: 'This account changed. Please reopen verification.', - authenticatorUnavailable: 'Authenticator is unavailable.', - phoneUnavailable: 'This phone number is unavailable.', - methodCannotRemove: 'This method cannot be removed.', - setupFactorFirst: 'Set up a verification method first.', - unexpectedResponse: 'The verification response was unexpected. Please try again.', - busy: 'Another verification action is in progress.', - }, remove: 'Remove method', regenerate: 'Regenerate', manage: 'Manage {label}', 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 index 5287ac7aa1c..e5590a29c3e 100644 --- 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 @@ -1,10 +1,8 @@ -import { isReverificationCancelledError } from '@clerk/shared/error'; +import { ClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; 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 { isLocalizableError, useErrorText, useMessages } from '../../../localization'; -import { SaveError, toLocalizableError } from '../../../utils/errors'; import { useReverificationWithState } from '../../reverification/use-reverification-with-state'; import { MfaCancelledError, @@ -16,6 +14,11 @@ import { type UserProfileMfaModel, } from './user-profile-mfa-section.types'; +type ReadyModel = Extract; +type MfaSnapshotData = Pick; +type ProtectedResult = Awaited>; +type RunProtected = (operation: ProtectedOperation) => Promise; + type ProtectedOperation = | { kind: 'createAuthenticator'; userId: string; sessionId: string } | { kind: 'reservePhone'; userId: string; sessionId: string; phoneId: string } @@ -48,111 +51,78 @@ function summarizePhone(phone: PhoneNumberResource): MfaPhone { }; } -function errorMessage(error: unknown, localize: ReturnType): Error { - if (error instanceof MfaCancelledError) { - return error; +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 reopen verification.', { code: 'mfa_account_changed' }); } - const description = toLocalizableError(error); - return new SaveError({ - global: { - ...(isLocalizableError(description) ? description : { code: 'generic' }), - message: localize(description), - }, - }); + return current; } -export function useUserProfileMfaModel(): UserProfileMfaModel { - const clerk = useClerk(); - const { isLoaded: userLoaded, user } = useUser(); - const { isLoaded: sessionLoaded, session } = useSession(); - const environment = useMosaicEnvironment(); - const localize = useErrorText(); - const m = useMessages('userProfileMfa'); - - const requireIdentity = (userId: string, sessionId: string): UserResource => { - const current = clerk.user; - if (!current || current.id !== userId || clerk.session?.id !== sessionId) { - throw new SaveError({ global: { code: 'mfa_account_changed', message: m.errors.accountChanged } }); +async function executeProtectedOperation( + clerk: ReturnType, + environment: EnvironmentResource | undefined, + operation: ProtectedOperation, +) { + const current = requireIdentity(clerk, operation.userId, operation.sessionId); + const currentSecondFactors = configuredFactors(environment); + switch (operation.kind) { + case 'createAuthenticator': { + if (current.totpEnabled || !currentSecondFactors.includes('totp')) { + throw new ClerkRuntimeError('Authenticator is unavailable.', { code: 'mfa_authenticator_unavailable' }); + } + const result = await current.createTOTP(); + return { kind: 'authenticatorSetup', secret: result.secret ?? '', uri: result.uri ?? '' } as const; } - return current; - }; - - const [protectedAction, reverification, resetReverification] = useReverificationWithState( - async (operation: ProtectedOperation) => { - const current = requireIdentity(operation.userId, operation.sessionId); - const currentSecondFactors = configuredFactors(environment); - switch (operation.kind) { - case 'createAuthenticator': { - if (current.totpEnabled || !currentSecondFactors.includes('totp')) { - throw new SaveError({ - global: { code: 'mfa_authenticator_unavailable', message: m.errors.authenticatorUnavailable }, - }); - } - const result = await current.createTOTP(); - return { kind: 'authenticatorSetup', secret: result.secret ?? '', uri: result.uri ?? '' } as const; - } - case 'reservePhone': { - const phone = current.phoneNumbers.find(item => item.id === operation.phoneId); - if ( - !phone || - phone.verification.status !== 'verified' || - phone.reservedForSecondFactor || - !currentSecondFactors.includes('phone_code') - ) { - throw new SaveError({ global: { code: 'mfa_phone_unavailable', message: m.errors.phoneUnavailable } }); - } - const result = await phone.setReservedForSecondFactor({ reserved: true }); - return { kind: 'enrollment', backupCodes: result.backupCodes ?? [] } as const; - } - case 'removeAuthenticator': { - const usableCount = usableFactorCount(current, currentSecondFactors); - if (!current.totpEnabled || (environment?.userSettings.signUp.mfa?.required && usableCount <= 1)) { - throw new SaveError({ global: { code: 'mfa_method_cannot_remove', message: m.errors.methodCannotRemove } }); - } - await current.disableTOTP(); - return { kind: 'done' } as const; - } - case 'removePhone': { - const phone = current.phoneNumbers.find(item => item.id === operation.phoneId); - const usableCount = usableFactorCount(current, currentSecondFactors); - if ( - !phone?.reservedForSecondFactor || - phone.verification.status !== 'verified' || - (environment?.userSettings.signUp.mfa?.required && usableCount <= 1) - ) { - throw new SaveError({ global: { code: 'mfa_method_cannot_remove', message: m.errors.methodCannotRemove } }); - } - await phone.setReservedForSecondFactor({ reserved: false }); - return { kind: 'done' } as const; - } - case 'generateBackupCodes': { - if (!currentSecondFactors.includes('backup_code') || usableFactorCount(current, currentSecondFactors) === 0) { - throw new SaveError({ global: { code: 'mfa_setup_factor_first', message: m.errors.setupFactorFirst } }); - } - const result = await current.createBackupCode(); - return { kind: 'backupCodes', codes: result.codes } as const; - } + case 'reservePhone': { + const phone = current.phoneNumbers.find(item => item.id === operation.phoneId); + if ( + !phone || + phone.verification.status !== 'verified' || + phone.reservedForSecondFactor || + !currentSecondFactors.includes('phone_code') + ) { + throw new ClerkRuntimeError('This phone number is unavailable.', { code: 'mfa_phone_unavailable' }); } - }, - ); - - if (!userLoaded || !sessionLoaded || !environment) { - return { status: 'loading', reverification, resetReverification }; - } - if (!user || !session) { - return { status: 'hidden', reverification, resetReverification }; + const result = await phone.setReservedForSecondFactor({ reserved: true }); + return { kind: 'enrollment', backupCodes: result.backupCodes ?? [] } as const; + } + case 'removeAuthenticator': { + const usableCount = usableFactorCount(current, currentSecondFactors); + if (!current.totpEnabled || (environment?.userSettings.signUp.mfa?.required && usableCount <= 1)) { + throw new ClerkRuntimeError('This method cannot be removed.', { code: 'mfa_method_cannot_remove' }); + } + await current.disableTOTP(); + return { kind: 'done' } as const; + } + case 'removePhone': { + const phone = current.phoneNumbers.find(item => item.id === operation.phoneId); + const usableCount = usableFactorCount(current, currentSecondFactors); + if ( + !phone?.reservedForSecondFactor || + phone.verification.status !== 'verified' || + (environment?.userSettings.signUp.mfa?.required && usableCount <= 1) + ) { + throw new ClerkRuntimeError('This method cannot be removed.', { code: 'mfa_method_cannot_remove' }); + } + await phone.setReservedForSecondFactor({ reserved: false }); + return { kind: 'done' } as const; + } + case 'generateBackupCodes': { + if (!currentSecondFactors.includes('backup_code') || usableFactorCount(current, currentSecondFactors) === 0) { + throw new ClerkRuntimeError('Set up a verification method first.', { code: 'mfa_setup_factor_first' }); + } + const result = await current.createBackupCode(); + return { kind: 'backupCodes', codes: result.codes } as const; + } } +} +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'); - if (!smsEnabled && !totpEnabled) { - return { status: 'hidden', reverification, resetReverification }; - } - - const userId = user.id; - const sessionId = session.id; const reservedPhones = verifiedReservedPhones(user); const usableCount = usableFactorCount(user, secondFactors); const canRemove = !environment.userSettings.signUp.mfa?.required || usableCount > 1; @@ -186,8 +156,101 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { addableMethods.push('backup-codes'); } + return { + methods, + addableMethods, + phones: user.phoneNumbers.filter(phone => !phone.reservedForSecondFactor).map(summarizePhone), + }; +} + +function createSmsActions( + clerk: ReturnType, + userId: string, + sessionId: string, + runProtected: RunProtected, + 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 new ClerkRuntimeError('This phone number is unavailable.', { code: 'mfa_phone_unavailable' }); + } + 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); + } + const result = await runProtected({ kind: 'reservePhone', userId, sessionId, phoneId }); + if (result.kind !== 'enrollment') { + throw new ClerkRuntimeError('The verification response was unexpected. Please try again.', { + code: 'mfa_unexpected_response', + }); + } + 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 new ClerkRuntimeError('This phone number is unavailable.', { code: 'mfa_phone_unavailable' }); + } + 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(); + + const [protectedAction, reverification, resetReverification] = useReverificationWithState( + (operation: ProtectedOperation) => executeProtectedOperation(clerk, environment, operation), + ); + + if (!userLoaded || !sessionLoaded || !environment) { + return { status: 'loading', reverification, resetReverification }; + } + if (!user || !session) { + return { status: 'hidden', reverification, resetReverification }; + } + + 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', reverification, resetReverification }; + } + + 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(userId, sessionId); + const current = requireIdentity(clerk, userId, sessionId); await current.reload(); }; const runProtected = async (operation: ProtectedOperation) => { @@ -197,93 +260,39 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { if (isReverificationCancelledError(error)) { throw new MfaCancelledError('Verification was cancelled.'); } - throw errorMessage(error, localize); - } - }; - const currentPhone = (phoneId: string) => { - const current = requireIdentity(userId, sessionId); - const phone = current.phoneNumbers.find(item => item.id === phoneId); - if (!phone) { - throw new SaveError({ global: { code: 'mfa_phone_unavailable', message: m.errors.phoneUnavailable } }); + throw error; } - return phone; }; + const smsActions = smsEnabled ? createSmsActions(clerk, userId, sessionId, runProtected, refresh) : {}; + return { status: 'ready', userId, sessionId, methods, addableMethods, - phones: user.phoneNumbers.filter(phone => !phone.reservedForSecondFactor).map(summarizePhone), + phones, reverification, resetReverification, - findOrCreatePhone: smsEnabled - ? async phoneNumber => { - try { - const current = requireIdentity(userId, sessionId); - const existing = current.phoneNumbers.find( - phone => !phone.reservedForSecondFactor && phone.phoneNumber === phoneNumber, - ); - return summarizePhone(existing ?? (await current.createPhoneNumber({ phoneNumber }))); - } catch (error) { - throw errorMessage(error, localize); - } - } - : undefined, - enrollSms: smsEnabled - ? async (phoneId, code): Promise => { - try { - 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); - } - const result = await runProtected({ kind: 'reservePhone', userId, sessionId, phoneId }); - if (result.kind !== 'enrollment') { - throw new SaveError({ - global: { code: 'mfa_unexpected_response', message: m.errors.unexpectedResponse }, - }); - } - await refresh().catch(() => undefined); - return { status: 'complete', backupCodes: result.backupCodes }; - } catch (error) { - throw errorMessage(error, localize); - } - } - : undefined, - resendSms: smsEnabled - ? async phoneId => { - try { - await currentPhone(phoneId).prepareVerification(); - } catch (error) { - throw errorMessage(error, localize); - } - } - : undefined, + ...smsActions, createAuthenticator: totpEnabled ? async () => { const result = await runProtected({ kind: 'createAuthenticator', userId, sessionId }); if (result.kind !== 'authenticatorSetup') { - throw new SaveError({ global: { code: 'mfa_unexpected_response', message: m.errors.unexpectedResponse } }); + throw new ClerkRuntimeError('The verification response was unexpected. Please try again.', { + code: 'mfa_unexpected_response', + }); } return { secret: result.secret, uri: result.uri }; } : undefined, verifyAuthenticator: totpEnabled ? async (code): Promise => { - try { - const result = await requireIdentity(userId, sessionId).verifyTOTP({ code }); - const backupCodes = result.backupCodes ?? []; - await refresh().catch(() => undefined); - return { backupCodes }; - } catch (error) { - throw errorMessage(error, localize); - } + const result = await requireIdentity(clerk, userId, sessionId).verifyTOTP({ code }); + const backupCodes = result.backupCodes ?? []; + await refresh().catch(() => undefined); + return { backupCodes }; } : undefined, generateBackupCodes: @@ -291,8 +300,8 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { ? async () => { const result = await runProtected({ kind: 'generateBackupCodes', userId, sessionId }); if (result.kind !== 'backupCodes') { - throw new SaveError({ - global: { code: 'mfa_unexpected_response', message: m.errors.unexpectedResponse }, + throw new ClerkRuntimeError('The verification response was unexpected. Please try again.', { + code: 'mfa_unexpected_response', }); } await refresh().catch(() => undefined); @@ -307,15 +316,5 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { await runProtected(operation); await refresh().catch(() => undefined); }, - setDefault: smsEnabled - ? async phoneId => { - const phone = currentPhone(phoneId); - if (phone.verification.status !== 'verified' || !phone.reservedForSecondFactor) { - throw new SaveError({ global: { code: 'mfa_phone_unavailable', message: m.errors.phoneUnavailable } }); - } - await phone.makeDefaultSecondFactor(); - await refresh().catch(() => undefined); - } - : 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 index 3078653a347..014a5a431b2 100644 --- 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 @@ -1,3 +1,5 @@ +import type { PhoneNumberResource } from '@clerk/shared/types'; + import type { ReverificationState } from '../../reverification/reverification.types'; export interface UserProfileMfaMethod { @@ -12,9 +14,7 @@ export interface UserProfileMfaMethod { export type UserProfileMfaAddableMethod = 'sms' | 'authenticator' | 'backup-codes'; -export interface MfaPhone { - id: string; - phoneNumber: string; +export interface MfaPhone extends Pick { verified: boolean; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx index 25485faeb34..3c854d08d1b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx @@ -77,17 +77,17 @@ export function UserProfileMfaSectionView({ ))} {controller.defaultError} - {onRemove && controller.removal ? ( + {onRemove && controller.selectedMethod ? ( ) : null} diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 82d227f3d06..6ba50ea342d 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -32,6 +32,14 @@ export const errorMessages: { readonly generic: string } & Readonly Date: Thu, 8 Oct 2026 16:54:23 -0600 Subject: [PATCH 17/26] fix(mosaic): match legacy MFA parity for phones, defaults and backup codes --- ...er-profile-mfa-management.feature.test.tsx | 146 +++++++++++++++--- .../user-profile-mfa.feature.test.tsx | 22 ++- .../user-profile-backup-codes.messages.ts | 4 + .../user-profile-backup-codes.view.tsx | 4 +- .../user-profile-mfa-row.view.tsx | 9 +- ...ser-profile-mfa-section-leaf.controller.ts | 8 +- .../user-profile-mfa-section.controller.ts | 30 +++- .../user-profile-mfa-section.model.ts | 25 ++- .../user-profile-mfa-section.types.ts | 2 + .../user-profile-mfa-section.view.tsx | 3 +- .../src/stories/fixtures/user-profile-mfa.ts | 3 +- 11 files changed, 206 insertions(+), 50 deletions(-) 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 index 739b6c2d28d..954a4b47cad 100644 --- 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 @@ -64,7 +64,7 @@ describe('User profile MFA management', () => { const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); try { const user = userEvent.setup(); - await user.click(screen.getByRole('button', { name: 'Manage SMS verification +15555550202' })); + 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 { @@ -125,13 +125,13 @@ describe('User profile MFA management', () => { 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 +15555550101' })); + 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 +15555550101. The phone number will remain on your account.', + '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('+15555550101')).toBeNull()); + 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); @@ -142,7 +142,7 @@ describe('User profile MFA management', () => { 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 +15555550101' })); + 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' }); @@ -169,10 +169,10 @@ describe('User profile MFA management', () => { 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 +15555550101' })); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })); await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - const remaining = await screen.findByRole('button', { name: 'Manage SMS verification +15555550202' }); + const remaining = await screen.findByRole('button', { name: 'Manage SMS verification +1 (555) 555-0202' }); await waitFor(() => expect(remaining).toHaveFocus()); await user.keyboard('{Enter}'); @@ -270,7 +270,7 @@ describe('User profile MFA management', () => { 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 +15555550101' }); + 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' })); @@ -532,8 +532,8 @@ describe('User profile MFA management', () => { } }); - it('downloads only the displayed server-issued backup codes', async () => { - const fapi = await renderMfa(); + 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); @@ -549,9 +549,16 @@ describe('User profile MFA management', () => { if (!(saved instanceof Blob)) { throw new Error('Expected the displayed backup codes to be saved in a blob'); } - expect(await saved.text()).toBe(fapi.mfa.codes.join('\n')); + 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('clerk-backup-codes.txt'); + expect(click.mock.instances[0]?.download).toBe('Acme_backup_codes.txt'); expect(revoke).toHaveBeenCalledWith('blob:backup-codes'); expect(screen.getByText('CODE0001')).toBeVisible(); } finally { @@ -613,16 +620,16 @@ describe('User profile MFA management', () => { const second = fapiPhoneNumber({ 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 +15555550202' })); + 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('+15555550202'), + 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 +15555550101' })); + 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()); }); @@ -646,7 +653,7 @@ describe('User profile MFA management', () => { }), ); const user = userEvent.setup(); - const selected = screen.getByRole('button', { name: 'Manage SMS verification +15555550202' }); + 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' })); @@ -660,13 +667,80 @@ describe('User profile MFA management', () => { 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 +15555550101' })); + 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('reverifies before setting a different SMS phone as default', async () => { + const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const second = fapiPhoneNumber({ 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 })); + fapi.verification.secrets.password = 'hunter2'; + 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: 'session_reverification_required', message: 'Reverification required' }] }, + { status: 403 }, + ) + : undefined; + }), + ); + 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.getByText('Verification required')).toBeVisible()); + expect(fapi.mfa.phoneUpdates).toEqual([]); + const password = await screen.findByLabelText('Password'); + await waitFor(() => expect(password).toBeVisible()); + await user.type(password, 'hunter2{Enter}'); + await waitFor(() => + expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_2', default: true, reserved: undefined }), + ); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + 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'); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('leaves the default unchanged when set-default reverification is cancelled', async () => { + const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const second = fapiPhoneNumber({ 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 })); + worker.use( + http.post(fapiUrl('/v1/me/phone_numbers/phone_2'), ({ request }) => + new URL(request.url).searchParams.get('_method') === 'PATCH' + ? HttpResponse.json( + { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, + { status: 403 }, + ) + : undefined, + ), + ); + 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.getByText('Verification required')).toBeVisible()); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getAllByText('SMS verification')[0]?.closest('li')).toHaveTextContent('+1 (555) 555-0101'); + expect(screen.getAllByText('Default')).toHaveLength(1); + expect(fapi.mfa.phoneUpdates).toEqual([]); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0202' })); 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: '+15555550202' }, - { phase: 'refresh', method: 'get', path: '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/v1/me', pendingPhone: '+15555550101' }, + { 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 }) => { @@ -675,7 +749,7 @@ describe('User profile MFA management', () => { 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 +15555550202' }); + 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)); @@ -692,9 +766,9 @@ describe('User profile MFA management', () => { } finally { held.release(); } - await waitFor(() => expect(screen.getByText('+15555550202').closest('li')).toHaveTextContent('Default')); + 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 +15555550101' })); + 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()); }, ); @@ -752,4 +826,32 @@ describe('User profile MFA management', () => { 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 index 0989e456741..d068f16441c 100644 --- 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 @@ -46,14 +46,28 @@ describe('User profile MFA', () => { 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('+15555550202'); + expect(rows[0]?.closest('li')).toHaveTextContent('+1 (555) 555-0202'); expect(rows[0]?.closest('li')).toHaveTextContent('Default'); - expect(rows[1]?.closest('li')).toHaveTextContent('+15555550101'); + expect(rows[1]?.closest('li')).toHaveTextContent('+1 (555) 555-0101'); expect(rows[1]?.closest('li')).not.toHaveTextContent('Default'); - expect(screen.getByText('+15555550202')).toBeVisible(); + 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({ 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(); @@ -149,7 +163,7 @@ describe('User profile MFA', () => { ); expect(screen.getByText('SMS verification')).toBeVisible(); expect(screen.queryByText('Authenticator app')).toBeNull(); - expect(screen.queryByRole('button', { name: 'Manage SMS verification +15555550101' })).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 () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.messages.ts index 23199c87c40..490cd8dabd1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.messages.ts @@ -11,4 +11,8 @@ export const userProfileBackupCodesMessages = { generating: 'Generating backup codes', copying: 'Copying backup codes', downloading: 'Downloading backup codes', + fileName: '{applicationName}_backup_codes.txt', + fileIntro: 'These are your backup codes for {applicationName} account {identifier}.', + fileInstructions: 'Store them securely and keep them secret. Each code can only be used once.', + printTitle: 'Your backup codes for {applicationName} account {identifier}', } as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx index f92c9787cd0..71512495631 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx @@ -17,7 +17,7 @@ export interface UserProfileBackupCodesViewProps { onRetry: () => void; onCopy: () => void; onDownload: () => void; - onPrint?: (title: string) => void; + onPrint?: () => void; pendingAction?: 'generate' | 'copy' | 'download'; errorMessage?: string; } @@ -100,7 +100,7 @@ export function UserProfileBackupCodesView({ variant='outline' color='neutral' fullWidth - onClick={() => onPrint(m.codesLabel)} + onClick={onPrint} > {m.print} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx index 56c28c62873..9b031d955ae 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx @@ -1,3 +1,4 @@ +import { stringToFormattedPhoneString } from '@clerk/shared/phone'; import type { Ref } from 'react'; import type { ActionMenuAction } from '../../../components/action-menu'; @@ -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 index 0b0c8f869ae..14a8df556c9 100644 --- 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 @@ -41,7 +41,13 @@ export function useUserProfileMfaSectionLeafController({ } operation.current = 'default'; try { - await defaultAction.run('default', () => onSetDefault(id)); + await defaultAction.run('default', () => + Promise.resolve(onSetDefault(id)).catch((error: unknown) => { + if (!(error instanceof MfaCancelledError)) { + throw error; + } + }), + ); } finally { operation.current = 'idle'; } 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 index a5082006668..e1444c25c84 100644 --- 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 @@ -2,7 +2,7 @@ import { ClerkRuntimeError } from '@clerk/shared/error'; import { useRef } from 'react'; import { useNow } from '../../../hooks/use-now'; -import { type ErrorDescription, useErrorText } from '../../../localization'; +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'; @@ -314,7 +314,14 @@ function createSmsActions( return { submitSms, resendSms }; } -function createBackupCodeActions(model: ReadyModel, flow: MfaFlow, run: RunMfaAction, send: MfaSend) { +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) { @@ -342,18 +349,20 @@ function createBackupCodeActions(model: ReadyModel, flow: MfaFlow, run: RunMfaAc if (flow.kind !== 'backup' || !flow.codes.length) { return; } - const url = URL.createObjectURL(new Blob([flow.codes.join('\n')], { type: 'text/plain' })); + 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 = 'clerk-backup-codes.txt'; + anchor.download = fill(m.fileName, fileValues); anchor.click(); URL.revokeObjectURL(url); }; - const print = (titleText: string) => { + 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' }); @@ -390,7 +399,7 @@ interface MfaSetupActions { generateBackupCodes: () => void; copy: () => void; download: () => void; - print: (titleText: string) => void; + print: () => void; } function createSetupProps( @@ -470,6 +479,7 @@ function useMfaActionRunner(value: string, send: MfaSend) { export function useUserProfileMfaController(model: ReadyModel, reverificationModel: ReverificationModel) { const errorText = useErrorText(); + const backupCodeMessages = useMessages('userProfileBackupCodes'); const reverificationProps = useReverificationController(reverificationModel, model.resetReverification); const [{ context, value }, send] = useMachine(mfaMachine); const { run, isLocked } = useMfaActionRunner(value, send); @@ -479,7 +489,13 @@ export function useUserProfileMfaController(model: ReadyModel, reverificationMod const flow = context.flow; const { prepareAuthenticator, verifyAuthenticator } = createAuthenticatorActions(model, flow, run); const { submitSms, resendSms } = createSmsActions(model, flow, resendAvailableAt, run, send); - const { generateBackupCodes, copy, download, print } = createBackupCodeActions(model, flow, 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 }); 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 index e5590a29c3e..5828a560b9d 100644 --- 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 @@ -1,4 +1,5 @@ import { ClerkRuntimeError, isReverificationCancelledError } 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'; @@ -24,6 +25,7 @@ type ProtectedOperation = | { kind: 'reservePhone'; userId: string; sessionId: string; phoneId: string } | { kind: 'removeAuthenticator'; userId: string; sessionId: string } | { kind: 'removePhone'; userId: string; sessionId: string; phoneId: string } + | { kind: 'setDefaultPhone'; userId: string; sessionId: string; phoneId: string } | { kind: 'generateBackupCodes'; userId: string; sessionId: string }; function configuredFactors(environment: EnvironmentResource | undefined): string[] { @@ -108,6 +110,14 @@ async function executeProtectedOperation( await phone.setReservedForSecondFactor({ reserved: false }); return { kind: 'done' } as const; } + case 'setDefaultPhone': { + const phone = current.phoneNumbers.find(item => item.id === operation.phoneId); + if (!phone?.reservedForSecondFactor || phone.verification.status !== 'verified') { + throw new ClerkRuntimeError('This phone number is unavailable.', { code: 'mfa_phone_unavailable' }); + } + await phone.makeDefaultSecondFactor(); + return { kind: 'done' } as const; + } case 'generateBackupCodes': { if (!currentSecondFactors.includes('backup_code') || usableFactorCount(current, currentSecondFactors) === 0) { throw new ClerkRuntimeError('Set up a verification method first.', { code: 'mfa_setup_factor_first' }); @@ -126,8 +136,9 @@ function describeMfa(user: UserResource, environment: EnvironmentResource): MfaS 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 (totpEnabled && user.totpEnabled) { + if (authenticatorEnrolled) { methods.push({ id: 'authenticator', type: 'authenticator', isDefault: true, canRemove }); } for (const phone of smsEnabled @@ -137,9 +148,9 @@ function describeMfa(user: UserResource, environment: EnvironmentResource): MfaS id: phone.id, type: 'sms', description: phone.phoneNumber, - isDefault: !(totpEnabled && user.totpEnabled) && phone.defaultSecondFactor, + isDefault: !authenticatorEnrolled && phone.defaultSecondFactor, canRemove, - canSetDefault: !phone.defaultSecondFactor, + canSetDefault: !authenticatorEnrolled && !phone.defaultSecondFactor, }); } if (backupEnabled && user.backupCodeEnabled) { @@ -210,11 +221,7 @@ function createSmsActions( await currentPhone(phoneId).prepareVerification(); }, setDefault: async phoneId => { - const phone = currentPhone(phoneId); - if (phone.verification.status !== 'verified' || !phone.reservedForSecondFactor) { - throw new ClerkRuntimeError('This phone number is unavailable.', { code: 'mfa_phone_unavailable' }); - } - await phone.makeDefaultSecondFactor(); + await runProtected({ kind: 'setDefaultPhone', userId, sessionId, phoneId }); await refresh().catch(() => undefined); }, }; @@ -270,6 +277,8 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { status: 'ready', userId, sessionId, + applicationName: environment.displayConfig.applicationName, + identifier: getIdentifier(user), methods, addableMethods, phones, 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 index 014a5a431b2..44d7d688b75 100644 --- 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 @@ -35,6 +35,8 @@ export type UserProfileMfaModel = status: 'ready'; userId: string; sessionId: string; + applicationName: string; + identifier: string; methods: readonly UserProfileMfaMethod[]; addableMethods: readonly UserProfileMfaAddableMethod[]; phones: readonly MfaPhone[]; diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx index 3c854d08d1b..d56fd831afa 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx @@ -1,3 +1,4 @@ +import { stringToFormattedPhoneString } from '@clerk/shared/phone'; import { type ReactNode, type Ref } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; @@ -97,7 +98,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/swingset/src/stories/fixtures/user-profile-mfa.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts index 7d808b20fdc..d51fc6d64b8 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts @@ -4,7 +4,6 @@ import type { UserProfileMfaSectionViewProps, } 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 { stringToFormattedPhoneString } from '@clerk/shared/phone'; 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, }; From da1263069f93b2afc4e6479742851405551e2474 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 17:23:27 -0600 Subject: [PATCH 18/26] refactor(mosaic): open MFA removal through the confirmation handle --- ...ser-profile-mfa-section-leaf.controller.ts | 85 +++++-------------- .../user-profile-mfa-section.view.tsx | 20 ++--- 2 files changed, 28 insertions(+), 77 deletions(-) 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 index 14a8df556c9..9e76dc47b14 100644 --- 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 @@ -1,6 +1,5 @@ import { useRef, useState } from 'react'; -import { useConfirmationController } from '../../../blocks/confirmation/confirmation.controller'; import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; import { usePendingAction } from '../../../hooks/use-pending-action'; import { useMessages } from '../../../localization'; @@ -14,8 +13,6 @@ export function useUserProfileMfaSectionLeafController({ }: UserProfileMfaSectionViewProps) { const m = useMessages('userProfileMfa'); const sectionRef = useRef(null); - const triggers = useRef(new Map()); - const lastRemovalId = useRef(); const removalFocus = useListRemovalFocus({ ids: methods.map(method => method.id), onRemove, @@ -23,53 +20,20 @@ export function useUserProfileMfaSectionLeafController({ sectionRef.current?.querySelector('button:not([disabled])') ?? sectionRef.current, }); const [pickerOpen, setPickerOpen] = useState(false); - const [selectedMethod, setSelectedMethod] = useState(); - const operation = useRef<'idle' | 'default' | 'remove'>('idle'); const defaultAction = usePendingAction<'default'>({ errorFallback: m.setDefaultError }); - const confirmation = useConfirmationController(); - const setDefault = async (id: string) => { + const setDefault = (id: string) => { const method = methods.find(method => method.id === id); - if ( - !onSetDefault || - method?.type !== 'sms' || - !method.canSetDefault || - method.isDefault || - operation.current !== 'idle' - ) { + if (!onSetDefault || method?.type !== 'sms' || !method.canSetDefault || method.isDefault) { return; } - operation.current = 'default'; - try { - await defaultAction.run('default', () => - Promise.resolve(onSetDefault(id)).catch((error: unknown) => { - if (!(error instanceof MfaCancelledError)) { - throw error; - } - }), - ); - } finally { - operation.current = 'idle'; - } - }; - - const confirmRemoval = () => { - if (!selectedMethod || !confirmation.isOpen || operation.current !== 'idle') { - return; - } - const method = selectedMethod; - operation.current = 'remove'; - confirmation.onConfirm(async () => { - try { - await removalFocus.remove(method.id); - } catch (error) { + void defaultAction.run('default', () => + Promise.resolve(onSetDefault(id)).catch((error: unknown) => { if (!(error instanceof MfaCancelledError)) { throw error; } - } finally { - operation.current = 'idle'; - } - }); + }), + ); }; return { @@ -77,33 +41,24 @@ export function useUserProfileMfaSectionLeafController({ pickerOpen, onPickerOpenChange: setPickerOpen, closePicker: () => setPickerOpen(false), - selectedMethod, - confirmation, - onRemovalOpenChange: confirmation.onOpenChange, - openRemoval: (method: UserProfileMfaMethod) => { - if (operation.current === 'idle') { - defaultAction.reset(); - lastRemovalId.current = method.id; - setSelectedMethod(method); - confirmation.onOpenChange(true); + registerTrigger: removalFocus.registerTrigger, + openRemoval: (open: () => void) => { + if (defaultAction.isPending) { + return; } + defaultAction.reset(); + open(); }, - confirmRemoval, - finalRemovalFocus: () => - removalFocus.finalFocus() ?? - (lastRemovalId.current ? triggers.current.get(lastRemovalId.current) : undefined) ?? - sectionRef.current, - registerTrigger: (id: string) => { - const registerRemovalTrigger = removalFocus.registerTrigger(id); - return (element: HTMLButtonElement | null) => { - registerRemovalTrigger(element); - if (element) { - triggers.current.set(id, element); - } else { - triggers.current.delete(id); + confirmRemoval: async (method: UserProfileMfaMethod) => { + try { + await removalFocus.remove(method.id); + } catch (error: unknown) { + if (!(error instanceof MfaCancelledError)) { + throw error; } - }; + } }, + 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.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx index d56fd831afa..4efa3afe2c8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx @@ -1,5 +1,5 @@ import { stringToFormattedPhoneString } from '@clerk/shared/phone'; -import { type ReactNode, type Ref } from 'react'; +import { type ReactNode, type Ref, useMemo } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; import { Section } from '../../../components/section'; @@ -36,6 +36,7 @@ export function UserProfileMfaSectionView({ }: UserProfileMfaSectionViewProps) { const m = useMessages('userProfileMfa'); const controller = useUserProfileMfaSectionLeafController({ methods, onRemove, onSetDefault }); + const removeMethod = useMemo(() => Confirmation.createHandle(), []); return ( <> @@ -69,26 +70,21 @@ export function UserProfileMfaSectionView({ key={method.id} method={method} triggerRef={controller.registerTrigger(method.id)} - onRemove={onRemove ? () => controller.openRemoval(method) : undefined} - onSetDefault={ - onSetDefault && !controller.isSettingDefault ? id => void controller.setDefault(id) : undefined - } + onRemove={onRemove ? () => controller.openRemoval(() => removeMethod.open(method)) : undefined} + onSetDefault={onSetDefault && !controller.isSettingDefault ? controller.setDefault : undefined} onRegenerateBackupCodes={onRegenerateBackupCodes} /> ))} {controller.defaultError} - {onRemove && controller.selectedMethod ? ( + {onRemove ? ( (method.type === 'sms' ? m.removeDialog.smsTitle : m.removeDialog.authenticatorTitle)} + description={method => describeMethodRemoval(method, m)} actionLabel={m.removeDialog.confirm} finalFocus={controller.finalRemovalFocus} onConfirm={controller.confirmRemoval} - isConfirming={controller.confirmation.isConfirming} - errorMessage={controller.confirmation.errorMessage} /> ) : null} From d16682a0fc6772cacb605878f31e46b9dafcaf1e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 17:27:14 -0600 Subject: [PATCH 19/26] refactor(mosaic): let MFA reverification cancellation propagate as a Clerk error --- .../user-profile-mfa-section-leaf.controller.ts | 7 ++++--- .../user-profile-mfa-section.controller.ts | 10 +++------- .../user-profile-mfa-section.model.ts | 14 ++------------ .../user-profile-mfa-section.types.ts | 2 -- 4 files changed, 9 insertions(+), 24 deletions(-) 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 index 9e76dc47b14..19a0e74a335 100644 --- 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 @@ -1,9 +1,10 @@ +import { isReverificationCancelledError } from '@clerk/shared/error'; 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 { MfaCancelledError, type UserProfileMfaMethod } from './user-profile-mfa-section.types'; +import type { UserProfileMfaMethod } from './user-profile-mfa-section.types'; import type { UserProfileMfaSectionViewProps } from './user-profile-mfa-section.view'; export function useUserProfileMfaSectionLeafController({ @@ -29,7 +30,7 @@ export function useUserProfileMfaSectionLeafController({ } void defaultAction.run('default', () => Promise.resolve(onSetDefault(id)).catch((error: unknown) => { - if (!(error instanceof MfaCancelledError)) { + if (!isReverificationCancelledError(error)) { throw error; } }), @@ -53,7 +54,7 @@ export function useUserProfileMfaSectionLeafController({ try { await removalFocus.remove(method.id); } catch (error: unknown) { - if (!(error instanceof MfaCancelledError)) { + if (!isReverificationCancelledError(error)) { throw error; } } 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 index e1444c25c84..42b021fbaf9 100644 --- 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 @@ -1,4 +1,4 @@ -import { ClerkRuntimeError } from '@clerk/shared/error'; +import { ClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; import { useRef } from 'react'; import { useNow } from '../../../hooks/use-now'; @@ -9,11 +9,7 @@ import { useMachine } from '../../../machine/use-machine'; import { toLocalizableError } from '../../../utils/errors'; import { useReverificationController } from '../../reverification/reverification.controller'; import type { ReverificationModel } from '../../reverification/reverification.model'; -import { - MfaCancelledError, - type UserProfileMfaAddableMethod, - type UserProfileMfaModel, -} from './user-profile-mfa-section.types'; +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'; @@ -210,7 +206,7 @@ const mfaMachine = createMachine({ actions: [ (context, event) => context.reject(event.error), assign((context, event) => { - if (event.error instanceof MfaCancelledError) { + if (isReverificationCancelledError(event.error)) { return { flow: { kind: 'closed' }, savedSetup: undefined, pending: undefined }; } const flow = context.flow; 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 index 5828a560b9d..ee3c0b096af 100644 --- 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 @@ -1,4 +1,4 @@ -import { ClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; +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'; @@ -6,7 +6,6 @@ import type { EnvironmentResource, PhoneNumberResource, UserResource } from '@cl import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useReverificationWithState } from '../../reverification/use-reverification-with-state'; import { - MfaCancelledError, type MfaEnrollmentResult, type MfaPhone, type SmsEnrollmentResult, @@ -260,16 +259,7 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { const current = requireIdentity(clerk, userId, sessionId); await current.reload(); }; - const runProtected = async (operation: ProtectedOperation) => { - try { - return await protectedAction(operation); - } catch (error) { - if (isReverificationCancelledError(error)) { - throw new MfaCancelledError('Verification was cancelled.'); - } - throw error; - } - }; + const runProtected = (operation: ProtectedOperation) => protectedAction(operation); const smsActions = smsEnabled ? createSmsActions(clerk, userId, sessionId, runProtected, refresh) : {}; 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 index 44d7d688b75..dd463a6a812 100644 --- 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 @@ -18,8 +18,6 @@ export interface MfaPhone extends Pick Date: Thu, 8 Oct 2026 17:39:32 -0600 Subject: [PATCH 20/26] refactor(mosaic): remove MFA reverification wiring --- ...er-profile-mfa-management.feature.test.tsx | 300 ------------------ ...ser-profile-mfa-section-leaf.controller.ts | 19 +- .../user-profile-mfa-section.controller.ts | 27 +- .../user-profile-mfa-section.model.ts | 174 ++++------ .../user-profile-mfa-section.tsx | 46 +-- .../user-profile-mfa-section.types.ts | 8 +- .../src/localization/errors.messages.ts | 3 +- 7 files changed, 78 insertions(+), 499 deletions(-) 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 index 954a4b47cad..2e8c5818124 100644 --- 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 @@ -183,89 +183,6 @@ describe('User profile MFA management', () => { expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_2', reserved: false, default: undefined }); }); - it('shows second-factor reverification for removal with no addable methods', async () => { - const environment = mfaEnvironment(); - environment.user_settings.attributes.phone_number.used_for_second_factor = false; - const fapi = serveFapi({ - environment, - client: fapiClient([ - fapiSession({ - id: 'sess_1', - user: fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), - }), - ]), - verification: { secondFactors: [{ strategy: 'totp' }], secrets: { totp: '654321' } }, - }); - await renderWithClerk(); - let attempts = 0; - worker.use( - http.post(fapiUrl('/v1/me/totp'), ({ request }) => { - if (new URL(request.url).searchParams.get('_method') !== 'DELETE') { - return undefined; - } - attempts += 1; - return attempts === 1 || fapi.verification.status !== 'complete' - ? HttpResponse.json( - { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, - { 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.getByText('Verification required')).toBeVisible()); - expect(fapi.mfa.totpRemovals).toBe(0); - await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '654321'); - await waitFor(() => expect(fapi.mfa.totpRemovals).toBe(1)); - await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); - }); - - it('leaves the enrolled method intact when removal reverification is cancelled', async () => { - const environment = mfaEnvironment(); - environment.user_settings.attributes.phone_number.used_for_second_factor = false; - const fapi = serveFapi({ - environment, - client: fapiClient([ - fapiSession({ - id: 'sess_1', - user: fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), - }), - ]), - verification: { secondFactors: [{ strategy: 'totp' }], secrets: { totp: '654321' } }, - }); - await renderWithClerk(); - worker.use( - http.post(fapiUrl('/v1/me/totp'), ({ request }) => - new URL(request.url).searchParams.get('_method') === 'DELETE' - ? HttpResponse.json( - { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, - { 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.getByText('Verification required')).toBeVisible()); - await user.keyboard('{Escape}'); - await waitFor(() => expect(screen.queryByText('Verification required')).toBeNull()); - await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); - expect(fapi.mfa.totpRemovals).toBe(0); - expect(screen.getByText('Authenticator app')).toBeVisible(); - expect(screen.queryByRole('alert')).toBeNull(); - serveFapi(fapi); - 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)); - await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); - }); - 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 })); @@ -278,156 +195,6 @@ describe('User profile MFA management', () => { expect(action).toHaveFocus(); }); - it('reverifies the first factor before creating an authenticator secret', async () => { - const fapi = await renderMfa(); - fapi.verification.secrets.password = 'hunter2'; - let attempts = 0; - worker.use( - http.post(fapiUrl('/v1/me/totp'), () => { - attempts += 1; - return attempts === 1 - ? HttpResponse.json( - { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, - { status: 403 }, - ) - : 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.getByText('Verification required')).toBeVisible()); - expect(fapi.mfa.totpCreations).toBe(0); - const password = await screen.findByLabelText('Password'); - await waitFor(() => expect(password).toBeVisible()); - await user.type(password, 'hunter2{Enter}'); - await waitFor(() => expect(fapi.verification.status).toBe('complete')); - await waitFor(() => expect(fapi.mfa.totpCreations).toBe(1)); - expect(await screen.findByRole('img', { name: /QR code/i }, { timeout: 3000 })).toBeVisible(); - expect(fapi.mfa.totpCreations).toBe(1); - }); - - it('waits for enrolled second-factor verification before regenerating backup codes', async () => { - const fapi = serveFapi({ - environment: mfaEnvironment(), - client: fapiClient([ - fapiSession({ - id: 'sess_1', - user: fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), - }), - ]), - verification: { secondFactors: [{ strategy: 'totp' }], secrets: { totp: '654321' } }, - }); - await renderWithClerk(); - let attempts = 0; - worker.use( - http.post(fapiUrl('/v1/me/backup_codes/'), () => { - attempts += 1; - return attempts === 1 || fapi.verification.status !== 'complete' - ? HttpResponse.json( - { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, - { status: 403 }, - ) - : undefined; - }), - ); - 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('Verification required')).toBeVisible()); - expect(fapi.mfa.backupCodeCreations).toBe(0); - await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '654321'); - await waitFor(() => expect(fapi.verification.status).toBe('complete')); - await waitFor(() => expect(screen.getByText('CODE0100')).toBeVisible()); - expect(fapi.mfa.backupCodeCreations).toBe(1); - }); - - it('cancels authenticator enrollment without showing an error or creating a secret', async () => { - const fapi = await renderMfa(); - worker.use( - http.post(fapiUrl('/v1/me/totp'), () => - HttpResponse.json( - { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, - { 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.getByText('Verification required')).toBeVisible()); - await user.keyboard('{Escape}'); - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - expect(screen.queryByRole('alert')).toBeNull(); - expect(fapi.mfa.totpCreations).toBe(0); - }); - - it('does not retry a pending authenticator challenge after the active account changes', 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(); - worker.use( - http.post(fapiUrl('/v1/me/totp'), () => - HttpResponse.json( - { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, - { 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.getByText('Verification required')).toBeVisible()); - await clerk.setActive({ session: 'sess_2' }); - await waitFor(() => expect(fapi.client.last_active_session_id).toBe('sess_2')); - await waitFor(() => expect(screen.queryByText('Verification required')).toBeNull()); - expect(fapi.mfa.totpCreations).toBe(0); - }); - - it('rechecks phone eligibility after reverification before reserving it', async () => { - const fapi = serveFapi({ - environment: mfaEnvironment(), - client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', phone_numbers: [phone] }) })]), - verification: { secrets: { password: 'hunter2' } }, - }); - await renderWithClerk(); - let attempts = 0; - worker.use( - http.post(fapiUrl('/v1/me/phone_numbers/phone_1'), ({ request }) => { - if (new URL(request.url).searchParams.get('_method') !== 'PATCH') { - return undefined; - } - attempts += 1; - return HttpResponse.json( - { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, - { status: 403 }, - ); - }), - ); - 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('Verification required')).toBeVisible()); - const password = await screen.findByLabelText('Password'); - await waitFor(() => expect(password).toBeVisible()); - const session = fapi.client.sessions.find(item => item.id === 'sess_1'); - const seededPhone = session?.user?.phone_numbers.find(item => item.id === 'phone_1'); - if (!seededPhone) { - throw new Error('Missing seeded phone'); - } - seededPhone.reserved_for_second_factor = true; - await user.type(password, 'hunter2{Enter}'); - await waitFor(() => expect(screen.getByText('This phone number is unavailable.')).toBeVisible()); - expect(attempts).toBe(1); - expect(fapi.mfa.phoneUpdates).toHaveLength(0); - }); - it('does not show a late authenticator setup response in another account', async () => { const fapi = serveFapi({ environment: mfaEnvironment(), @@ -671,73 +438,6 @@ describe('User profile MFA management', () => { await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as default' })).toBeVisible()); }); - it('reverifies before setting a different SMS phone as default', async () => { - const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); - const second = fapiPhoneNumber({ 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 })); - fapi.verification.secrets.password = 'hunter2'; - 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: 'session_reverification_required', message: 'Reverification required' }] }, - { status: 403 }, - ) - : undefined; - }), - ); - 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.getByText('Verification required')).toBeVisible()); - expect(fapi.mfa.phoneUpdates).toEqual([]); - const password = await screen.findByLabelText('Password'); - await waitFor(() => expect(password).toBeVisible()); - await user.type(password, 'hunter2{Enter}'); - await waitFor(() => - expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_2', default: true, reserved: undefined }), - ); - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - 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'); - expect(screen.queryByRole('alert')).toBeNull(); - }); - - it('leaves the default unchanged when set-default reverification is cancelled', async () => { - const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); - const second = fapiPhoneNumber({ 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 })); - worker.use( - http.post(fapiUrl('/v1/me/phone_numbers/phone_2'), ({ request }) => - new URL(request.url).searchParams.get('_method') === 'PATCH' - ? HttpResponse.json( - { errors: [{ code: 'session_reverification_required', message: 'Reverification required' }] }, - { status: 403 }, - ) - : undefined, - ), - ); - 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.getByText('Verification required')).toBeVisible()); - await user.keyboard('{Escape}'); - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - expect(screen.queryByRole('alert')).toBeNull(); - expect(screen.getAllByText('SMS verification')[0]?.closest('li')).toHaveTextContent('+1 (555) 555-0101'); - expect(screen.getAllByText('Default')).toHaveLength(1); - expect(fapi.mfa.phoneUpdates).toEqual([]); - await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0202' })); - 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' }, 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 index 19a0e74a335..8f2609c9a45 100644 --- 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 @@ -1,4 +1,3 @@ -import { isReverificationCancelledError } from '@clerk/shared/error'; import { useRef, useState } from 'react'; import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; @@ -28,13 +27,7 @@ export function useUserProfileMfaSectionLeafController({ if (!onSetDefault || method?.type !== 'sms' || !method.canSetDefault || method.isDefault) { return; } - void defaultAction.run('default', () => - Promise.resolve(onSetDefault(id)).catch((error: unknown) => { - if (!isReverificationCancelledError(error)) { - throw error; - } - }), - ); + void defaultAction.run('default', () => onSetDefault(id)); }; return { @@ -50,15 +43,7 @@ export function useUserProfileMfaSectionLeafController({ defaultAction.reset(); open(); }, - confirmRemoval: async (method: UserProfileMfaMethod) => { - try { - await removalFocus.remove(method.id); - } catch (error: unknown) { - if (!isReverificationCancelledError(error)) { - throw error; - } - } - }, + confirmRemoval: (method: UserProfileMfaMethod) => removalFocus.remove(method.id), finalRemovalFocus: removalFocus.finalFocus, isSettingDefault: defaultAction.isPending, defaultError: defaultAction.error, 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 index 42b021fbaf9..a8848774d7b 100644 --- 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 @@ -1,4 +1,4 @@ -import { ClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; +import { ClerkRuntimeError } from '@clerk/shared/error'; import { useRef } from 'react'; import { useNow } from '../../../hooks/use-now'; @@ -7,8 +7,6 @@ import { setup } from '../../../machine/setup'; import type { DoneInvokeEvent } from '../../../machine/types'; import { useMachine } from '../../../machine/use-machine'; import { toLocalizableError } from '../../../utils/errors'; -import { useReverificationController } from '../../reverification/reverification.controller'; -import type { ReverificationModel } from '../../reverification/reverification.model'; 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'; @@ -206,9 +204,6 @@ const mfaMachine = createMachine({ actions: [ (context, event) => context.reject(event.error), assign((context, event) => { - if (isReverificationCancelledError(event.error)) { - return { flow: { kind: 'closed' }, savedSetup: undefined, pending: undefined }; - } const flow = context.flow; if (flow.kind !== 'authenticator' && flow.kind !== 'sms' && flow.kind !== 'backup') { return { pending: undefined }; @@ -473,10 +468,9 @@ function useMfaActionRunner(value: string, send: MfaSend) { return { run, isLocked: () => locked.current }; } -export function useUserProfileMfaController(model: ReadyModel, reverificationModel: ReverificationModel) { +export function useUserProfileMfaController(model: ReadyModel) { const errorText = useErrorText(); const backupCodeMessages = useMessages('userProfileBackupCodes'); - const reverificationProps = useReverificationController(reverificationModel, model.resetReverification); const [{ context, value }, send] = useMachine(mfaMachine); const { run, isLocked } = useMfaActionRunner(value, send); const now = useNow({ updateInterval: 1000 }); @@ -554,27 +548,12 @@ export function useUserProfileMfaController(model: ReadyModel, reverificationMod const dialogOpen = flow.kind !== 'closed'; const showAddTrigger = model.addableMethods.length > 0; - const showReverification = model.reverification.phase === 'active' || model.reverification.phase === 'retrying'; return { dialogOpen, showAddTrigger, showAddControl: showAddTrigger || dialogOpen, - showReverification, - separateReverificationOpen: !dialogOpen && showReverification, - onSeparateReverificationOpenChange: (open: boolean) => { - if (!open && model.reverification.phase === 'active') { - model.reverification.cancel(); - } - }, - reverificationProps, - onDialogOpenChange: (open: boolean) => { - if (!open && model.reverification.phase === 'active') { - model.reverification.cancel(); - return; - } - send({ type: open ? 'OPEN' : 'CLOSE' }); - }, + onDialogOpenChange: (open: boolean) => send({ type: open ? 'OPEN' : 'CLOSE' }), sectionProps, setupProps, }; 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 index ee3c0b096af..555826f6d5b 100644 --- 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 @@ -4,28 +4,17 @@ 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 { useReverificationWithState } from '../../reverification/use-reverification-with-state'; -import { - type MfaEnrollmentResult, - type MfaPhone, - type SmsEnrollmentResult, - type UserProfileMfaAddableMethod, - type UserProfileMfaMethod, - type UserProfileMfaModel, +import type { + MfaEnrollmentResult, + MfaPhone, + SmsEnrollmentResult, + UserProfileMfaAddableMethod, + UserProfileMfaMethod, + UserProfileMfaModel, } from './user-profile-mfa-section.types'; type ReadyModel = Extract; type MfaSnapshotData = Pick; -type ProtectedResult = Awaited>; -type RunProtected = (operation: ProtectedOperation) => Promise; - -type ProtectedOperation = - | { kind: 'createAuthenticator'; userId: string; sessionId: string } - | { kind: 'reservePhone'; userId: string; sessionId: string; phoneId: string } - | { kind: 'removeAuthenticator'; userId: string; sessionId: string } - | { kind: 'removePhone'; userId: string; sessionId: string; phoneId: string } - | { kind: 'setDefaultPhone'; userId: string; sessionId: string; phoneId: string } - | { kind: 'generateBackupCodes'; userId: string; sessionId: string }; function configuredFactors(environment: EnvironmentResource | undefined): string[] { return Object.values(environment?.userSettings.attributes ?? {}).flatMap(attribute => @@ -52,78 +41,21 @@ function summarizePhone(phone: PhoneNumberResource): MfaPhone { }; } +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 reopen verification.', { code: 'mfa_account_changed' }); + throw new ClerkRuntimeError('This account changed. Please try again.', { code: 'mfa_account_changed' }); } return current; } -async function executeProtectedOperation( - clerk: ReturnType, - environment: EnvironmentResource | undefined, - operation: ProtectedOperation, -) { - const current = requireIdentity(clerk, operation.userId, operation.sessionId); - const currentSecondFactors = configuredFactors(environment); - switch (operation.kind) { - case 'createAuthenticator': { - if (current.totpEnabled || !currentSecondFactors.includes('totp')) { - throw new ClerkRuntimeError('Authenticator is unavailable.', { code: 'mfa_authenticator_unavailable' }); - } - const result = await current.createTOTP(); - return { kind: 'authenticatorSetup', secret: result.secret ?? '', uri: result.uri ?? '' } as const; - } - case 'reservePhone': { - const phone = current.phoneNumbers.find(item => item.id === operation.phoneId); - if ( - !phone || - phone.verification.status !== 'verified' || - phone.reservedForSecondFactor || - !currentSecondFactors.includes('phone_code') - ) { - throw new ClerkRuntimeError('This phone number is unavailable.', { code: 'mfa_phone_unavailable' }); - } - const result = await phone.setReservedForSecondFactor({ reserved: true }); - return { kind: 'enrollment', backupCodes: result.backupCodes ?? [] } as const; - } - case 'removeAuthenticator': { - const usableCount = usableFactorCount(current, currentSecondFactors); - if (!current.totpEnabled || (environment?.userSettings.signUp.mfa?.required && usableCount <= 1)) { - throw new ClerkRuntimeError('This method cannot be removed.', { code: 'mfa_method_cannot_remove' }); - } - await current.disableTOTP(); - return { kind: 'done' } as const; - } - case 'removePhone': { - const phone = current.phoneNumbers.find(item => item.id === operation.phoneId); - const usableCount = usableFactorCount(current, currentSecondFactors); - if ( - !phone?.reservedForSecondFactor || - phone.verification.status !== 'verified' || - (environment?.userSettings.signUp.mfa?.required && usableCount <= 1) - ) { - throw new ClerkRuntimeError('This method cannot be removed.', { code: 'mfa_method_cannot_remove' }); - } - await phone.setReservedForSecondFactor({ reserved: false }); - return { kind: 'done' } as const; - } - case 'setDefaultPhone': { - const phone = current.phoneNumbers.find(item => item.id === operation.phoneId); - if (!phone?.reservedForSecondFactor || phone.verification.status !== 'verified') { - throw new ClerkRuntimeError('This phone number is unavailable.', { code: 'mfa_phone_unavailable' }); - } - await phone.makeDefaultSecondFactor(); - return { kind: 'done' } as const; - } - case 'generateBackupCodes': { - if (!currentSecondFactors.includes('backup_code') || usableFactorCount(current, currentSecondFactors) === 0) { - throw new ClerkRuntimeError('Set up a verification method first.', { code: 'mfa_setup_factor_first' }); - } - const result = await current.createBackupCode(); - return { kind: 'backupCodes', codes: result.codes } as const; - } +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' }); } } @@ -175,16 +107,16 @@ function describeMfa(user: UserResource, environment: EnvironmentResource): MfaS function createSmsActions( clerk: ReturnType, + environment: EnvironmentResource, userId: string, sessionId: string, - runProtected: RunProtected, 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 new ClerkRuntimeError('This phone number is unavailable.', { code: 'mfa_phone_unavailable' }); + throw phoneUnavailable(); } return phone; }; @@ -207,20 +139,22 @@ function createSmsActions( await phone.attemptVerification({ code }); phone = currentPhone(phoneId); } - const result = await runProtected({ kind: 'reservePhone', userId, sessionId, phoneId }); - if (result.kind !== 'enrollment') { - throw new ClerkRuntimeError('The verification response was unexpected. Please try again.', { - code: 'mfa_unexpected_response', - }); + 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 }; + return { status: 'complete', backupCodes: result.backupCodes ?? [] }; }, resendSms: async phoneId => { await currentPhone(phoneId).prepareVerification(); }, setDefault: async phoneId => { - await runProtected({ kind: 'setDefaultPhone', userId, sessionId, phoneId }); + const phone = currentPhone(phoneId); + if (phone.verification.status !== 'verified' || !phone.reservedForSecondFactor) { + throw phoneUnavailable(); + } + await phone.makeDefaultSecondFactor(); await refresh().catch(() => undefined); }, }; @@ -232,15 +166,11 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { const { isLoaded: sessionLoaded, session } = useSession(); const environment = useMosaicEnvironment(); - const [protectedAction, reverification, resetReverification] = useReverificationWithState( - (operation: ProtectedOperation) => executeProtectedOperation(clerk, environment, operation), - ); - if (!userLoaded || !sessionLoaded || !environment) { - return { status: 'loading', reverification, resetReverification }; + return { status: 'loading' }; } if (!user || !session) { - return { status: 'hidden', reverification, resetReverification }; + return { status: 'hidden' }; } const secondFactors = configuredFactors(environment); @@ -248,7 +178,7 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { const totpEnabled = secondFactors.includes('totp'); const backupEnabled = secondFactors.includes('backup_code'); if (!smsEnabled && !totpEnabled) { - return { status: 'hidden', reverification, resetReverification }; + return { status: 'hidden' }; } const userId = user.id; @@ -259,9 +189,8 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { const current = requireIdentity(clerk, userId, sessionId); await current.reload(); }; - const runProtected = (operation: ProtectedOperation) => protectedAction(operation); - const smsActions = smsEnabled ? createSmsActions(clerk, userId, sessionId, runProtected, refresh) : {}; + const smsActions = smsEnabled ? createSmsActions(clerk, environment, userId, sessionId, refresh) : {}; return { status: 'ready', @@ -272,18 +201,15 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { methods, addableMethods, phones, - reverification, - resetReverification, ...smsActions, createAuthenticator: totpEnabled ? async () => { - const result = await runProtected({ kind: 'createAuthenticator', userId, sessionId }); - if (result.kind !== 'authenticatorSetup') { - throw new ClerkRuntimeError('The verification response was unexpected. Please try again.', { - code: 'mfa_unexpected_response', - }); + const current = requireIdentity(clerk, userId, sessionId); + if (current.totpEnabled || !configuredFactors(environment).includes('totp')) { + throw new ClerkRuntimeError('Authenticator is unavailable.', { code: 'mfa_authenticator_unavailable' }); } - return { secret: result.secret, uri: result.uri }; + const result = await current.createTOTP(); + return { secret: result.secret ?? '', uri: result.uri ?? '' }; } : undefined, verifyAuthenticator: totpEnabled @@ -297,22 +223,32 @@ export function useUserProfileMfaModel(): UserProfileMfaModel { generateBackupCodes: backupEnabled && usableCount > 0 ? async () => { - const result = await runProtected({ kind: 'generateBackupCodes', userId, sessionId }); - if (result.kind !== 'backupCodes') { - throw new ClerkRuntimeError('The verification response was unexpected. Please try again.', { - code: 'mfa_unexpected_response', - }); + 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 operation: ProtectedOperation = - methodId === 'authenticator' - ? { kind: 'removeAuthenticator', userId, sessionId } - : { kind: 'removePhone', userId, sessionId, phoneId: methodId }; - await runProtected(operation); + 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 index de974fadb17..0b8cba4569b 100644 --- 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 @@ -1,7 +1,5 @@ import type { ReactNode } from 'react'; -import { Reverification } from '../../reverification/reverification'; -import { useReverificationModel } from '../../reverification/reverification.model'; import { UserProfileAddMfaDialog } from './user-profile-add-mfa.dialog'; import { useUserProfileMfaController } from './user-profile-mfa-section.controller'; import { useUserProfileMfaModel } from './user-profile-mfa-section.model'; @@ -34,36 +32,22 @@ export function mfaSectionNode(model: UserProfileMfaModel, fallback: ReactNode = } function MfaEditor({ model }: { model: Extract }) { - const reverificationModel = useReverificationModel(model.reverification); - const controller = useUserProfileMfaController(model, reverificationModel); + const controller = useUserProfileMfaController(model); return ( - <> - - {controller.showReverification ? ( - - ) : ( - - )} - - ) : undefined - } - /> - - - - + + + + ) : 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 index dd463a6a812..43426e3812a 100644 --- 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 @@ -1,7 +1,5 @@ import type { PhoneNumberResource } from '@clerk/shared/types'; -import type { ReverificationState } from '../../reverification/reverification.types'; - export interface UserProfileMfaMethod { id: string; type: 'sms' | 'authenticator' | 'backup-codes'; @@ -27,8 +25,8 @@ export type SmsEnrollmentResult = | ({ status: 'complete' } & MfaEnrollmentResult); export type UserProfileMfaModel = - | { status: 'loading'; reverification: ReverificationState; resetReverification: () => void } - | { status: 'hidden'; reverification: ReverificationState; resetReverification: () => void } + | { status: 'loading' } + | { status: 'hidden' } | { status: 'ready'; userId: string; @@ -38,8 +36,6 @@ export type UserProfileMfaModel = methods: readonly UserProfileMfaMethod[]; addableMethods: readonly UserProfileMfaAddableMethod[]; phones: readonly MfaPhone[]; - reverification: ReverificationState; - resetReverification: () => void; findOrCreatePhone?: (phoneNumber: string) => Promise; enrollSms?: (phoneId: string, code?: string) => Promise; resendSms?: (phoneId: string) => Promise; diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 6ba50ea342d..37f1769aedd 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -32,14 +32,13 @@ export const errorMessages: { readonly generic: string } & Readonly Date: Thu, 8 Oct 2026 17:45:07 -0600 Subject: [PATCH 21/26] test(mosaic): align MFA pending assertions with focusable inputs --- .../__tests__/user-profile-mfa.feature.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index d068f16441c..708fb0bd911 100644 --- 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 @@ -298,7 +298,7 @@ describe('User profile MFA', () => { 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' }); + const code = await screen.findByRole('textbox', { name: 'Verification code' }); const held = holdRequests('post', '/v1/me/phone_numbers/phone_1/prepare_verification'); const later = Date.now() + 31_000; vi.useFakeTimers({ toFake: ['Date'] }); @@ -307,6 +307,8 @@ describe('User profile MFA', () => { 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 { @@ -314,6 +316,8 @@ describe('User profile MFA', () => { 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 () => { @@ -436,7 +440,7 @@ describe('User profile MFA', () => { 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).toBeEnabled()); + await waitFor(() => expect(number).not.toHaveAttribute('readonly')); serveFapi(fapi); } await user.clear(number); From 8c10f05b80a82de372dcf906e5f1d4d8c469a15f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 19:52:25 -0600 Subject: [PATCH 22/26] test(mosaic): remove sequential MFA deletion focus test --- ...er-profile-mfa-management.feature.test.tsx | 20 ------------------- 1 file changed, 20 deletions(-) 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 index 2e8c5818124..4733c23b80c 100644 --- 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 @@ -163,26 +163,6 @@ describe('User profile MFA management', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Add verification method' })).toHaveFocus()); }); - it('moves focus through remaining MFA rows and then to Add after sequential removals', async () => { - const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); - const second = fapiPhoneNumber({ 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-0101' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - const remaining = await screen.findByRole('button', { name: 'Manage SMS verification +1 (555) 555-0202' }); - await waitFor(() => expect(remaining).toHaveFocus()); - - await user.keyboard('{Enter}'); - 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('button', { name: 'Add verification method' })).toHaveFocus()); - expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_1', reserved: false, default: undefined }); - expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_2', reserved: false, default: undefined }); - }); - 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 })); From 245aca784acf7bc06c21bf42fe22e617f894cb95 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 20:12:47 -0600 Subject: [PATCH 23/26] test(mosaic): stabilize async assertions and remove deletion focus test --- .../__tests__/user-profile-mfa.feature.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 708fb0bd911..488a040e8e3 100644 --- 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 @@ -102,9 +102,9 @@ describe('User profile MFA', () => { 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 }); - expect(await screen.findByText('CODE0001')).toBeVisible(); }); it('shows the backend error when the last login identifier cannot be reserved', async () => { From 4696d2d17d254eab0c7fdf11131a0cf1a67073c1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 20:27:14 -0600 Subject: [PATCH 24/26] fix(mosaic): keep backup-code actions visible while generating --- ...er-profile-mfa-management.feature.test.tsx | 11 ++- .../user-profile-backup-codes.view.tsx | 71 +++++++++---------- 2 files changed, 41 insertions(+), 41 deletions(-) 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 index 4733c23b80c..35ce1f69a43 100644 --- 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 @@ -242,11 +242,18 @@ describe('User profile MFA management', () => { 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()); - expect(screen.queryByRole('button', { name: 'Copy and close' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Download' })).toBeNull(); + 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 () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx index 71512495631..a0dd16457d8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.view.tsx @@ -36,6 +36,7 @@ export function UserProfileBackupCodesView({ const m = useMessages('userProfileBackupCodes'); const actionRef = useFlowAutoFocus(); const hasCodes = codes.length > 0 && pendingAction !== 'generate'; + const showSaveActions = hasCodes || pendingAction === 'generate'; return ( <> @@ -92,7 +93,7 @@ export function UserProfileBackupCodesView({ ) : null} - {hasCodes ? ( + {showSaveActions ? ( <> {onPrint ? ( - - {m.retry} - - + )} + + {showSaveActions ? ( + + ) : null} + {showSaveActions ? m.copyAndClose : m.retry} + ); From 5fb1acd4ddbd289feb23074b2fb2866dbfb7f213 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 20:50:26 -0600 Subject: [PATCH 25/26] refactor(mosaic): let the MFA machine own only the setup flow Drop the hand-rolled action lock, hand row actions to their shared owners, handle dismissal while an action is in flight, and drive the resend countdown with the machine tick used by add-phone. --- ...-profile-mfa-interactions.feature.test.tsx | 34 ++++ .../user-profile-mfa.feature.test.tsx | 11 +- .../user-profile-mfa-section.controller.ts | 148 ++++++++---------- .../user-profile-mfa-section.tsx | 2 +- .../src/localization/errors.messages.ts | 1 - 5 files changed, 110 insertions(+), 86 deletions(-) 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 index d5a2de72905..52460d42121 100644 --- 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 @@ -92,6 +92,40 @@ describe('User profile MFA interaction boundaries', () => { 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(); 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 index 488a040e8e3..b7ea20df685 100644 --- 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 @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from '@testing-library/react'; +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'; @@ -293,17 +293,18 @@ describe('User profile MFA', () => { 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(); + 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'); - const later = Date.now() + 31_000; - vi.useFakeTimers({ toFake: ['Date'] }); - vi.setSystemTime(later); 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)); 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 index a8848774d7b..6d62580e132 100644 --- 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 @@ -1,7 +1,5 @@ -import { ClerkRuntimeError } from '@clerk/shared/error'; -import { useRef } from 'react'; +import { useEffect } from 'react'; -import { useNow } from '../../../hooks/use-now'; import { type ErrorDescription, fill, type MosaicMessages, useErrorText, useMessages } from '../../../localization'; import { setup } from '../../../machine/setup'; import type { DoneInvokeEvent } from '../../../machine/types'; @@ -13,15 +11,7 @@ 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' - | 'remove' - | 'default'; +type MfaAction = 'prepareAuthenticator' | 'verifyAuthenticator' | 'sms' | 'resend' | 'generate' | 'copy'; type MfaFlow = | { kind: 'closed' } | { kind: 'select' } @@ -48,7 +38,8 @@ interface MfaContext { flow: MfaFlow; savedSetup?: AuthenticatorSetup; pending?: MfaAction; - resendAvailableAt?: number; + dismissed: boolean; + resendSeconds: number; run: () => Promise; resolve: () => void; reject: (error: unknown) => void; @@ -64,16 +55,20 @@ type MfaEvent = | { 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_MS = 30_000; +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, @@ -83,8 +78,9 @@ const mfaMachine = createMachine({ on: { OPEN: { actions: assign(() => ({ flow: { kind: 'select' } })) }, CLOSE: { - actions: assign(() => ({ flow: { kind: 'closed' }, savedSetup: undefined, resendAvailableAt: undefined })), + actions: assign(() => ({ flow: { kind: 'closed' }, savedSetup: undefined, resendSeconds: 0 })), }, + TICK: tick, SELECT: { actions: assign((context, event) => ({ flow: @@ -159,6 +155,7 @@ const mfaMachine = createMachine({ target: 'busy', actions: assign((_, event) => ({ pending: event.key, + dismissed: false, run: event.run, resolve: event.resolve, reject: event.reject, @@ -168,6 +165,16 @@ const mfaMachine = createMachine({ }, 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: @@ -182,21 +189,27 @@ const mfaMachine = createMachine({ target: 'idle', actions: [ context => context.resolve(), - assign>((context, event) => ({ - flow: event.output, - savedSetup: - event.output.kind === 'authenticator' && event.output.setup - ? event.output.setup - : event.output.kind === 'closed' - ? undefined - : context.savedSetup, - pending: undefined, - resendAvailableAt: - context.pending === 'resend' || - (context.pending === 'sms' && event.output.kind === 'sms' && event.output.step === 'verify') - ? Date.now() + RESEND_COOLDOWN_MS - : context.resendAvailableAt, - })), + 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: { @@ -205,6 +218,9 @@ const mfaMachine = createMachine({ (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 }; } @@ -256,13 +272,7 @@ function createAuthenticatorActions(model: ReadyModel, flow: MfaFlow, run: RunMf return { prepareAuthenticator, verifyAuthenticator }; } -function createSmsActions( - model: ReadyModel, - flow: MfaFlow, - resendAvailableAt: number | undefined, - run: RunMfaAction, - send: MfaSend, -) { +function createSmsActions(model: ReadyModel, flow: MfaFlow, resendSeconds: number, run: RunMfaAction, send: MfaSend) { const submitSms = (code?: string) => { const enrollSms = model.enrollSms; const findOrCreatePhone = model.findOrCreatePhone; @@ -292,7 +302,7 @@ function createSmsActions( const resendSms = () => { const resend = model.resendSms; - if (flow.kind !== 'sms' || !flow.phoneId || !resend || (resendAvailableAt && Date.now() < resendAvailableAt)) { + if (flow.kind !== 'sms' || !flow.phoneId || !resend || resendSeconds > 0) { return; } const current = flow; @@ -398,7 +408,6 @@ function createSetupProps( context: MfaContext, send: MfaSend, actions: MfaSetupActions, - resendSeconds: number, errorText: ReturnType, ): UserProfileMfaSetupViewProps { const flow = context.flow; @@ -426,7 +435,7 @@ function createSetupProps( onResend: actions.resendSms, isPending: context.pending === 'sms', isResending: context.pending === 'resend', - resendSeconds, + resendSeconds: context.resendSeconds, errorMessage: sms?.error ? errorText(sms.error) : undefined, }, authenticator: { @@ -451,34 +460,31 @@ function createSetupProps( }; } -function useMfaActionRunner(value: string, send: MfaSend) { - const locked = useRef(false); +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 (locked.current || value === 'busy') { - return Promise.reject(new ClerkRuntimeError('Another verification action is in progress.', { code: 'mfa_busy' })); + if (isBusy) { + return Promise.resolve(); } - locked.current = true; return new Promise((resolve, reject) => { send({ type: 'RUN', key, run: action, resolve, reject }); - }).finally(() => { - locked.current = false; }); }; - - return { run, isLocked: () => locked.current }; -} - -export function useUserProfileMfaController(model: ReadyModel) { - const errorText = useErrorText(); - const backupCodeMessages = useMessages('userProfileBackupCodes'); - const [{ context, value }, send] = useMachine(mfaMachine); - const { run, isLocked } = useMfaActionRunner(value, send); - const now = useNow({ updateInterval: 1000 }); - const resendAvailableAt = context.resendAvailableAt; - const resendSeconds = resendAvailableAt ? Math.max(0, Math.ceil((resendAvailableAt - now.getTime()) / 1000)) : 0; + 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, resendAvailableAt, run, send); + const { submitSms, resendSms } = createSmsActions(model, flow, resendSeconds, run, send); const { generateBackupCodes, copy, download, print } = createBackupCodeActions( model, flow, @@ -491,7 +497,7 @@ export function useUserProfileMfaController(model: ReadyModel) { const back = () => send({ type: 'BACK', hasPhones: model.phones.length > 0 }); const select = (method: UserProfileMfaAddableMethod) => { - if (isLocked()) { + if (isBusy) { return; } send({ type: 'SELECT', method, phoneId: model.phones[0]?.id ?? '', hasPhones: model.phones.length > 0 }); @@ -503,20 +509,6 @@ export function useUserProfileMfaController(model: ReadyModel) { } }; - const remove = (id: string) => - run('remove', async () => { - await model.remove(id); - return flow; - }); - const setDefaultAction = model.setDefault; - const setDefault = setDefaultAction - ? (id: string) => - run('default', async () => { - await setDefaultAction(id); - return flow; - }) - : undefined; - const setupProps = createSetupProps( model, context, @@ -534,7 +526,6 @@ export function useUserProfileMfaController(model: ReadyModel) { download, print, }, - resendSeconds, errorText, ); @@ -542,11 +533,10 @@ export function useUserProfileMfaController(model: ReadyModel) { methods: [...model.methods], addableMethods: model.addableMethods, onRegenerateBackupCodes: model.generateBackupCodes ? generateBackupCodes : undefined, - onRemove: remove, - onSetDefault: setDefault, + onRemove: model.remove, + onSetDefault: model.setDefault, }; - const dialogOpen = flow.kind !== 'closed'; const showAddTrigger = model.addableMethods.length > 0; return { 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 index 0b8cba4569b..e7f725a7988 100644 --- 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 @@ -18,7 +18,7 @@ export function UserProfileMfaSection(props: UserProfileMfaSectionProps = {}) { export function mfaSectionNode(model: UserProfileMfaModel, fallback: ReactNode = null): ReactNode { if (model.status === 'loading') { - return fallback || null; + return fallback ?? null; } if (model.status === 'hidden') { return null; diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 37f1769aedd..03481fa7476 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -34,7 +34,6 @@ export const errorMessages: { readonly generic: string } & Readonly Date: Fri, 9 Oct 2026 09:53:40 -0600 Subject: [PATCH 26/26] test(mosaic): reconcile MFA fixtures with main --- .../feature/fake-fapi-mfa.feature.test.tsx | 10 +++++-- .../mosaic/src/__tests__/feature/fake-fapi.ts | 4 +-- .../src/__tests__/feature/fake-fapi/mfa.ts | 13 ++++----- .../src/__tests__/feature/fake-fapi/shared.ts | 2 ++ packages/mosaic/src/__tests__/feature/fapi.ts | 13 --------- .../__tests__/mfa-feature-setup.tsx | 7 ++++- ...er-profile-mfa-management.feature.test.tsx | 28 ++++++++++++++++--- .../user-profile-mfa.feature.test.tsx | 20 ++++++++----- 8 files changed, 60 insertions(+), 37 deletions(-) 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 index ced803fdfd5..acc27fff91f 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi-mfa.feature.test.tsx +++ b/packages/mosaic/src/__tests__/feature/fake-fapi-mfa.feature.test.tsx @@ -1,13 +1,19 @@ import { describe, expect, it } from 'vitest'; import { fapiUrl, serveFapi } from './fake-fapi'; -import { fapiClient, fapiPhoneNumber, fapiSession, fapiUser } from './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' })], + phone_numbers: [ + fapiPhoneNumber({ + id: 'phone_2', + phone_number: '+15555550202', + verification: fapiVerification('phone_code', { status: 'verified' }), + }), + ], }); const fapi = serveFapi({ client: fapiClient([ diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 3be015a864b..de409b5e0bd 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -21,7 +21,7 @@ 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, @@ -208,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 = [ diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts index 8ede7d216f6..41f511abb5d 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts @@ -2,7 +2,7 @@ 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 } from './shared'; +import { envelope, missing, requestUser, updateUser, VERIFICATION_CODE } from './shared'; export interface FakeMfaState { totpCreations: number; @@ -39,7 +39,7 @@ export function mfaHandlers( 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') + 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 }); @@ -213,11 +213,8 @@ export function mfaHandlers( const id = String(params.id); const code = new URLSearchParams(await request.text()).get('code') ?? ''; state.mfa.phoneAttempts.push({ id, code }); - if (code !== '123456') { - return HttpResponse.json( - { errors: [{ code: 'form_code_incorrect', message: 'Incorrect code' }] }, - { status: 422 }, - ); + if (code !== VERIFICATION_CODE) { + return error('form_code_incorrect', 'Incorrect code'); } const phone = updatePhone(currentUser, id, current => ({ ...current, @@ -241,7 +238,7 @@ export function mfaHandlers( if (!user || !existingPhone) { return missing(); } - if (reserved === true && existingPhone.verification.status !== 'verified') { + if (reserved === true && existingPhone.verification?.status !== 'verified') { return error( 'identification_update_second_factor_unverified', 'Cannot update second factor attributes for unverified identification', 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/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index 1629e585c39..f53462b5f2e 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -344,19 +344,6 @@ export function fapiUser(overrides: Partial & Pick): U }; } -export function fapiPhoneNumber( - overrides: Partial & Pick, -): PhoneNumberJSON { - return { - object: 'phone_number', - reserved_for_second_factor: false, - default_second_factor: false, - linked_to: [], - verification: fapiVerification('phone_code', { status: 'verified' }), - ...overrides, - }; -} - export function fapiEnterpriseAccount( overrides: Partial & Pick, connection: Partial = {}, 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 index 2f56d66a89b..75921cc1f65 100644 --- 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 @@ -5,11 +5,16 @@ import { 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' }); +export const phone = fapiPhoneNumber({ + id: 'phone_1', + phone_number: '+15555550101', + verification: fapiVerification('phone_code', { status: 'verified' }), +}); export function mfaEnvironment() { const environment = fapiEnvironment(); 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 index 35ce1f69a43..f9cc227f9b9 100644 --- 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 @@ -48,7 +48,12 @@ describe('User profile MFA management', () => { { 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({ id: 'phone_2', phone_number: '+15555550202', reserved_for_second_factor: true }); + const second = fapiPhoneNumber({ + ...phone, + id: 'phone_2', + phone_number: '+15555550202', + reserved_for_second_factor: true, + }); serveFapi({ environment: mfaEnvironment(), client: fapiClient([ @@ -371,7 +376,12 @@ describe('User profile MFA management', () => { 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({ id: 'phone_2', phone_number: '+15555550202', reserved_for_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' })); @@ -389,7 +399,12 @@ describe('User profile MFA management', () => { 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({ id: 'phone_2', phone_number: '+15555550202', reserved_for_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( @@ -432,7 +447,12 @@ describe('User profile MFA management', () => { '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({ id: 'phone_2', phone_number: '+15555550202', reserved_for_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(); 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 index b7ea20df685..b1d31c0c143 100644 --- 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 @@ -3,7 +3,7 @@ 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 { fapiUrl, holdRequests, serveFapi, VERIFICATION_CODE, worker } from '../../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiEnvironment, @@ -34,6 +34,7 @@ describe('User profile MFA', () => { 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, @@ -56,7 +57,12 @@ describe('User profile MFA', () => { 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({ id: 'phone_2', phone_number: '+15555550202', reserved_for_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 }), ); @@ -219,9 +225,9 @@ describe('User profile MFA', () => { 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' }), '123456'); + 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: '123456' }]); + expect(fapi.mfa.phoneAttempts).toEqual([{ id: 'phone_1', code: VERIFICATION_CODE }]); expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_1', reserved: true, default: undefined }); }); @@ -270,9 +276,9 @@ describe('User profile MFA', () => { 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' }), '123456'); + 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: '123456' }]); + 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); @@ -450,7 +456,7 @@ describe('User profile MFA', () => { 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' }), '123456'); + 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 }]); });