From 5e5fa88e38806e42af1f0dab70ed6b07c40df819 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 26 Oct 2026 13:17:29 -0600 Subject: [PATCH 1/5] fix(mosaic): keep MFA setup on the method list until it is ready --- .changeset/mfa-loading-fixes.md | 2 + ...-profile-mfa-interactions.feature.test.tsx | 47 ++++++-- ...er-profile-mfa-management.feature.test.tsx | 101 +++++++++++----- .../user-profile-mfa.feature.test.tsx | 9 ++ ...user-profile-add-authenticator.messages.ts | 2 - .../user-profile-add-authenticator.view.tsx | 111 ++++++------------ .../user-profile-add-mfa.dialog.tsx | 5 +- .../user-profile-add-mfa.view.tsx | 17 ++- .../user-profile-mfa-section.controller.ts | 102 ++++++++-------- .../user-profile-mfa-section.tsx | 34 +++++- .../user-profile-mfa-section.view.tsx | 4 +- .../user-profile-mfa-setup.view.tsx | 44 ++++--- .../src/stories/fixtures/user-profile-mfa.ts | 3 +- 13 files changed, 292 insertions(+), 189 deletions(-) create mode 100644 .changeset/mfa-loading-fixes.md diff --git a/.changeset/mfa-loading-fixes.md b/.changeset/mfa-loading-fixes.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mfa-loading-fixes.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-interactions.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-interactions.feature.test.tsx index 8afba0590d5..04a507a05b8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-interactions.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-interactions.feature.test.tsx @@ -45,8 +45,8 @@ describe('User profile MFA interaction boundaries', () => { 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()); + const retry = screen.getByRole('button', { name: /Authenticator app/ }); + expect(retry).toBeEnabled(); await user.click(retry); expect(await screen.findByRole('img', { name: /QR code/i })).toBeVisible(); const code = screen.getByRole('textbox', { name: 'Verification code' }); @@ -74,7 +74,7 @@ describe('User profile MFA interaction boundaries', () => { expect(fapi.mfa.totpCreations).toBe(1); }); - it('disables Back while authenticator preparation is pending', async () => { + it('disables method choices and prevents duplicate authenticator preparation', async () => { await renderMfa(); const held = holdRequests('post', '/v1/me/totp'); const user = userEvent.setup(); @@ -83,9 +83,12 @@ describe('User profile MFA interaction boundaries', () => { 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(); + expect(screen.getByRole('button', { name: /Authenticator app/ })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: /Authenticator app/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: /SMS verification/ })).toBeDisabled(); }); + await user.click(screen.getByRole('button', { name: /Authenticator app/ })); + expect(held.requests).toHaveLength(1); } finally { held.release(); } @@ -202,9 +205,39 @@ describe('User profile MFA interaction boundaries', () => { 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()); + const retry = screen.getByRole('button', { name: /Backup codes/ }); + expect(retry).toBeEnabled(); + const held = holdRequests('post', '/v1/me/backup_codes/'); await user.click(retry); + await waitFor(() => expect(held.requests).toHaveLength(1)); + try { + expect(retry).toBeDisabled(); + expect(retry).toHaveAttribute('aria-busy', 'true'); + expect(screen.queryByRole('list', { name: 'Backup codes' })).toBeNull(); + await user.click(retry); + expect(held.requests).toHaveLength(1); + } finally { + held.release(); + } + expect(await screen.findByText('CODE0100')).toBeVisible(); + }); + + it('keeps the selected backup choice while the account refresh completes', async () => { + await renderMfa(fapiUser({ id: 'user_1', totp_enabled: true, two_factor_enabled: true })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + const held = holdRequests('get', '/v1/me'); + await user.click(screen.getByRole('button', { name: /Backup codes/ })); + await waitFor(() => expect(held.requests).toHaveLength(1)); + try { + expect(within(screen.getByRole('dialog')).getByRole('button', { name: /Backup codes/ })).toHaveAttribute( + 'aria-busy', + 'true', + ); + expect(screen.queryByRole('list', { name: 'Backup codes' })).toBeNull(); + } finally { + held.release(); + } expect(await screen.findByText('CODE0100')).toBeVisible(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx index 059eecc91c5..e0970fdfc37 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from '@testing-library/react'; +import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { describe, expect, it, vi } from 'vitest'; @@ -355,7 +355,7 @@ describe('User profile MFA management', () => { expect(clerk.user?.id).toBe('user_2'); }); - it('opens the backup-code surface while regeneration is pending', async () => { + it('keeps the profile visible until regenerated backup codes are ready', async () => { await renderMfa( fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), ); @@ -364,15 +364,13 @@ describe('User profile MFA management', () => { await openMenu(user, screen.getByRole('button', { name: 'Manage Backup codes' })); await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); await waitFor(() => expect(held.requests).toHaveLength(1)); - await waitFor(() => expect(screen.getByRole('status', { name: 'Generating backup codes' })).toBeVisible()); - for (const name of ['Print', 'Download']) { - expect(screen.getByRole('button', { name })).toBeVisible(); - expect(screen.getByRole('button', { name })).toBeDisabled(); + try { + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByRole('button', { name: 'Add verification method' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Manage Backup codes' })).toBeVisible(); + } finally { + held.release(); } - expect(screen.getByRole('button', { name: 'Copy and close' })).toBeVisible(); - expect(screen.getByRole('button', { name: 'Copy and close' })).toHaveAttribute('aria-disabled', 'true'); - expect(screen.queryByRole('button', { name: 'Close' })).toBeNull(); - held.release(); await waitFor(() => expect(screen.getByText('CODE0100')).toBeVisible()); expect(screen.getByRole('button', { name: 'Close' })).toBeVisible(); for (const name of ['Print', 'Download', 'Copy and close']) { @@ -380,6 +378,32 @@ describe('User profile MFA management', () => { } }); + it('returns failed regeneration to the section and retries from the menu', async () => { + const fapi = await renderMfa( + fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), + ); + const handlers = worker.listHandlers(); + const held = holdRequests('post', '/v1/me/backup_codes/'); + const user = userEvent.setup(); + const trigger = screen.getByRole('button', { name: 'Manage Backup codes' }); + await openMenu(user, trigger); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + await waitFor(() => expect(held.requests).toHaveLength(1)); + held.fail('action_blocked', 'Unable to create backup codes.'); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent( + "This action couldn't be completed. Please try again later or contact support if this persists.", + ), + ); + expect(screen.queryByRole('dialog')).toBeNull(); + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()); + worker.resetHandlers(...handlers); + await openMenu(user, trigger); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + expect(await screen.findByText('CODE0100')).toBeVisible(); + expect(fapi.mfa.backupCodeCreations).toBe(1); + }); + 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 }), @@ -414,7 +438,21 @@ describe('User profile MFA management', () => { await regenerate(user); expect(await screen.findByText('Enter your current password to continue')).toBeVisible(); - await user.type(screen.getByLabelText('Password'), 'hunter2{Enter}'); + await waitFor(() => expect(screen.getByLabelText('Password')).toHaveFocus()); + const refresh = holdRequests('get', '/v1/me'); + try { + await user.keyboard('hunter2{Enter}'); + await act(async () => { + await waitFor(() => expect(refresh.requests).toHaveLength(1)); + }); + await user.keyboard('{Escape}'); + expect(screen.getByRole('dialog')).toHaveAttribute('data-open'); + expect(screen.getByText('Enter your current password to continue')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Close' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Continue' })).toHaveAttribute('aria-busy', 'true'); + } finally { + refresh.release(); + } const list = await screen.findByRole('list', { name: 'Backup codes' }); expect(within(list).getAllByRole('listitem')).toHaveLength(10); @@ -445,6 +483,7 @@ describe('User profile MFA management', () => { await regenerate(user); await screen.findByText('Enter your current password to continue'); const dialog = screen.getByRole('dialog'); + const handlers = worker.listHandlers(); const replay = holdRequests('post', '/v1/me/backup_codes/'); await user.type(screen.getByLabelText('Password'), 'hunter2{Enter}'); await waitFor(() => expect(replay.requests).toHaveLength(1)); @@ -454,47 +493,53 @@ describe('User profile MFA management', () => { expect(dialog).toBeVisible(); replay.fail('action_blocked', 'Unable to create backup codes.'); - await waitFor(() => expect(within(screen.getByRole('dialog')).getByRole('alert')).toBeVisible()); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(screen.getByRole('alert')).toHaveTextContent( + "This action couldn't be completed. Please try again later or contact support if this persists.", + ); expect(screen.queryByRole('list', { name: 'Backup codes' })).toBeNull(); expect(fapi.mfa.backupCodeCreations).toBe(0); + worker.resetHandlers(...handlers); + await regenerate(user); + await screen.findByRole('list', { name: 'Backup codes' }); + expect(fapi.mfa.backupCodeCreations).toBe(1); }); - it('cancels a loading verification when the backup-code dialog closes so regeneration can start again', async () => { + it('keeps the profile visible until verification preparation is ready', async () => { const fapi = await renderBackupCodesNeedingReverification(); const start = holdRequests('post', '/v1/client/sessions/:id/verify'); const user = userEvent.setup(); await regenerate(user); await waitFor(() => expect(start.requests).toHaveLength(1)); - await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' })); - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - start.release(); - - await regenerate(user); + try { + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByRole('button', { name: 'Manage Backup codes' })).toBeVisible(); + } finally { + start.release(); + } + await screen.findByText('Enter your current password to continue'); + await waitFor(() => expect(screen.getByRole('dialog')).toContainElement(document.activeElement)); + await user.type(screen.getByLabelText('Password'), 'hunter2{Enter}'); await screen.findByRole('list', { name: 'Backup codes' }); expect(fapi.mfa.backupCodeCreations).toBe(1); }); - it('keeps the backup-code dialog open until the first response so a verification can appear', async () => { - const fapi = await renderBackupCodesNeedingReverification(); + it('keeps the profile visible until the first response can request verification', async () => { + await renderBackupCodesNeedingReverification(); const first = holdRequests('post', '/v1/me/backup_codes/'); const user = userEvent.setup(); await regenerate(user); await waitFor(() => expect(first.requests).toHaveLength(1)); - const dialog = screen.getByRole('dialog'); try { - expect(within(dialog).queryByRole('button', { name: 'Close' })).toBeNull(); - await user.keyboard('{Escape}'); - expect(dialog).toBeVisible(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByRole('button', { name: 'Manage Backup codes' })).toBeVisible(); } finally { first.release(); } - expect(await within(dialog).findByText('Enter your current password to continue')).toBeVisible(); - await user.type(screen.getByLabelText('Password'), 'hunter2{Enter}'); - await screen.findByRole('list', { name: 'Backup codes' }); - expect(fapi.mfa.backupCodeCreations).toBe(1); + expect(await screen.findByText('Enter your current password to continue')).toBeVisible(); }); const startSms = async (user: ReturnType) => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa.feature.test.tsx index 7c0bfc11bbc..ae581bee984 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa.feature.test.tsx @@ -221,7 +221,16 @@ describe('User profile MFA', () => { const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Add verification method' })); await user.click(screen.getByRole('button', { name: /SMS verification/ })); + const held = holdRequests('post', '/v1/me/phone_numbers/phone_1/prepare_verification'); await user.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(held.requests).toHaveLength(1)); + try { + expect(screen.getByRole('combobox', { name: /Phone number/ })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Continue' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.queryByRole('textbox', { name: 'Verification code' })).toBeNull(); + } finally { + held.release(); + } expect(await screen.findByRole('textbox', { name: 'Verification code' })).toBeVisible(); expect(fapi.mfa.phonePreparations).toEqual(['phone_1']); expect(fapi.mfa.phoneUpdates).toEqual([]); diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.messages.ts index 227d0dd9705..fe6a6e9067f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.messages.ts @@ -3,6 +3,4 @@ export const userProfileAddAuthenticatorMessages = { back: 'Back', verify: 'Verify', pending: 'Verifying code', - preparing: 'Preparing authenticator…', - retry: 'Try again', } as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.view.tsx index e254f6673d1..0f03eaf342d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.view.tsx @@ -5,16 +5,13 @@ 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 { Form } from '../../../primitives/form'; import { styles } from './user-profile-add-authenticator.styles'; import { UserProfileAuthenticatorSetupView } from './user-profile-authenticator-setup.view'; export interface UserProfileAddAuthenticatorViewProps { - setup?: { secret: string; uri: string }; - setupErrorMessage?: string; - onRetry: () => void; + setup: { secret: string; uri: string }; onBack: () => void; code: string; onCodeChange: (value: string) => void; @@ -26,8 +23,6 @@ export interface UserProfileAddAuthenticatorViewProps { export function UserProfileAddAuthenticatorView({ onBack, setup, - setupErrorMessage, - onRetry, code, onCodeChange, onSubmit, @@ -35,76 +30,49 @@ export function UserProfileAddAuthenticatorView({ errorMessage, }: UserProfileAddAuthenticatorViewProps) { const m = useMessages('userProfileAddAuthenticator'); - const setupMessages = useMessages('userProfileAuthenticatorSetup'); const formId = useId(); const actionRef = useFlowAutoFocus(); const inputRef = useFlowAutoFocus(); const submitCode = (value: string) => { - if (setup && !isPending && value.length === 6) { + if (!isPending && value.length === 6) { onSubmit(value); } }; return ( <> - {setup ? ( - - ) : ( - <> - - {setupMessages.title} - - - {setupErrorMessage} - - {setupErrorMessage ? null : ( - - - {m.preparing} - - - )} - - )} - {setup ? ( - { - event.preventDefault(); - submitCode(code); - }} - /> - } + + { + event.preventDefault(); + submitCode(code); + }} + /> + } + > + - - {m.codeLabel} - - - {errorMessage} - - - - ) : null} + {m.codeLabel} + + + {errorMessage} + + +