Skip to content

Commit 7993987

Browse files
feat(mosaic): hold the backup codes menu open while regeneration starts
1 parent 7095120 commit 7993987

8 files changed

Lines changed: 115 additions & 7 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/docs/controllers.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ section. The dialog opens once `visible` is true, so it skips the loading card,
104104
dismissal only while the controller has a cancel callback. Return focus with the hook's
105105
`lastKey`, which outlives the run, unlike `pendingKey`.
106106

107+
For a direct action in `ActionMenu`, derive its `progress` from the pending
108+
action and whether its dialog is open. Pass `idle` before activation, `pending`
109+
while the request or verification preparation is running, and `handoff` when the
110+
dialog becomes visible. The menu keeps its selected item and trailing spinner
111+
visible while pending, disables its actions, and blocks dismissal until handoff
112+
or settlement. Ordinary failures close the menu and use the section's existing
113+
error. Actions without `progress` still close immediately.
114+
107115
A single open boolean earns nothing. A two-state machine with one event is a
108116
boolean spelled long. `useUserProfileEditUsernameController` holds `isOpen` in
109117
`useState` and hands the async part to `useForm`:

‎packages/mosaic/src/components/action-menu/action-menu.test.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen } from '@testing-library/react';
1+
import { render, screen, waitFor } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { describe, expect, it, vi } from 'vitest';
44

@@ -37,6 +37,18 @@ describe('Mosaic ActionMenu', () => {
3737

3838
expect(onClick).toHaveBeenCalledOnce();
3939
});
40+
it('closes a progress action that settles before pending renders', async () => {
41+
const onClick = vi.fn();
42+
const user = userEvent.setup();
43+
renderMenu([{ label: 'Make default', progress: 'idle', onClick }]);
44+
45+
await user.click(screen.getByRole('button', { name: 'Manage passkey' }));
46+
await user.click(await screen.findByRole('menuitem', { name: 'Make default' }));
47+
48+
expect(onClick).toHaveBeenCalledOnce();
49+
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
50+
});
51+
4052
it('keeps a disabled trigger focusable without allowing pointer or keyboard activation', async () => {
4153
const onClick = vi.fn();
4254
render(

‎packages/mosaic/src/components/action-menu/action-menu.tsx‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import type { Ref } from 'react';
1+
import { type Ref, useState } from 'react';
22

3+
import { useSpinDelay } from '../../hooks/use-spin-delay';
34
import type { IconComponent } from '../../icons/glyph';
45
import { Icon } from '../icon';
56
import { Menu } from '../menu';
7+
import { Spinner } from '../spinner';
68

79
export interface ActionMenuAction {
810
label: string;
911
color?: 'neutral' | 'negative';
1012
icon?: IconComponent;
1113
onClick: () => void;
14+
progress?: 'idle' | 'pending' | 'handoff';
1215
}
1316

1417
export interface ActionMenuProps {
@@ -20,12 +23,31 @@ export interface ActionMenuProps {
2023
}
2124

2225
export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhenDisabled }: ActionMenuProps) {
26+
const [open, setOpen] = useState(false);
27+
const [activated, setActivated] = useState(false);
28+
const pending = actions.some(action => action.progress === 'pending');
29+
const handoff = actions.some(action => action.progress === 'handoff');
30+
const showSpinner = useSpinDelay(pending ? true : null);
31+
32+
if (activated && !pending) {
33+
setActivated(false);
34+
setOpen(false);
35+
}
36+
2337
if (actions.length === 0) {
2438
return null;
2539
}
2640

2741
return (
28-
<Menu.Root placement='bottom-end'>
42+
<Menu.Root
43+
placement='bottom-end'
44+
open={open && !handoff}
45+
onOpenChange={nextOpen => {
46+
if (!pending && !handoff) {
47+
setOpen(nextOpen);
48+
}
49+
}}
50+
>
2951
<Menu.Trigger
3052
ref={triggerRef}
3153
aria-label={label}
@@ -38,15 +60,30 @@ export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhen
3860
key={action.label}
3961
color={action.color}
4062
label={action.label}
41-
onClick={action.onClick}
42-
disabled={disabled}
63+
onClick={() => {
64+
if (action.progress !== undefined) {
65+
setActivated(true);
66+
}
67+
action.onClick();
68+
}}
69+
closeOnClick={action.progress === undefined}
70+
aria-busy={action.progress === 'pending' || undefined}
71+
disabled={disabled || pending || handoff}
4372
>
4473
{action.icon ? (
4574
<Menu.Media>
4675
<Icon glyph={action.icon} />
4776
</Menu.Media>
4877
) : null}
4978
<Menu.Label>{action.label}</Menu.Label>
79+
{action.progress === 'pending' && showSpinner ? (
80+
<Spinner
81+
size='sm'
82+
role='progressbar'
83+
aria-hidden={undefined}
84+
aria-label={action.label}
85+
/>
86+
) : null}
5087
</Menu.Item>
5188
))}
5289
</Menu.Popup>

‎packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,49 @@ describe('User profile MFA management', () => {
417417
expect(fapi.mfa.backupCodeCreations).toBe(1);
418418
});
419419

420+
it('holds the menu open with a spinner on Regenerate until the backup codes are ready', async () => {
421+
await renderMfa(
422+
fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }),
423+
mfaEnvironment(),
424+
);
425+
const generate = holdRequests('post', '/v1/me/backup_codes/');
426+
const user = userEvent.setup();
427+
428+
try {
429+
await regenerate(user);
430+
await waitFor(() => expect(generate.requests).toHaveLength(1));
431+
expect(screen.getByRole('menu')).toBeVisible();
432+
expect(screen.getByRole('menuitem', { name: 'Regenerate' })).toHaveAttribute('aria-busy', 'true');
433+
expect(await screen.findByRole('progressbar', { name: 'Regenerate' })).toBeVisible();
434+
expect(screen.queryByRole('dialog')).toBeNull();
435+
} finally {
436+
generate.release();
437+
}
438+
439+
const list = await screen.findByRole('list', { name: 'Backup codes' });
440+
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
441+
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
442+
});
443+
444+
it('holds the Regenerate menu item until session verification opens', async () => {
445+
await renderBackupCodesNeedingReverification();
446+
const start = holdRequests('post', '/v1/client/sessions/:id/verify');
447+
const user = userEvent.setup();
448+
449+
try {
450+
await regenerate(user);
451+
await waitFor(() => expect(start.requests).toHaveLength(1));
452+
expect(screen.getByRole('menu')).toBeVisible();
453+
expect(screen.getByRole('menuitem', { name: 'Regenerate' })).toHaveAttribute('aria-busy', 'true');
454+
expect(screen.queryByText('Enter your current password to continue')).toBeNull();
455+
} finally {
456+
start.release();
457+
}
458+
459+
expect(await screen.findByText('Enter your current password to continue')).toBeVisible();
460+
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
461+
});
462+
420463
async function renderBackupCodesNeedingReverification() {
421464
const fapi = await renderMfa(
422465
fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }),

‎packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export function UserProfileMfaRowView({
1515
onRemove,
1616
onSetDefault,
1717
onRegenerateBackupCodes,
18-
}: Pick<UserProfileMfaSectionViewProps, 'onRegenerateBackupCodes'> & {
18+
regenerateProgress,
19+
}: Pick<UserProfileMfaSectionViewProps, 'onRegenerateBackupCodes' | 'regenerateProgress'> & {
1920
method: UserProfileMfaMethod;
2021
triggerRef?: Ref<HTMLButtonElement>;
2122
onRemove?: () => void;
@@ -37,7 +38,7 @@ export function UserProfileMfaRowView({
3738

3839
if (method.type === 'backup-codes') {
3940
if (onRegenerateBackupCodes) {
40-
actions.push({ label: m.regenerate, onClick: onRegenerateBackupCodes });
41+
actions.push({ label: m.regenerate, onClick: onRegenerateBackupCodes, progress: regenerateProgress });
4142
}
4243
} else if (onRemove && method.canRemove !== false) {
4344
actions.push({ label: m.remove, color: 'negative', onClick: onRemove });

‎packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ export function useUserProfileMfaController(model: ReadyModel) {
588588
methods: [...model.methods],
589589
addableMethods: model.addableMethods,
590590
onRegenerateBackupCodes: model.generateBackupCodes ? generateBackupCodes : undefined,
591+
regenerateProgress: context.pending === 'generate' ? (dialogOpen ? 'handoff' : 'pending') : 'idle',
591592
regenerateError: flow.kind === 'closed' && flow.error ? errorText(flow.error) : undefined,
592593
onRemove: model.remove,
593594
onSetDefault: model.setDefault,

‎packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ReactNode, Ref } from 'react';
33

44
import { Confirmation } from '../../../blocks/confirmation';
55
import { ReverificationDialog } from '../../../blocks/reverification';
6+
import type { ActionMenuAction } from '../../../components/action-menu';
67
import { Section } from '../../../components/section';
78
import { fill, type MosaicMessages, useMessages } from '../../../localization';
89
import type { ReverificationController } from '../../reverification';
@@ -22,6 +23,7 @@ export interface UserProfileMfaSectionViewProps {
2223
addControl?: ReactNode;
2324
onAdd?: (type: UserProfileMfaAddableMethod) => void;
2425
onRegenerateBackupCodes?: () => void;
26+
regenerateProgress?: ActionMenuAction['progress'];
2527
regenerateError?: string;
2628
onRemove?: (id: string) => void | Promise<void>;
2729
onSetDefault?: (id: string) => void | Promise<void>;
@@ -36,6 +38,7 @@ export function UserProfileMfaSectionView({
3638
addControl,
3739
onAdd,
3840
onRegenerateBackupCodes,
41+
regenerateProgress,
3942
regenerateError,
4043
onRemove,
4144
onSetDefault,
@@ -83,6 +86,7 @@ export function UserProfileMfaSectionView({
8386
onRemove={controller.remove ? () => controller.openRemoval(method) : undefined}
8487
onSetDefault={controller.setDefault}
8588
onRegenerateBackupCodes={onRegenerateBackupCodes}
89+
regenerateProgress={regenerateProgress}
8690
/>
8791
))}
8892
</UserProfileSecurityList>

0 commit comments

Comments
 (0)