From bd829cfa0eebb3d0b9ae2e1a39154f61d25b9cc5 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 | 9 ++- .../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, 199 insertions(+), 42 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 ffe933bff8b..0a5c9c4bf44 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -36,6 +36,7 @@ Most problems a feature hits already have a shared answer. Find the row for your | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | | An async action from a button or row: pending, an error, one lock | `usePendingAction` (`src/hooks/`) | | Direct actions and confirmation dialogs that must exclude each other | `usePendingAction.guard` on the confirmation (`src/hooks/`, `src/blocks/confirm/`) | +| A machine-driven step that a lock or caller must await | `waitFor` (`src/machine/wait-for.ts`), inside `usePendingAction.run` | | A form: values, validation, submit, field errors, success UI | `useForm` (`src/components/form/`), with `validateAsync` for server checks and `onSuccess` for mounted success UI | | A `
` element, without the browser's validation popup | `Form` (`src/primitives/form/`) | | A model save whose Clerk errors should land on form fields | `save` (`src/utils/errors.ts`) in the model, or reject with `FormSubmitError` | diff --git a/packages/mosaic/docs/controllers.md b/packages/mosaic/docs/controllers.md index 1353e16f808..b9d8fa39b2b 100644 --- a/packages/mosaic/docs/controllers.md +++ b/packages/mosaic/docs/controllers.md @@ -85,9 +85,12 @@ so views render it without comparing pending keys. `run` resolves `true` on success, so the caller can close a dialog or move focus. To hold pending through an OAuth redirect, await a delay inside the -action (`user-profile-connected-accounts-section.controller.ts`). When the user -cancels a reverification challenge, `run` resolves `false` without showing an -error and releases the lock. It is not for: +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`). When the user cancels a +reverification challenge, `run` resolves `false` without showing an error and +releases the lock. It is not for: - a form → `useForm` - a confirm step → `useConfirmController` with `Confirmation` / `Destructive` 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 1f38a4fa3d1..9129cf5d379 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 @@ -200,7 +200,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'); @@ -227,11 +227,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'); @@ -253,7 +253,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); @@ -279,7 +279,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); @@ -297,12 +297,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); @@ -312,7 +312,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); @@ -323,7 +323,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 dceb6ef2b2d..9f05db314fa 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 @@ -157,12 +157,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 048e1a302ba..41cfcf8398b 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 @@ -7,6 +7,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 } from '../../../utils/verification'; @@ -31,7 +32,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 { @@ -266,9 +267,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 af5666ef02e..79abc2f965b 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 9602f10ca77..3d8f54cd463 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,6 +1,7 @@ import { isReverificationCancelledError } from '@clerk/shared/error'; import { useEffect } from 'react'; +import { usePendingAction } from '../../../hooks/use-pending-action'; import { type ErrorDescription, fill, type MosaicMessages, useErrorText, useMessages } from '../../../localization'; import { setup } from '../../../machine/setup'; import type { DoneInvokeEvent } from '../../../machine/types'; @@ -355,9 +356,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); @@ -504,6 +505,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); @@ -557,7 +559,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 }); } @@ -574,7 +576,7 @@ export function useUserProfileMfaController(model: ReadyModel) { submitSms, resendSms, verifyAuthenticator, - generateBackupCodes, + generateBackupCodes: () => void generateBackupCodes(), copy, download, print, @@ -585,11 +587,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, } @@ -607,7 +610,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 3340d462079..0c8257bbdea 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 7cf4e0e1db3..b7dd97e88a0 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 @@ -197,6 +197,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(); } @@ -204,6 +206,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 7db589f8e18..3f1f2899437 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 @@ -7,6 +7,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 } from '../../../utils/verification'; @@ -25,7 +26,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 { @@ -199,9 +200,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 3376aa8c0a5..f3ef40941c2 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 d4951947791..3845f56fc8b 100644 --- a/packages/mosaic/src/machine/README.md +++ b/packages/mosaic/src/machine/README.md @@ -223,11 +223,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 fa62776265c8039ea9d6ae61cbeb1bbd285ad1ee 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 | 14 +++++++------ ...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 +++---- .../user-profile-enterprise-accounts.ts | 4 ++-- 11 files changed, 55 insertions(+), 31 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 75288f5e092..1c493dd8110 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,15 +81,17 @@ 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, onConnect: id => run(id, onConnect), onReconnect: id => run(id, onReconnect), sectionRef, registerTrigger: removalFocus.registerTrigger, - remove: onRemove ? remove : undefined, + remove: onRemove ? actions.guard(remove) : undefined, finalFocus: removalFocus.finalFocus, reconnectReverification, reconnectFinalFocus: () => (actions.lastKey ? removalFocus.trigger(actions.lastKey) : null), 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 7908a8f2b0c..1bc2d9306be 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/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) {