Skip to content

Commit 7256f0f

Browse files
fix(mosaic): align MFA dialog close controls with pending actions
1 parent ae4e285 commit 7256f0f

3 files changed

Lines changed: 70 additions & 22 deletions

File tree

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,13 @@ describe('User profile MFA interaction boundaries', () => {
100100
await user.click(screen.getByRole('button', { name: /Authenticator app/ }));
101101
await waitFor(() => expect(held.requests).toHaveLength(1));
102102
const dialog = screen.getByRole('dialog');
103-
await user.keyboard('{Escape}');
104-
expect(dialog).toBeVisible();
105-
held.release();
103+
try {
104+
expect(within(dialog).queryByRole('button', { name: 'Close' })).toBeNull();
105+
await user.keyboard('{Escape}');
106+
expect(dialog).toBeVisible();
107+
} finally {
108+
held.release();
109+
}
106110
expect(await screen.findByRole('img', { name: /QR code/i })).toBeVisible();
107111
expect(fapi.mfa.totpCreations).toBe(1);
108112
});

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

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
fapiUrl,
1010
holdRequests,
1111
serveFapi,
12+
VERIFICATION_CODE,
1213
worker,
1314
} from '../../../../__tests__/feature/fake-fapi';
1415
import { fapiClient, fapiPhoneNumber, fapiSession, fapiUser } from '../../../../__tests__/feature/fapi';
@@ -370,9 +371,10 @@ describe('User profile MFA management', () => {
370371
}
371372
expect(screen.getByRole('button', { name: 'Copy and close' })).toBeVisible();
372373
expect(screen.getByRole('button', { name: 'Copy and close' })).toHaveAttribute('aria-disabled', 'true');
373-
await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus());
374+
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
374375
held.release();
375376
await waitFor(() => expect(screen.getByText('CODE0100')).toBeVisible());
377+
expect(screen.getByRole('button', { name: 'Close' })).toBeVisible();
376378
for (const name of ['Print', 'Download', 'Copy and close']) {
377379
expect(screen.getByRole('button', { name })).toBeEnabled();
378380
}
@@ -481,9 +483,13 @@ describe('User profile MFA management', () => {
481483
await regenerate(user);
482484
await waitFor(() => expect(first.requests).toHaveLength(1));
483485
const dialog = screen.getByRole('dialog');
484-
await user.keyboard('{Escape}');
485-
expect(dialog).toBeVisible();
486-
first.release();
486+
try {
487+
expect(within(dialog).queryByRole('button', { name: 'Close' })).toBeNull();
488+
await user.keyboard('{Escape}');
489+
expect(dialog).toBeVisible();
490+
} finally {
491+
first.release();
492+
}
487493

488494
expect(await within(dialog).findByText('Enter your current password to continue')).toBeVisible();
489495
await user.type(screen.getByLabelText('Password'), 'hunter2{Enter}');
@@ -511,6 +517,37 @@ describe('User profile MFA management', () => {
511517
expect(fapi.mfa.phoneUpdates).toContainEqual({ id: 'phone_1', reserved: true, default: undefined });
512518
});
513519

520+
it('keeps SMS code verification open until its reservation challenge can be cancelled', async () => {
521+
const unverified = fapiPhoneNumber({ ...phone, verification: { ...phone.verification, status: 'unverified' } });
522+
const fapi = await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [unverified] }), mfaEnvironment(), {
523+
verification: { secrets: { password: 'hunter2' } },
524+
});
525+
demandReverificationOnce('/v1/me/phone_numbers/phone_1');
526+
const user = userEvent.setup();
527+
528+
await startSms(user);
529+
await user.click(screen.getByRole('button', { name: 'Continue' }));
530+
const code = await screen.findByRole('textbox', { name: 'Verification code' });
531+
const attempt = holdRequests('post', '/v1/me/phone_numbers/phone_1/attempt_verification');
532+
await user.type(code, VERIFICATION_CODE);
533+
await waitFor(() => expect(attempt.requests).toHaveLength(1));
534+
const dialog = screen.getByRole('dialog');
535+
try {
536+
expect(within(dialog).queryByRole('button', { name: 'Close' })).toBeNull();
537+
await user.keyboard('{Escape}');
538+
expect(dialog).toBeVisible();
539+
} finally {
540+
attempt.release();
541+
}
542+
543+
expect(await within(dialog).findByText('Enter your current password to continue')).toBeVisible();
544+
await user.click(within(dialog).getByRole('button', { name: 'Close' }));
545+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
546+
expect(fapi.mfa.phoneAttempts).toEqual([{ id: 'phone_1', code: VERIFICATION_CODE }]);
547+
expect(fapi.mfa.phoneUpdates).toEqual([]);
548+
expect(screen.queryByRole('alert')).toBeNull();
549+
});
550+
514551
it('verifies the session before creating a phone for SMS MFA, then sends its code', async () => {
515552
const fapi = await renderMfa(fapiUser({ id: 'user_1' }), mfaEnvironment(), {
516553
verification: { secrets: { password: 'hunter2' } },

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

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ type MfaEvent =
6262

6363
const { createMachine, assign, fromPromise } = setup<MfaContext, MfaEvent>();
6464
const RESEND_COOLDOWN_SECONDS = 30;
65-
const REVERIFIABLE_ACTIONS: readonly (MfaAction | undefined)[] = ['generate', 'sms', 'prepareAuthenticator'];
65+
const REVERIFIABLE_ACTIONS: readonly MfaAction[] = ['generate', 'sms', 'prepareAuthenticator'];
6666
const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) };
6767

6868
const mfaMachine = createMachine({
@@ -421,15 +421,20 @@ function createSetupProps(
421421
const sms = flow.kind === 'sms' ? flow : undefined;
422422
const authenticator = flow.kind === 'authenticator' ? flow : undefined;
423423
const backup = flow.kind === 'backup' ? flow : undefined;
424-
const isReverifying = REVERIFIABLE_ACTIONS.includes(context.pending) && reverification.visible;
424+
const isReverifying =
425+
context.pending !== undefined && REVERIFIABLE_ACTIONS.includes(context.pending) && reverification.visible;
426+
let step: UserProfileMfaSetupViewProps['step'];
427+
if (isReverifying) {
428+
step = 'verify';
429+
} else if (flow.kind === 'closed') {
430+
step = 'select';
431+
} else if (flow.kind === 'backup') {
432+
step = 'backup-codes';
433+
} else {
434+
step = flow.kind;
435+
}
425436
return {
426-
step: isReverifying
427-
? 'verify'
428-
: flow.kind === 'closed'
429-
? 'select'
430-
: flow.kind === 'backup'
431-
? 'backup-codes'
432-
: flow.kind,
437+
step,
433438
reverification,
434439
methods: model.addableMethods,
435440
onSelect: actions.select,
@@ -561,22 +566,24 @@ export function useUserProfileMfaController(model: ReadyModel) {
561566

562567
const showAddTrigger = model.addableMethods.length > 0;
563568
const reverifying = setupProps.step === 'verify';
569+
const hasPendingReverification = context.pending !== undefined && REVERIFIABLE_ACTIONS.includes(context.pending);
570+
const canClose = !hasPendingReverification || Boolean(reverification.onCancel);
564571

565572
return {
566573
dialogOpen,
567574
showAddTrigger,
568575
showAddControl: showAddTrigger || dialogOpen,
569576
reverifying,
570577
onDialogOpenChange: (open: boolean) => {
571-
if (!open && REVERIFIABLE_ACTIONS.includes(context.pending)) {
572-
if (!reverification.onCancel) {
573-
return;
574-
}
575-
reverification.onCancel();
578+
if (!open && !canClose) {
579+
return;
580+
}
581+
if (!open && hasPendingReverification) {
582+
reverification.onCancel?.();
576583
}
577584
send({ type: open ? 'OPEN' : 'CLOSE' });
578585
},
579-
showCloseButton: !reverifying || Boolean(reverification.onCancel),
586+
showCloseButton: canClose,
580587
sectionProps,
581588
setupProps,
582589
};

0 commit comments

Comments
 (0)