From b304745509131dddc39e3b885c983627e52171ab Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 13:31:30 -0600 Subject: [PATCH 01/14] 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 | 14 + .../mosaic/src/__tests__/feature/render.tsx | 9 +- 4 files changed, 305 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 bea691e3880..9f1818a260d 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -14,6 +14,7 @@ import { http, HttpResponse, type JsonBodyType } 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 { createVerificationState, type FakeVerificationSeed, @@ -49,6 +50,7 @@ export interface FakeFapiState { passwordUpdates: URLSearchParams[]; enterpriseConnections: EnterpriseConnectionJSON[]; enterpriseLinking: FakeEnterpriseLinking; + mfa: FakeMfaState; } export type FakeFapiSeed = Partial> & { @@ -152,6 +154,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { apiKeys: [], passwordUpdates: [], enterpriseConnections: [], + mfa: createMfaState(), ...rest, verification: createVerificationState(verification), enterpriseLinking: { @@ -166,6 +169,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { worker.use( ...verificationHandlers(state, fapiUrl), ...enterpriseHandlers(state, fapiUrl), + ...mfaHandlers(state, fapiUrl), http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)), http.get(fapiUrl('/v1/client'), () => envelope(state.client, null)), http.get(fapiUrl('/v1/me'), () => { 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('/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 ae84f69a4e1..56de453f3ea 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -16,6 +16,7 @@ import type { OrganizationMembershipJSON, OrganizationSettingsJSON, OrganizationSuggestionJSON, + PhoneNumberJSON, PublicKeyCredentialRequestOptionsJSON, PublicOrganizationDataJSON, SessionJSON, @@ -315,6 +316,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, ): EnterpriseAccountJSON { 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 88ae19cb285278a00a34df5d57dd7fff32acd15a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 13:31:37 -0600 Subject: [PATCH 02/14] 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 d49d3673546e3ceb4c5eb6d592ce8c004385d5ea Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 13:31:59 -0600 Subject: [PATCH 03/14] 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 aa8c23b2a66..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((id: string) => onSetDefault?.(id), { 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(id); - } - } - : undefined + onSetDefault && !controller.isSettingDefault ? id => void controller.setDefault(id) : undefined } onRegenerateBackupCodes={onRegenerateBackupCodes} /> ))} - {setDefault.errorMessage} - {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 323f6f2619ecf313c984f59fa952a83928198dd6 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 13:32:24 -0600 Subject: [PATCH 04/14] 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 | 150 ----- ...-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(+), 599 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 17cfa6f198a..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-sms.view.test.tsx +++ /dev/null @@ -1,150 +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 }); - expect(field).toBeDisabled(); - const submit = screen.getByRole('button', { name: action, exact: true }); - expect(submit).toHaveAttribute('aria-busy', 'true'); - await user.click(submit); - const form = field.closest('form'); - if (!form) { - throw new Error('Step form missing'); - } - form.requestSubmit(); - expect(props.onSubmit).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); - - rerender( - , - ); - expect(screen.getByRole(role, { name })).toHaveAttribute('aria-invalid', 'true'); - const describedControl = - step === 'verify' ? screen.getByRole('group', { name }) : screen.getByRole(role, { name }); - expect(describedControl).toHaveAccessibleDescription('Please try again.'); - await user.click(screen.getByRole('button', { name: action, exact: true })); - expect(props.onSubmit).toHaveBeenCalledOnce(); - }, - ); - - it('waits for resend to finish before allowing verification or Back', async () => { - const user = userEvent.setup(); - const { props, rerender } = renderView({ step: 'verify', code: '123456', isResending: true }); - const code = screen.getByRole('textbox', { name: 'Verification code' }); - expect(code).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Verify', exact: true })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Sending a new code…' })).toBeDisabled(); - - rerender( - , - ); - expect(code).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (12)' })).toBeDisabled(); - rerender( - , - ); - await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })); - expect(props.onResend).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-authenticator-setup.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-authenticator-setup.view.test.tsx 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('/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('/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('/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 af6c5c8e34c..e1b53768c5d 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 @@ -8,6 +8,7 @@ import type { UserProfileDevice, } from './user-profile-active-devices-section.view'; import { UserProfileActiveDevicesSectionView } from './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'; @@ -18,6 +19,7 @@ export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMeth export interface UserProfileSecurityPanelViewProps extends Omit { passwordSlot?: UserProfilePasswordSlot | null; + mfaSlot?: UserProfileMfaSlot | null; passkeys?: UserProfilePasskey[]; passkeysVisible?: boolean; mfaMethods?: UserProfileMfaMethod[]; @@ -38,6 +40,7 @@ export interface UserProfileSecurityPanelViewProps extends Omit}> @@ -76,7 +80,8 @@ export function UserProfileSecurityPanelView({ onRemove={onRemovePasskey} /> ) : null} - {mfaMethods !== undefined ? ( + {mfaSlot?.content} + {mfaSlot === undefined && mfaMethods !== undefined ? ( Date: Sat, 3 Oct 2026 13:32:32 -0600 Subject: [PATCH 05/14] 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 9b97aa31ea3..cc393be100e 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -13,6 +13,7 @@ const liveRoutes = new Map([ ['/user-profile/user-profile-api-keys-panel', '/live/api-keys'], ['/user-profile/user-profile-account-section', '/live/account-section'], ['/user-profile/user-profile-password-section', '/live/password'], + ['/user-profile/user-profile-mfa-section', '/live/mfa'], ['/user-profile/user-profile-connected-accounts-section', '/live/connected-accounts'], ['/user-profile/user-profile-enterprise-accounts-section', '/live/enterprise-accounts'], ['/user-profile/user-profile-danger-section', '/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 71bd8c4051e3879be9e1dde6684548962aca4cc4 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:36:35 -0600 Subject: [PATCH 06/14] 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 57f2d5450274192da934090d760ceacd890ada06 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:36:42 -0600 Subject: [PATCH 07/14] 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 74d30e0ea9071a1099473398149cc8dce7f0095b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 15:20:54 -0600 Subject: [PATCH 08/14] 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 ce5fbad5043ee7e3e4579a8a7bdf89ab38381973 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 15:48:44 -0600 Subject: [PATCH 09/14] 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 ef45e116ae9bd45a83e380ad0c755d5a1032a288 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 5 Oct 2026 23:02:26 -0600 Subject: [PATCH 10/14] 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 16b1ced1cf7..287f5d1b49c 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 e1b53768c5d..a48313d3fa5 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 @@ -8,9 +8,12 @@ import type { UserProfileDevice, } from './user-profile-active-devices-section.view'; import { UserProfileActiveDevicesSectionView } from './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'; import { UserProfilePasskeysSectionView } from './user-profile-passkeys-section.view'; import type { UserProfilePasswordSlot } from './user-profile-password-section/user-profile-password-section.types'; diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 2a40f6b2d6c..1a824ab1bf6 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -16,14 +16,14 @@ import { userProfileAddPhoneMessages } from '../features/user-profile/user-profi import { userProfileVerifyEmailLinkMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-link.messages'; import { userProfileVerifyEmailSsoMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages'; import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices.messages'; -import { userProfileAddAuthenticatorMessages } from '../features/user-profile/user-profile-add-authenticator.messages'; -import { userProfileAddSmsMessages } from '../features/user-profile/user-profile-add-sms.messages'; -import { userProfileAuthenticatorSetupMessages } from '../features/user-profile/user-profile-authenticator-setup.messages'; -import { userProfileBackupCodesMessages } from '../features/user-profile/user-profile-backup-codes.messages'; import { userProfileConnectedAccountsMessages } from '../features/user-profile/user-profile-connected-accounts-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.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 bd84cf5552dcf02066de2d479e4de2fee7bcb3ad Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 5 Oct 2026 23:02:28 -0600 Subject: [PATCH 11/14] 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 | 234 ------------------ .../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(+), 300 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 2036628b25b..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { act, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -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 { UserProfileMfaMethod, 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(['picker', 'custom', 'none'] as const)( - 'restores focus after removing methods with %s add control', - async add => { - const user = userEvent.setup(); - function Example() { - const [methods, setMethods] = useState([ - { id: 'totp', type: 'authenticator' }, - { id: 'sms', type: 'sms' }, - ]); - return ( - - {} : undefined} - addControl={add === 'custom' ? : undefined} - onRemove={id => setMethods(current => current.filter(method => method.id !== id))} - /> - - ); - } - render(); - await user.click(screen.getByRole('button', { name: 'Manage Authenticator app' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage SMS verification' })).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' })); - const fallback = - add === 'none' - ? screen.getByRole('group', { name: '2-step verification' }) - : screen.getByRole('button', { name: add === 'picker' ? 'Add verification method' : 'Add custom method' }); - await waitFor(() => expect(fallback).toHaveFocus()); - }, - ); - - it.each(['sms', 'authenticator'] as const)('continues immediately when the %s option is activated', async type => { - const user = userEvent.setup(); - const { props } = renderView({ - methods: [{ id: 'existing', type: 'sms', description: '+1 801-555-0100' }], - addableMethods: ['sms', 'authenticator'], - }); - const labels = { sms: 'SMS verification', authenticator: 'Authenticator app' }; - - await user.click(screen.getByRole('button', { name: 'Add verification method' })); - const dialog = screen.getByRole('dialog', { name: 'Add 2-step verification' }); - expect(dialog).toHaveAccessibleDescription('Choose a verification method'); - expect(within(dialog).queryByRole('button', { name: /Backup codes/ })).not.toBeInTheDocument(); - expect(within(dialog).queryByRole('button', { name: 'Continue' })).not.toBeInTheDocument(); - await user.click(within(dialog).getByRole('button', { name: new RegExp(labels[type]) })); - - expect(props.onAdd).toHaveBeenCalledExactlyOnceWith(type); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(screen.getByText('+1 801-555-0100')).toBeVisible(); - }); - - it('cancels without selecting a method and restores focus to Add', async () => { - const user = userEvent.setup(); - const { props } = renderView(); - const add = screen.getByRole('button', { name: 'Add verification method' }); - await user.click(add); - await user.tab(); - expect(screen.getByRole('button', { name: /SMS verification/ })).toHaveFocus(); - await user.keyboard('{Escape}'); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(add).toHaveFocus(); - expect(props.onAdd).not.toHaveBeenCalled(); - await user.click(add); - await user.click(screen.getByRole('button', { name: 'Close' })); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(add).toHaveFocus(); - }); - - it('confirms the selected SMS method and restores focus when removal is cancelled', async () => { - const user = userEvent.setup(); - const { props } = renderView({ - methods: [ - { id: 'totp', type: 'authenticator', isDefault: true, canRemove: false }, - { id: 'personal', type: 'sms', description: '+1 801-555-0100' }, - { id: 'work', type: 'sms', description: '+1 801-555-0200' }, - ], - }); - - expect(screen.getByText('Authenticator app')).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Manage Authenticator app' })).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0100' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); - expect(screen.getByRole('alertdialog', { name: 'Remove SMS verification' })).toHaveAccessibleDescription( - 'You will no longer receive sign-in verification codes at +1 801-555-0100. The phone number will remain on your account.', - ); - expect(props.onRemove).not.toHaveBeenCalled(); - await user.click(screen.getByRole('button', { name: 'Cancel' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0100' })).toHaveFocus(); - - await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); - const dialog = screen.getByRole('alertdialog', { name: 'Remove SMS verification' }); - expect(dialog).toHaveAccessibleDescription( - 'You will no longer receive sign-in verification codes at +1 801-555-0200. The phone number will remain on your account.', - ); - await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); - - expect(props.onRemove).toHaveBeenCalledExactlyOnceWith('work'); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('hides Set as default during an update while keeping the menu available', async () => { - const user = userEvent.setup(); - const pending = deferred(); - const onSetDefault = vi.fn(() => pending.promise); - renderView({ - methods: [ - { id: 'work', type: 'sms', description: '+1 801-555-0200', canSetDefault: true }, - { id: 'other', type: 'sms', description: '+1 801-555-0300', canSetDefault: true }, - ], - onSetDefault, - }); - - const selected = screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' }); - await user.click(selected); - await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); - await user.click(selected); - expect(screen.queryByRole('menuitem', { name: 'Set as default' })).not.toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Remove method' })).toBeVisible(); - await user.keyboard('{Escape}'); - await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0300' })); - expect(screen.queryByRole('menuitem', { name: 'Set as default' })).not.toBeInTheDocument(); - expect(onSetDefault).toHaveBeenCalledExactlyOnceWith('work'); - - await act(async () => { - pending.resolve(); - await pending.promise; - }); - - expect(await screen.findByRole('menuitem', { name: 'Set as default' })).toBeVisible(); - }); - - it.each([ - { - cause: 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 a48313d3fa5..c44b5862dbd 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 @@ -8,7 +8,6 @@ import type { UserProfileDevice, } from './user-profile-active-devices-section.view'; import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view'; -import type { UserProfileMfaSlot } from './user-profile-mfa-section/user-profile-mfa-section'; import type { UserProfileMfaAddableMethod, UserProfileMfaMethod, @@ -22,7 +21,7 @@ export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMeth export interface UserProfileSecurityPanelViewProps extends Omit { passwordSlot?: UserProfilePasswordSlot | null; - mfaSlot?: UserProfileMfaSlot | null; + mfaSlot?: ReactNode; passkeys?: UserProfilePasskey[]; passkeysVisible?: boolean; mfaMethods?: UserProfileMfaMethod[]; @@ -64,8 +63,21 @@ export function UserProfileSecurityPanelView({ }: UserProfileSecurityPanelViewProps): ReactElement { const showPassword = Boolean(passwordSlot); const showPasskeys = passkeys !== undefined && passkeysVisible; - const hasAuthentication = - showPassword || showPasskeys || (mfaSlot !== undefined ? Boolean(mfaSlot) : mfaMethods !== undefined); + const selectedMfaNode = + mfaSlot !== undefined ? ( + mfaSlot + ) : mfaMethods !== undefined ? ( + + ) : null; + const hasAuthentication = showPassword || showPasskeys || Boolean(selectedMfaNode); return ( }> @@ -83,18 +95,7 @@ export function UserProfileSecurityPanelView({ onRemove={onRemovePasskey} /> ) : null} - {mfaSlot?.content} - {mfaSlot === undefined && mfaMethods !== undefined ? ( - - ) : null} + {selectedMfaNode} ) : null} {devices ? ( From f9046125a0f891719182bf587540df5d0ad8d604 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 6 Oct 2026 10:41:06 -0600 Subject: [PATCH 12/14] 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 4aed7f6d6f3a6f65d86a23702b19e10d12146da3 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 6 Oct 2026 10:58:51 -0600 Subject: [PATCH 13/14] 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: '/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 859c803c383fb46f5a72e97f7ac5224021ab0668 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 6 Oct 2026 11:16:44 -0600 Subject: [PATCH 14/14] 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.