From cab9ec4ea2c760b42ee1546b9f255da3a65f6d2b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 22:09:19 -0600 Subject: [PATCH 1/2] fix(mosaic): hold contact verify and MFA actions under one section lock Sending a verification code for an email or phone now holds the section, so set-primary, Remove and Add wait until the code is sent. Changing the default SMS method, regenerating backup codes and removing an MFA method now exclude each other, and the method menus and Add are disabled while one runs. Adds waitFor to the machine runtime so a pending action can wait for a machine-driven step. --- packages/mosaic/AGENTS.md | 1 + packages/mosaic/docs/controllers.md | 1 + .../user-profile-add-email.controller.test.ts | 18 ++--- ...ser-profile-email-section.feature.test.tsx | 6 ++ .../user-profile-add-email.controller.ts | 6 +- .../user-profile-email-section.controller.ts | 2 +- ...er-profile-mfa-management.feature.test.tsx | 67 +++++++++++++++++-- .../user-profile-mfa-row.view.tsx | 4 ++ ...ser-profile-mfa-section-leaf.controller.ts | 20 +++--- .../user-profile-mfa-section.controller.ts | 15 +++-- .../user-profile-mfa-section.view.tsx | 6 ++ .../user-profile-add-phone.controller.test.ts | 4 +- ...ser-profile-phone-section.feature.test.tsx | 3 + .../user-profile-add-phone.controller.ts | 6 +- .../user-profile-phone-section.controller.ts | 2 +- packages/mosaic/src/machine/README.md | 3 +- .../src/machine/__tests__/wait-for.test.ts | 45 +++++++++++++ packages/mosaic/src/machine/wait-for.ts | 24 +++++++ 18 files changed, 194 insertions(+), 39 deletions(-) create mode 100644 packages/mosaic/src/machine/__tests__/wait-for.test.ts create mode 100644 packages/mosaic/src/machine/wait-for.ts diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 04fbe3de9e7..f2efa863c05 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -94,6 +94,7 @@ Most problems a feature hits already have a shared answer. Find the line for you - One screen replacing another on the same card or dialog → `Flow` (`src/components/flow/`), see [Views](docs/views.md) - An interaction with an async lifecycle or two values that move together → A machine in the controller file, via `setup()` (`src/machine/`) - Styles several components share → `src/styles/*.styles.ts` +- A machine-driven step that a lock or caller must await → `waitFor` (`src/machine/wait-for.ts`), inside `usePendingAction.run` - Anything else small: a permission, verification codes, backup codes, avatar files, a list patch → List `src/utils/`, `src/hooks/` and `src/primitives/hooks/` first; most of these already have a helper ## Signs you are about to invent a pattern diff --git a/packages/mosaic/docs/controllers.md b/packages/mosaic/docs/controllers.md index 20178a70609..3e20ed5293f 100644 --- a/packages/mosaic/docs/controllers.md +++ b/packages/mosaic/docs/controllers.md @@ -66,6 +66,7 @@ Pass `isDisabled` to every control that starts one of the actions, including the - `run` resolves `true` on success, so the caller can close a dialog or move focus. A cancelled reverification resolves `false`, shows no error and releases the lock. - To hold pending through an OAuth redirect, await a delay inside the action (`user-profile-connected-accounts-section.controller.ts`). +- To hold it through a step a machine drives, run an action that awaits `waitFor` (`machine/wait-for.ts`) until the machine leaves that state (`user-profile-add-email.controller.ts`). - It is not for a form (`useForm`), a confirm step (`useConfirmController`), an action whose result drives other state (a machine), or a check that reruns as the user types (`useDebouncedAsync`, or a field's `validateAsync`). For a direct action in `ActionMenu`, opt in with `status: 'idle'` and set it to `'pending'` in the click event while the request runs and no dialog is open yet; the menu closes on the next render unless an action is pending. While pending, the menu reserves room for a spinner, disables its actions and blocks dismissal. It closes when the status returns to `'idle'`. Leave `status` undefined for ordinary actions that close through the menu's own click. diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts index d212a1da3f8..dbb19ec871e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts @@ -222,7 +222,7 @@ describe('useUserProfileAddEmailController', () => { useUserProfileAddEmailController({ reverification: idleReverification, onCreate }), ); - act(() => result.current.onVerifyEmail(other, email)); + act(() => void result.current.onVerifyEmail(other, email)); expect(email.start).toHaveBeenCalledOnce(); expect(result.current.open).toBe(false); expect(result.current.pendingVerifyId).toBe('email_2'); @@ -249,11 +249,11 @@ describe('useUserProfileAddEmailController', () => { ); expect(result.current.verifyingId).toBeUndefined(); - act(() => result.current.onVerifyEmail(other, email)); + act(() => void result.current.onVerifyEmail(other, email)); expect(result.current.verifyingId).toBe('email_2'); await waitFor(() => expect(result.current.step).toBe('verify')); - act(() => result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email)); + act(() => void result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email)); expect(result.current.verifyingId).toBe('email_2'); expect(result.current.emailAddress).toBe('other@example.com'); @@ -275,7 +275,7 @@ describe('useUserProfileAddEmailController', () => { useUserProfileAddEmailController({ reverification: idleReverification, onCreate: created }), ); - act(() => result.current.onVerifyEmail(other, email)); + act(() => void result.current.onVerifyEmail(other, email)); await waitFor(() => expect(result.current.errorMessage).toBe('Too many requests')); expect(result.current.step).toBe('verify'); expect(result.current.resendSeconds).toBe(0); @@ -301,7 +301,7 @@ describe('useUserProfileAddEmailController', () => { const verified = createDeferredPromise(); const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification })); - act(() => result.current.onVerifyEmail(other, verifier(sentLink(verified.promise.then(() => undefined))))); + act(() => void result.current.onVerifyEmail(other, verifier(sentLink(verified.promise.then(() => undefined))))); expect(result.current.step).toBe('link'); expect(result.current.resendSeconds).toBe(60); @@ -319,12 +319,12 @@ describe('useUserProfileAddEmailController', () => { useUserProfileAddEmailController({ reverification: idleReverification }), ); - act(() => result.current.onVerifyEmail(other, email)); + act(() => void result.current.onVerifyEmail(other, email)); await waitFor(() => expect(result.current.step).toBe('link')); act(() => result.current.onOpenChange(false)); expect(cancel).toHaveBeenCalledOnce(); - act(() => result.current.onVerifyEmail(other, email)); + act(() => void result.current.onVerifyEmail(other, email)); await waitFor(() => expect(result.current.step).toBe('link')); unmount(); expect(cancel).toHaveBeenCalledTimes(2); @@ -334,7 +334,7 @@ describe('useUserProfileAddEmailController', () => { const email = verifier(sentLink(Promise.reject(saveError('Link expired')))); const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification })); - act(() => result.current.onVerifyEmail(other, email)); + act(() => void result.current.onVerifyEmail(other, email)); await waitFor(() => expect(result.current.errorMessage).toBe('Link expired')); expect(result.current.step).toBe('link'); expect(result.current.open).toBe(true); @@ -345,7 +345,7 @@ describe('useUserProfileAddEmailController', () => { const email = verifier(() => ({ method: 'sso', connect })); const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification })); - act(() => result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email)); + act(() => void result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email)); expect(result.current.step).toBe('sso'); expect(result.current.resendSeconds).toBe(0); diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx index f9789877eae..c535f4bbf00 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx @@ -189,12 +189,18 @@ describe('the user profile email section', () => { expect(screen.getByRole('menuitem', { name: 'Verify' })).toHaveAttribute('aria-busy', 'true'); expect(await screen.findByRole('progressbar', { name: 'Verify' })).toBeVisible(); expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByRole('button', { name: 'Manage alice@example.com' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Add email' })).toBeDisabled(); } finally { prepare.release(); } await enterCode(actor, VERIFICATION_CODE); expect(screen.queryByRole('menu')).toBeNull(); + expect(screen.getByRole('button', { name: 'Manage alice@example.com' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts index 89ccd25abee..7710d928143 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts @@ -8,6 +8,7 @@ import { useErrorText } from '../../../localization'; import { setup } from '../../../machine/setup'; import type { ErrorInvokeEvent } from '../../../machine/types'; import { useMachine } from '../../../machine/use-machine'; +import { waitFor } from '../../../machine/wait-for'; import type { FormError } from '../../../utils/errors'; import { toFormError } from '../../../utils/errors'; import { CODE_LENGTH, CODE_RESEND_SECONDS, isResendUnlocked, resendUnlockTime } from '../../../utils/verification'; @@ -32,7 +33,7 @@ export interface UserProfileAddEmailController extends UserProfileAddEmailDialog handle: DialogHandle; verifyingId: string | undefined; pendingVerifyId: string | undefined; - onVerifyEmail: (email: Pick, verifier: UserProfileEmailVerifier) => void; + onVerifyEmail: (email: Pick, verifier: UserProfileEmailVerifier) => Promise; } interface Context { @@ -268,9 +269,10 @@ export function useUserProfileAddEmailController({ onConnect: () => send({ type: 'CONNECT' }), verifyingId: snapshot.context.verifyingId, pendingVerifyId: state === 'preparing' ? snapshot.context.verifyingId : undefined, - onVerifyEmail: (email, verifier) => { + onVerifyEmail: async (email, verifier) => { codeForm.reset(); send({ type: 'START', id: email.id, emailAddress: email.value, verifier }); + await waitFor(actor, current => current.value !== 'preparing'); }, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts index f9c73803cb6..14f66484e24 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts @@ -58,7 +58,7 @@ export function useUserProfileEmailSectionController({ onVerify: id => { const email = emails.find(email => email.id === id); if (email) { - verification.onVerifyEmail(email, getEmailVerifier(id)); + void actions.run(id, () => verification.onVerifyEmail(email, getEmailVerifier(id))); } }, onSetPrimary: id => void actions.run(id, () => onSetPrimaryEmail(id)), 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 f146797e6cf..61781b09b0f 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 @@ -367,6 +367,41 @@ describe('User profile MFA management', () => { } }); + it('blocks backup-code regeneration while the default SMS method is changing', async () => { + const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const second = fapiPhoneNumber({ + ...phone, + id: 'phone_2', + phone_number: '+15555550202', + reserved_for_second_factor: true, + }); + const fapi = await renderMfa( + fapiUser({ id: 'user_1', phone_numbers: [first, second], backup_code_enabled: true, two_factor_enabled: true }), + ); + const held = holdRequests('post', '/v1/me/phone_numbers/phone_2'); + const user = userEvent.setup(); + try { + await openMenu(user, screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0202' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as default' })); + await waitFor(() => expect(held.requests).toHaveLength(1)); + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()); + const manage = screen.getByRole('button', { name: 'Manage Backup codes' }); + expect(manage).toHaveAttribute('aria-disabled', 'true'); + await user.click(manage); + expect(screen.queryByRole('menu')).toBeNull(); + expect(fapi.mfa.backupCodeCreations).toBe(0); + expect(screen.getByRole('button', { name: 'Add verification method' })).toBeDisabled(); + } finally { + held.release(); + } + const manage = screen.getByRole('button', { name: 'Manage Backup codes' }); + await waitFor(() => expect(manage).not.toHaveAttribute('aria-disabled', 'true')); + await openMenu(user, manage); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + await waitFor(() => expect(screen.getByText('CODE0100')).toBeVisible()); + expect(fapi.mfa.backupCodeCreations).toBe(1); + }); + 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 }), @@ -421,6 +456,7 @@ describe('User profile MFA management', () => { expect(screen.getByRole('menuitem', { name: 'Regenerate' })).toHaveAttribute('aria-busy', 'true'); expect(await screen.findByRole('progressbar', { name: 'Regenerate' })).toBeVisible(); expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByRole('button', { name: 'Manage Authenticator app' })).toHaveAttribute('aria-disabled', 'true'); } finally { generate.release(); } @@ -428,6 +464,10 @@ describe('User profile MFA management', () => { const list = await screen.findByRole('list', { name: 'Backup codes' }); expect(within(list).getAllByRole('listitem')).toHaveLength(10); await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()); + expect(screen.getByRole('button', { name: 'Manage Authenticator app', hidden: true })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ); }); it('holds the Regenerate menu item until session verification opens', async () => { @@ -865,6 +905,12 @@ describe('User profile MFA management', () => { expect(screen.getAllByText('SMS verification')[0]?.closest('li')).toHaveTextContent('+1 (555) 555-0202'), ); expect(screen.getAllByText('SMS verification')[0]?.closest('li')).toHaveTextContent('Default'); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ), + ); await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })); await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as default' })).toBeVisible()); }); @@ -908,6 +954,12 @@ describe('User profile MFA management', () => { expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_2', default: true, reserved: undefined }), ); expect(attempts).toBe(2); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ), + ); await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })); await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as default' })).toBeVisible()); }); @@ -916,7 +968,7 @@ describe('User profile MFA management', () => { { phase: 'mutation', method: 'post', path: '/v1/me/phone_numbers/phone_2', pendingPhone: '+1 (555) 555-0202' }, { phase: 'refresh', method: 'get', path: '/v1/me', pendingPhone: '+1 (555) 555-0101' }, ] as const)( - 'keeps method menus available but hides default changes during $phase', + 'disables method menus while changing the default during $phase', async ({ method, path, pendingPhone }) => { const first = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); const second = fapiPhoneNumber({ @@ -934,16 +986,21 @@ describe('User profile MFA management', () => { await waitFor(() => expect(held.requests).toHaveLength(1)); try { const pendingAction = screen.getByRole('button', { name: `Manage SMS verification ${pendingPhone}` }); - await openMenu(user, pendingAction); - await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Remove method' })).toBeVisible()); - expect(screen.queryByRole('menuitem', { name: 'Set as default' })).toBeNull(); - await user.click(await screen.findByRole('menuitem', { name: 'Remove method' })); + expect(pendingAction).toHaveAttribute('aria-disabled', 'true'); + await user.click(pendingAction); + expect(screen.queryByRole('menu')).toBeNull(); expect(screen.queryByRole('alertdialog')).toBeNull(); } finally { held.release(); } await waitFor(() => expect(screen.getByText('+1 (555) 555-0202').closest('li')).toHaveTextContent('Default')); await user.keyboard('{Escape}'); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ), + ); await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })); await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as default' })).toBeVisible()); }, diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx index cffe0b4ebdf..10a6d93c07b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx @@ -11,12 +11,14 @@ import type { UserProfileMfaMethod, UserProfileMfaSectionViewProps } from './use export function UserProfileMfaRowView({ method, + isDisabled, triggerRef, onRemove, onSetDefault, regenerate, }: Pick & { method: UserProfileMfaMethod; + isDisabled?: boolean; triggerRef?: Ref; onRemove?: () => void; onSetDefault?: (id: string) => void; @@ -56,6 +58,8 @@ export function UserProfileMfaRowView({ {actions.length > 0 ? ( sectionRef.current?.querySelector('button:not([disabled])') ?? sectionRef.current, }); + const localPendingAction = usePendingAction(); + const actions = pendingAction ?? localPendingAction; const remove = useConfirmController({ onConfirm: method => removalFocus.remove(method.id), reverification: removeReverification, }); const [pickerOpen, setPickerOpen] = useState(false); - const defaultAction = usePendingAction({ errorFallback: m.setDefaultError }); const setDefault = (id: string) => { const method = methods.find(method => method.id === id); if (!onSetDefault || method?.type !== 'sms' || !method.canSetDefault || method.isDefault) { return; } - void defaultAction.run(id, () => onSetDefault(id)); + void actions.run(id, () => onSetDefault(id), { errorFallback: m.setDefaultError }); }; return { sectionRef, + isDisabled: actions.isPending, pickerOpen, onPickerOpenChange: setPickerOpen, addMethod: onAdd @@ -49,16 +52,13 @@ export function useUserProfileMfaSectionLeafController({ : undefined, registerTrigger: removalFocus.registerTrigger, openRemoval: (method: UserProfileMfaMethod) => { - if (defaultAction.isPending) { - return; - } - defaultAction.reset(); + actions.reset(); remove.open(method); }, - remove: onRemove ? remove : undefined, + remove: onRemove ? actions.guard(remove) : undefined, finalRemovalFocus: removalFocus.finalFocus, - setDefaultFinalFocus: () => (defaultAction.lastKey ? removalFocus.trigger(defaultAction.lastKey) : null), - defaultError: defaultAction.error, - setDefault: onSetDefault && !defaultAction.isPending ? setDefault : undefined, + setDefaultFinalFocus: () => (actions.lastKey ? removalFocus.trigger(actions.lastKey) : null), + defaultError: actions.error, + setDefault: onSetDefault ? setDefault : undefined, }; } 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 9c6de127f1b..f8cdbc799fc 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts @@ -1,5 +1,6 @@ import { isReverificationCancelledError } from '@clerk/shared/error'; +import { usePendingAction } from '../../../hooks/use-pending-action'; import { useResendSeconds } from '../../../hooks/use-resend-seconds'; import { type ErrorDescription, fill, type MosaicMessages, useErrorText, useMessages } from '../../../localization'; import { setup } from '../../../machine/setup'; @@ -351,9 +352,9 @@ function createBackupCodeActions( const generateBackupCodes = () => { const generate = model.generateBackupCodes; if (!generate) { - return; + return Promise.resolve(); } - void run('generate', async () => { + return run('generate', async () => { const codes = await generate(); return { kind: 'backup', codes }; }).catch(() => undefined); @@ -482,6 +483,7 @@ function createSetupProps( } export function useUserProfileMfaController(model: ReadyModel) { + const pendingAction = usePendingAction(); const errorText = useErrorText(); const backupCodeMessages = useMessages('userProfile').mfa.backupCodes; const [{ context, value }, send] = useMachine(mfaMachine); @@ -528,7 +530,7 @@ export function useUserProfileMfaController(model: ReadyModel) { prepareAuthenticator(); } } else if (method === 'backup-codes') { - generateBackupCodes(); + void generateBackupCodes(); } else { send({ type: 'SELECT_SMS', phoneId: model.phones[0]?.id ?? '', hasPhones: model.phones.length > 0 }); } @@ -545,7 +547,7 @@ export function useUserProfileMfaController(model: ReadyModel) { submitSms, resendSms, verifyAuthenticator, - generateBackupCodes, + generateBackupCodes: () => void generateBackupCodes(), copy, download, print, @@ -557,11 +559,12 @@ export function useUserProfileMfaController(model: ReadyModel) { ); const sectionProps: UserProfileMfaSectionViewProps = { + pendingAction, methods: [...model.methods], addableMethods: model.addableMethods, regenerate: model.generateBackupCodes ? { - onClick: generateBackupCodes, + onClick: () => void pendingAction.run('regenerate', generateBackupCodes), status: context.pending === 'generate' && !dialogOpen ? 'pending' : 'idle', error: flow.kind === 'closed' && flow.error ? errorText(flow.error) : undefined, } @@ -579,7 +582,7 @@ export function useUserProfileMfaController(model: ReadyModel) { dialogOpen, showAddTrigger, showAddControl: showAddTrigger || dialogOpen, - isPending: isBusy, + isPending: isBusy || pendingAction.isPending, reverifying, onDialogOpenChange: (open: boolean) => { if (!open && !canClose) { diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx index e4609cf442b..de7d2006951 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx @@ -4,6 +4,7 @@ import type { ReactNode, Ref } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; import { ReverificationDialog } from '../../../blocks/reverification'; import { Section } from '../../../components/section'; +import type { PendingAction } from '../../../hooks/use-pending-action'; import { fill, type MosaicMessages, useMessages } from '../../../localization'; import type { ReverificationController } from '../../reverification'; import { UserProfileSecurityList } from '../user-profile-security-list'; @@ -17,6 +18,7 @@ export type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-p export interface UserProfileMfaSectionViewProps { methods: UserProfileMfaMethod[]; + pendingAction?: PendingAction; addableMethods?: readonly UserProfileMfaAddableMethod[]; addButtonRef?: Ref; addControl?: ReactNode; @@ -30,6 +32,7 @@ export interface UserProfileMfaSectionViewProps { export function UserProfileMfaSectionView({ methods, + pendingAction, addableMethods, addButtonRef, addControl, @@ -43,6 +46,7 @@ export function UserProfileMfaSectionView({ const m = useMessages('userProfile').mfa; const controller = useUserProfileMfaSectionLeafController({ methods, + pendingAction, onAdd, onRemove, onSetDefault, @@ -58,6 +62,7 @@ export function UserProfileMfaSectionView({ (controller.addMethod && addableMethods?.length ? ( @@ -77,6 +82,7 @@ export function UserProfileMfaSectionView({ controller.openRemoval(method) : undefined} onSetDefault={controller.setDefault} diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts index ba8b84cb7cc..7c5c5e6a0ef 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts @@ -43,12 +43,12 @@ describe('useUserProfileAddPhoneController', () => { const verifier = { sendCode, verifyCode: () => Promise.resolve() }; expect(result.current.verifyingId).toBeUndefined(); - act(() => result.current.onVerifyPhone({ id: 'phone_2', value: '+18015550101' }, verifier)); + act(() => void result.current.onVerifyPhone({ id: 'phone_2', value: '+18015550101' }, verifier)); expect(result.current.verifyingId).toBe('phone_2'); await act(async () => vi.advanceTimersByTimeAsync(0)); expect(result.current.step).toBe('verify'); - act(() => result.current.onVerifyPhone({ id: 'phone_3', value: '+18015550102' }, verifier)); + act(() => void result.current.onVerifyPhone({ id: 'phone_3', value: '+18015550102' }, verifier)); expect(result.current.verifyingId).toBe('phone_2'); expect(result.current.phoneNumber).toBe('+18015550101'); diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx index 80952c1fe7b..a718e40d106 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx @@ -312,6 +312,8 @@ describe('the user profile phone section', () => { expect(screen.getByRole('menuitem', { name: 'Verify phone number' })).toHaveAttribute('aria-busy', 'true'); expect(await screen.findByRole('progressbar', { name: 'Verify phone number' })).toBeVisible(); expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByRole('button', { name: `Manage ${HOME_LABEL}` })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Add phone number' })).toBeDisabled(); } finally { prepare.release(); } @@ -319,6 +321,7 @@ describe('the user profile phone section', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus()); await enterCode(actor, VERIFICATION_CODE); expect(screen.queryByRole('menu')).toBeNull(); + expect(screen.getByRole('button', { name: `Manage ${HOME_LABEL}` })).not.toHaveAttribute('aria-disabled', 'true'); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts index b3f9d53d912..33cc8f35dba 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts @@ -8,6 +8,7 @@ import { useErrorText } from '../../../localization'; import { setup } from '../../../machine/setup'; import type { ErrorInvokeEvent } from '../../../machine/types'; import { useMachine } from '../../../machine/use-machine'; +import { waitFor } from '../../../machine/wait-for'; import type { FormError } from '../../../utils/errors'; import { toFormError } from '../../../utils/errors'; import { CODE_RESEND_SECONDS, isResendUnlocked, resendUnlockTime } from '../../../utils/verification'; @@ -26,7 +27,7 @@ export interface UserProfileAddPhoneController extends UserProfileAddPhoneDialog handle: DialogHandle; verifyingId: string | undefined; pendingVerifyId: string | undefined; - onVerifyPhone: (phone: Pick, verifier: UserProfilePhoneVerifier) => void; + onVerifyPhone: (phone: Pick, verifier: UserProfilePhoneVerifier) => Promise; } interface Context { @@ -198,9 +199,10 @@ export function useUserProfileAddPhoneController({ }, verifyingId: snapshot.context.verifyingId, pendingVerifyId: state === 'preparing' ? snapshot.context.verifyingId : undefined, - onVerifyPhone: (phone, verifier) => { + onVerifyPhone: async (phone, verifier) => { codeForm.reset(); send({ type: 'START', id: phone.id, phoneNumber: phone.value, verifier }); + await waitFor(actor, current => current.value !== 'preparing'); }, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts index c5a587ad606..d127c320743 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts @@ -60,7 +60,7 @@ export function useUserProfilePhoneSectionController({ onVerify: id => { const phone = phones.find(phone => phone.id === id); if (phone) { - verification.onVerifyPhone(phone, getPhoneVerifier(id)); + void actions.run(id, () => verification.onVerifyPhone(phone, getPhoneVerifier(id))); } }, onSetPrimary: id => void actions.run(id, () => onSetPrimaryPhone(id)), diff --git a/packages/mosaic/src/machine/README.md b/packages/mosaic/src/machine/README.md index f4205427a94..ce5f667ccc6 100644 --- a/packages/mosaic/src/machine/README.md +++ b/packages/mosaic/src/machine/README.md @@ -222,11 +222,12 @@ You've already seen `states`, `on`, `context`, `assign`, and `invoke`. The rest: ### API at a glance -The exports live in `create-machine.ts`, `setup.ts`, `assign.ts`, `create-actor.ts` and `use-machine.ts`; their JSDoc is the reference. Which React hook to reach for: +The exports live in `create-machine.ts`, `setup.ts`, `assign.ts`, `create-actor.ts`, `use-machine.ts` and `wait-for.ts`; their JSDoc is the reference. Which React hook to reach for: - `useMachine(machine)` when the component owns the actor for its lifetime. - `useActor(actor)` when the actor is shared or passed in, including a `mockActor` that renders a step directly. - `useSelector(actor, selector)` when a component needs one slice and should re-render only when that slice changes. +- `waitFor(actor, predicate)` when a caller needs a promise for a machine-driven step, such as holding a `usePendingAction` lock until the machine leaves a sending state. ### Two behaviors worth knowing diff --git a/packages/mosaic/src/machine/__tests__/wait-for.test.ts b/packages/mosaic/src/machine/__tests__/wait-for.test.ts new file mode 100644 index 00000000000..ba4f99d1e42 --- /dev/null +++ b/packages/mosaic/src/machine/__tests__/wait-for.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { deferred, tick } from '../../__tests__/async'; +import { createActor } from '../create-actor'; +import { createMachine } from '../create-machine'; +import { waitFor } from '../wait-for'; + +function startSending() { + const gate = deferred(); + const machine = createMachine, { type: 'SEND' }>({ + initial: 'idle', + states: { + idle: { on: { SEND: 'sending' } }, + sending: { invoke: { src: () => gate.promise, onDone: 'sent' } }, + sent: {}, + }, + }); + const actor = createActor(machine); + actor.start(); + return { actor, gate }; +} + +describe('waitFor', () => { + it('resolves with the current snapshot when it already matches', async () => { + const { actor } = startSending(); + + await expect(waitFor(actor, snapshot => snapshot.value === 'idle')).resolves.toMatchObject({ value: 'idle' }); + }); + + it('resolves once the actor reaches a matching snapshot', async () => { + const { actor, gate } = startSending(); + actor.send({ type: 'SEND' }); + let settled = false; + const waiting = waitFor(actor, snapshot => snapshot.value !== 'sending').then(snapshot => { + settled = true; + return snapshot; + }); + + await tick(); + expect(settled).toBe(false); + + gate.resolve(); + await expect(waiting).resolves.toMatchObject({ value: 'sent' }); + }); +}); diff --git a/packages/mosaic/src/machine/wait-for.ts b/packages/mosaic/src/machine/wait-for.ts new file mode 100644 index 00000000000..26099d794ab --- /dev/null +++ b/packages/mosaic/src/machine/wait-for.ts @@ -0,0 +1,24 @@ +import type { Actor, EventObject, Snapshot } from './types'; + +/** + * Resolves with the first snapshot that matches `predicate`, checking the current one first. + * Use it to await a machine-driven step, such as `actions.run(key, () => waitFor(actor, s => s.value !== 'sending'))`. + */ +export function waitFor( + actor: Pick, 'getSnapshot' | 'subscribe'>, + predicate: (snapshot: Snapshot) => boolean, +): Promise> { + return new Promise(resolve => { + const current = actor.getSnapshot(); + if (predicate(current)) { + resolve(current); + return; + } + const unsubscribe = actor.subscribe(snapshot => { + if (predicate(snapshot)) { + unsubscribe(); + resolve(snapshot); + } + }); + }); +} From 8eda22f202603620474d5a6a9258a5d0a2099170 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 22:13:22 -0600 Subject: [PATCH 2/2] fix(mosaic): let account section controllers own row pending state Connected accounts now disables account menus while a connection starts, so removal can no longer begin mid-connect, and removal shares the section lock. Enterprise accounts moves onto the shared pending action, so a connect error clears when any connection starts. --- .changeset/goofy-parents-trade.md | 2 ++ ...onnected-accounts.connect.feature.test.tsx | 21 +++++++++++++++++++ ...ser-profile-connected-account-row.view.tsx | 10 ++++----- ...e-connected-accounts-section.controller.ts | 12 ++++++----- ...rofile-connected-accounts-section.types.ts | 3 ++- ...rofile-connected-accounts-section.view.tsx | 8 +++---- ...er-profile-enterprise-account-row.view.tsx | 4 +--- ...-enterprise-accounts-section.controller.ts | 12 ++++++----- ...ofile-enterprise-accounts-section.types.ts | 1 + ...ofile-enterprise-accounts-section.view.tsx | 7 +++---- ...ser-profile-mfa-section-leaf.controller.ts | 4 ++-- .../user-profile-enterprise-accounts.ts | 4 ++-- 12 files changed, 56 insertions(+), 32 deletions(-) create mode 100644 .changeset/goofy-parents-trade.md diff --git a/.changeset/goofy-parents-trade.md b/.changeset/goofy-parents-trade.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/goofy-parents-trade.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx index 5a61178dc54..2bc90bef87f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx @@ -158,6 +158,27 @@ describe('connected accounts', () => { request.fail(); }); + it('holds connected account menus while a connection starts', async () => { + await renderSection([google]); + const request = holdRequests('post', '/v1/me/external_accounts'); + const user = userEvent.setup(); + + try { + await user.click(screen.getByRole('button', { name: 'Connect GitHub' })); + await waitFor(() => expect(request.requests).toHaveLength(1)); + const manage = screen.getByRole('button', { name: 'Manage Google' }); + expect(manage).toHaveAttribute('aria-disabled', 'true'); + await user.click(manage); + expect(screen.queryByRole('menu')).toBeNull(); + } finally { + request.fail(); + } + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Manage Google' })).not.toHaveAttribute('aria-disabled', 'true'), + ); + }); + it('verifies the session before connecting a provider, then continues to it', async () => { serveFapi(signedIn([], { verification: { secrets: { password: 'hunter2' } } })); const view = await renderWithClerk(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx index 80730e8551f..6937edb467c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx @@ -19,13 +19,11 @@ import type { export function UserProfileConnectProviderRowView({ provider, - isPending, isDisabled, onConnect, triggerRef, }: { provider: UserProfileConnectionProvider; - isPending: boolean; isDisabled: boolean; onConnect: (id: string) => void; triggerRef?: Ref; @@ -58,8 +56,8 @@ export function UserProfileConnectProviderRowView({ variant='outline' color='neutral' aria-label={fill(m.connectLabel, { provider: provider.provider })} - isPending={isPending} - disabled={isDisabled && !isPending} + isPending={provider.isPending} + disabled={isDisabled && !provider.isPending} onClick={() => onConnect(provider.id)} > {m.connect} @@ -91,7 +89,7 @@ export function UserProfileConnectedAccountRowView({ }) { const m = useMessages('userProfile').connectedAccounts; const actions: ActionMenuAction[] = []; - if ((account.status === 'reconnect' || account.status === 'error') && onReconnect && !isDisabled) { + if ((account.status === 'reconnect' || account.status === 'error') && onReconnect) { actions.push({ label: account.status === 'reconnect' ? m.reconnect : m.tryAgain, onClick: () => onReconnect(account.id), @@ -131,6 +129,8 @@ export function UserProfileConnectedAccountRowView({ {actions.length > 0 ? ( void; onReconnect: (id: string) => void; sectionRef: Ref; @@ -81,10 +81,12 @@ export function useUserProfileConnectedAccountsController({ accounts: accounts.map(account => actions.errorKey === account.id ? { ...account, reconnectError: actions.error } : account, ), - availableProviders: availableProviders.map(provider => - actions.errorKey === provider.id ? { ...provider, connectError: actions.error } : provider, - ), - pendingId: actions.pendingKey, + availableProviders: availableProviders.map(provider => ({ + ...provider, + isPending: actions.pendingKey === provider.id, + connectError: actions.errorKey === provider.id ? actions.error : undefined, + })), + isDisabled: actions.isPending || remove.isPending, onConnect: id => run(id, onConnect), onReconnect: id => run(id, onReconnect), sectionRef, diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts index bfd25079a0b..5d8a2e0b891 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts @@ -16,6 +16,7 @@ export interface ConnectedAccountProviderDisplay { export interface UserProfileConnectionProvider extends ConnectedAccountProviderDisplay { id: string; connectError?: string; + isPending?: boolean; } export interface UserProfileConnectedAccount extends ConnectedAccountProviderDisplay { @@ -30,7 +31,7 @@ export interface UserProfileConnectedAccountsSectionViewProps { accounts: UserProfileConnectedAccount[]; sectionRef?: Ref; availableProviders?: UserProfileConnectionProvider[]; - pendingId?: string; + isDisabled?: boolean; onConnect?: (id: string) => void; onReconnect?: (id: string) => void; registerTrigger: (id: string) => Ref; diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx index 0ce9ea67d93..a8fe2660ba4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx @@ -18,7 +18,7 @@ export function UserProfileConnectedAccountsSectionView({ accounts, sectionRef, availableProviders = [], - pendingId, + isDisabled = false, onConnect, onReconnect, registerTrigger, @@ -31,7 +31,6 @@ export function UserProfileConnectedAccountsSectionView({ }: UserProfileConnectedAccountsSectionViewProps) { const m = useMessages('userProfile').connectedAccounts; const hasRows = accounts.length > 0 || (availableProviders.length > 0 && Boolean(onConnect)); - const isBusy = pendingId !== undefined; return ( <> @@ -52,7 +51,7 @@ export function UserProfileConnectedAccountsSectionView({ key={account.id} account={account} triggerRef={registerTrigger(account.id)} - isDisabled={isBusy} + isDisabled={isDisabled} onReconnect={onReconnect} onRemove={remove ? account => remove.open(account) : undefined} /> @@ -63,8 +62,7 @@ export function UserProfileConnectedAccountsSectionView({ key={provider.id} provider={provider} triggerRef={registerTrigger(provider.id)} - isPending={pendingId === provider.id} - isDisabled={isBusy} + isDisabled={isDisabled} onConnect={onConnect} /> )) diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx index 043343f82f4..635e28d3931 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx @@ -14,12 +14,10 @@ import type { UserProfileEnterpriseAccount } from './user-profile-enterprise-acc export function UserProfileEnterpriseAccountRowView({ account, onConnect, - isPending, disabled, }: { account: UserProfileEnterpriseAccount; onConnect?: (id: string) => void; - isPending?: boolean; disabled?: boolean; }) { const m = useMessages('userProfile').enterpriseAccounts; @@ -58,7 +56,7 @@ export function UserProfileEnterpriseAccountRowView({ size='sm' variant='outline' aria-label={fill(m.connectProvider, { provider: account.name })} - isPending={isPending} + isPending={account.isPending} pendingLabel={m.connecting} disabled={disabled} onClick={() => onConnect(account.id)} diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts index d92ec9df897..7e36b725538 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts @@ -18,7 +18,7 @@ export interface UserProfileEnterpriseAccountsControllerOptions { export interface UserProfileEnterpriseAccountsController { accounts: UserProfileEnterpriseAccount[]; connections: UserProfileEnterpriseConnection[]; - pendingId: string | undefined; + isDisabled: boolean; onConnect: (connectionId: string) => void; connectReverification: ReverificationController; } @@ -34,10 +34,12 @@ export function useUserProfileEnterpriseAccountsController({ return { accounts, - connections: connections.map(connection => - actions.errorKey === connection.id ? { ...connection, connectError: actions.error } : connection, - ), - pendingId: actions.pendingKey, + connections: connections.map(connection => ({ + ...connection, + isPending: actions.pendingKey === connection.id, + connectError: actions.errorKey === connection.id ? actions.error : undefined, + })), + isDisabled: actions.isPending, onConnect: id => void actions.run(id, async () => { if ((await onConnect(id)) === 'redirecting') { diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts index 9f43d1bf1af..3e09e948d21 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts @@ -4,6 +4,7 @@ export interface UserProfileEnterpriseConnection { iconUrl?: string; monochromeIcon?: boolean; connectError?: string; + isPending?: boolean; } export interface UserProfileEnterpriseAccount extends UserProfileEnterpriseConnection { diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx index ca9ba722029..c2144b7382c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx @@ -11,7 +11,7 @@ import type { export interface UserProfileEnterpriseAccountsSectionViewProps { accounts: UserProfileEnterpriseAccount[]; connections?: UserProfileEnterpriseConnection[]; - pendingId?: string; + isDisabled?: boolean; onConnect?: (id: string) => void; connectReverification?: ReverificationController; } @@ -19,7 +19,7 @@ export interface UserProfileEnterpriseAccountsSectionViewProps { export function UserProfileEnterpriseAccountsSectionView({ accounts, connections = [], - pendingId, + isDisabled = false, onConnect, connectReverification, }: UserProfileEnterpriseAccountsSectionViewProps) { @@ -49,8 +49,7 @@ export function UserProfileEnterpriseAccountsSectionView({ key={connection.id} account={connection} onConnect={onConnect} - isPending={pendingId === connection.id} - disabled={pendingId !== undefined} + disabled={isDisabled} /> )) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section-leaf.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section-leaf.controller.ts index aeb0e6cb6a4..7ab4a6fb86e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section-leaf.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section-leaf.controller.ts @@ -41,7 +41,7 @@ export function useUserProfileMfaSectionLeafController({ return { sectionRef, - isDisabled: actions.isPending, + isDisabled: actions.isPending || remove.isPending, pickerOpen, onPickerOpenChange: setPickerOpen, addMethod: onAdd @@ -55,7 +55,7 @@ export function useUserProfileMfaSectionLeafController({ actions.reset(); remove.open(method); }, - remove: onRemove ? actions.guard(remove) : undefined, + remove: onRemove ? remove : undefined, finalRemovalFocus: removalFocus.finalFocus, setDefaultFinalFocus: () => (actions.lastKey ? removalFocus.trigger(actions.lastKey) : null), defaultError: actions.error, diff --git a/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts index 9316a74f1d1..f05eecd8016 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts @@ -50,8 +50,8 @@ export function useEnterpriseAccountsFixture({ return { accounts: linkedAccounts, - connections: availableConnections, - pendingId, + connections: availableConnections.map(item => ({ ...item, isPending: item.id === pendingId })), + isDisabled: pendingId !== undefined, onConnect: (id: string) => { const connection = availableConnections.find(item => item.id === id); if (!connection || pendingId) {