Skip to content

Commit 4ff463a

Browse files
feat(ui): Add MFA section + flow (#9802)
1 parent 1b2e623 commit 4ff463a

51 files changed

Lines changed: 3191 additions & 442 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/quiet-mfa-setup.md‎

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

‎.claude/skills/mosaic/references/motion.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Motion: entrances and exits
1+
# Motion: entrances, exits, and pulses
22

33
Token semantics live in `packages/mosaic/src/tokens.stylex.ts`, above
44
`durationDefaults` / `easingDefaults` — read those comments first. This file is the
@@ -15,6 +15,7 @@ rather than eyeball it.
1515
| `--cl-ease-default` | `cubic-bezier(0.175, 0.885, 0.32, 1.1)` | things ARRIVING (Swift Out) |
1616
| `--cl-ease-enter` | `cubic-bezier(0, 0, 0.2, 1)` | arrivals that must not overshoot |
1717
| `--cl-ease-exit` | `cubic-bezier(0.55, 0.085, 0.68, 0.53)` | things LEAVING (In Quad) |
18+
| `--cl-ease-pulse` | `cubic-bezier(0.4, 0, 0.6, 1)` | repeating opacity pulses |
1819

1920
Named curves come from [easing.dev](https://www.easing.dev) (Lochie Axon's Easing
2021
Graphs). Take one from there rather than inventing a bezier, so the catalog stays
@@ -47,10 +48,17 @@ So the axis is not the element's type but the size of its overshoot. Work out wh
4748
2% of the travel actually is; once it is enough pixels to notice as a bounce, take
4849
`--cl-ease-enter`, which decelerates the same way without the pass-through.
4950

50-
Opacity is the degenerate case and always takes `--cl-ease-enter`: there is nothing
51+
For entrances, opacity takes `--cl-ease-enter`: there is nothing
5152
past `1` to overshoot into, so the pass is clamped away and only its cost — the
5253
slower approach to full opacity — is left.
5354

55+
## Repeating pulses
56+
57+
Use `--cl-ease-pulse` for repeating opacity fades such as loading skeletons. Its
58+
symmetric curve slows at both ends of each fade, keeping the reversal smooth.
59+
Keep the pulse duration on the component and disable the animation under
60+
`prefers-reduced-motion: reduce`.
61+
5462
## A curve has a direction — don't run the entrance curve backwards
5563

5664
The single most common motion bug in this codebase. `--cl-ease-default` is
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"files": [
3-
{ "path": "./dist/index.js", "maxSize": "123KB" },
3+
{ "path": "./dist/index.js", "maxSize": "125KB" },
44
{ "path": "./dist/styles.css", "maxSize": "12KB" }
55
]
66
}

‎packages/mosaic/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"@types/react": "catalog:react",
7373
"@types/react-dom": "catalog:react",
7474
"bundlewatch": "^0.4.2",
75+
"qrcode.react": "4.2.0",
7576
"react": "catalog:react",
7677
"react-dom": "catalog:react",
7778
"tsdown": "catalog:repo"

‎packages/mosaic/src/components/phone-input/phone-input.test.tsx‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,22 +42,6 @@ describe('Mosaic PhoneInput', () => {
4242
expect(ref).toHaveBeenLastCalledWith(null);
4343
});
4444

45-
it('keeps the country indicator on the selection while hovering another country', async () => {
46-
const user = userEvent.setup();
47-
render(<PhoneInput aria-label='Phone number' />);
48-
await user.click(screen.getByRole('button', { name: 'Country, United States' }));
49-
const us = screen.getByRole('option', { name: /United States/ });
50-
const uk = screen.getByRole('option', { name: /United Kingdom/ });
51-
await user.hover(uk);
52-
expect(us.querySelector('.cl-combobox-option-indicator')).toBeVisible();
53-
expect(uk.querySelector('.cl-combobox-option-indicator')).not.toBeInTheDocument();
54-
await user.click(uk);
55-
await user.click(screen.getByRole('button', { name: 'Country, United Kingdom' }));
56-
expect(
57-
screen.getByRole('option', { name: /United Kingdom/ }).querySelector('.cl-combobox-option-indicator'),
58-
).toBeVisible();
59-
});
60-
6145
it('positions the country popup against the full phone field', async () => {
6246
const user = userEvent.setup();
6347
render(<PhoneInput aria-label='Phone number' />);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { vi } from 'vitest';
2+
3+
import { MosaicProvider } from '../../../MosaicProvider';
4+
import { UserProfileAddMfaDialog } from '../user-profile-add-mfa.dialog';
5+
import type { UserProfileMfaSetupViewProps } from '../user-profile-mfa-setup.view';
6+
import { UserProfileMfaSetupView } from '../user-profile-mfa-setup.view';
7+
8+
export function MfaSetupDialog(props: Partial<UserProfileMfaSetupViewProps>) {
9+
return (
10+
<MosaicProvider>
11+
<UserProfileAddMfaDialog
12+
open
13+
onOpenChange={vi.fn()}
14+
>
15+
<UserProfileMfaSetupView
16+
step='select'
17+
methods={['sms', 'authenticator', 'backup-codes']}
18+
onSelect={vi.fn()}
19+
onBack={vi.fn()}
20+
onCancel={vi.fn()}
21+
authenticator={{
22+
onRetry: vi.fn(),
23+
code: '',
24+
onCodeChange: vi.fn(),
25+
onSubmit: vi.fn(),
26+
}}
27+
sms={{
28+
step: 'select',
29+
phoneNumbers: [],
30+
selectedPhoneId: '',
31+
onSelectedPhoneIdChange: vi.fn(),
32+
onAddPhone: vi.fn(),
33+
onBack: vi.fn(),
34+
phoneNumber: '',
35+
onPhoneNumberChange: vi.fn(),
36+
code: '',
37+
onCodeChange: vi.fn(),
38+
onSubmit: vi.fn(),
39+
onResend: vi.fn(),
40+
}}
41+
backupCodes={{
42+
codes: [],
43+
onRetry: vi.fn(),
44+
onCopy: vi.fn(),
45+
onDownload: vi.fn(),
46+
}}
47+
{...props}
48+
/>
49+
</UserProfileAddMfaDialog>
50+
</MosaicProvider>
51+
);
52+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { useState } from 'react';
4+
import { describe, expect, it, vi } from 'vitest';
5+
6+
import type { UserProfileMfaSetupViewProps } from '../user-profile-mfa-setup.view';
7+
import { MfaSetupDialog } from './mfa-test-utils';
8+
9+
type ViewProps = UserProfileMfaSetupViewProps['authenticator'];
10+
11+
const setup = {
12+
secret: 'JBSWY3DPEHPK3PXP',
13+
uri: 'otpauth://totp/Swingset:demo@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Swingset',
14+
};
15+
16+
function renderView(overrides: Partial<ViewProps> = {}, step: UserProfileMfaSetupViewProps['step'] = 'authenticator') {
17+
const props: ViewProps = {
18+
setup,
19+
onRetry: vi.fn(),
20+
code: '',
21+
onCodeChange: vi.fn(),
22+
onSubmit: vi.fn(),
23+
...overrides,
24+
};
25+
return {
26+
props,
27+
...render(
28+
<MfaSetupDialog
29+
step={step}
30+
authenticator={props}
31+
/>,
32+
),
33+
};
34+
}
35+
36+
function VerificationExample({ onSubmit }: Pick<ViewProps, 'onSubmit'>) {
37+
const [code, setCode] = useState('');
38+
return (
39+
<MfaSetupDialog
40+
step='authenticator'
41+
authenticator={{ setup, onRetry: () => undefined, code, onCodeChange: setCode, onSubmit }}
42+
/>
43+
);
44+
}
45+
46+
describe('UserProfileAddAuthenticatorView', () => {
47+
it('shows preparation, offers retry on failure, and waits for setup data before verification', async () => {
48+
const user = userEvent.setup();
49+
const { props, rerender } = renderView({ setup: undefined }, 'select');
50+
rerender(
51+
<MfaSetupDialog
52+
step='authenticator'
53+
authenticator={props}
54+
/>,
55+
);
56+
const dialog = screen.getByRole('dialog', { name: 'Add an authenticator app' });
57+
expect(screen.getByRole('status', { name: 'Preparing authenticator…' })).toBeVisible();
58+
expect(screen.queryByRole('img')).not.toBeInTheDocument();
59+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
60+
expect(screen.queryByRole('button', { name: 'Verify', exact: true })).not.toBeInTheDocument();
61+
expect(screen.getByRole('button', { name: 'Back' })).toBeEnabled();
62+
expect(screen.getByRole('button', { name: /Preparing authenticator/ })).toHaveFocus();
63+
64+
rerender(
65+
<MfaSetupDialog
66+
step='authenticator'
67+
authenticator={{ ...props, setupErrorMessage: 'Unable to prepare your authenticator.' }}
68+
/>,
69+
);
70+
expect(screen.getByRole('alert')).toHaveTextContent('Unable to prepare your authenticator.');
71+
expect(screen.queryByRole('status', { name: 'Preparing authenticator…' })).not.toBeInTheDocument();
72+
expect(screen.getByRole('button', { name: 'Try again' })).toHaveFocus();
73+
await user.click(screen.getByRole('button', { name: 'Try again' }));
74+
expect(props.onRetry).toHaveBeenCalledOnce();
75+
expect(props.onSubmit).not.toHaveBeenCalled();
76+
77+
rerender(
78+
<MfaSetupDialog
79+
step='authenticator'
80+
authenticator={props}
81+
/>,
82+
);
83+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
84+
expect(screen.getByRole('status', { name: 'Preparing authenticator…' })).toBeVisible();
85+
expect(screen.getByRole('button', { name: /Preparing authenticator/ })).toHaveFocus();
86+
87+
rerender(
88+
<MfaSetupDialog
89+
step='authenticator'
90+
authenticator={{ ...props, setup }}
91+
/>,
92+
);
93+
expect(screen.getByRole('dialog')).toBe(dialog);
94+
expect(screen.queryByRole('status', { name: 'Preparing authenticator…' })).not.toBeInTheDocument();
95+
expect(screen.getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible();
96+
expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus();
97+
expect(screen.getByRole('button', { name: 'Verify', exact: true })).toHaveAttribute('aria-disabled', 'true');
98+
await user.keyboard('{Enter}');
99+
expect(props.onSubmit).not.toHaveBeenCalled();
100+
rerender(
101+
<MfaSetupDialog
102+
step='authenticator'
103+
authenticator={{ ...props, setup, code: '123456' }}
104+
/>,
105+
);
106+
expect(props.onSubmit).toHaveBeenCalledExactlyOnceWith('123456');
107+
});
108+
109+
it('blocks incomplete and pending submissions, including native form submission', async () => {
110+
const user = userEvent.setup();
111+
const { props, rerender } = renderView({ code: '123' });
112+
const verify = screen.getByRole('button', { name: 'Verify', exact: true });
113+
expect(verify).toHaveAttribute('aria-disabled', 'true');
114+
await user.click(screen.getByRole('textbox', { name: 'Verification code' }));
115+
await user.keyboard('{Enter}');
116+
expect(props.onSubmit).not.toHaveBeenCalled();
117+
118+
rerender(
119+
<MfaSetupDialog
120+
step='authenticator'
121+
authenticator={{ ...props, code: '123456', isPending: true }}
122+
/>,
123+
);
124+
expect(verify).toHaveAttribute('aria-busy', 'true');
125+
expect(screen.getByRole('progressbar', { name: 'Verifying code' })).toBeInTheDocument();
126+
for (const slot of screen.getAllByRole('textbox')) {
127+
expect(slot).toBeDisabled();
128+
}
129+
expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled();
130+
await user.click(verify);
131+
const form = screen.getByRole('textbox', { name: 'Verification code' }).closest('form');
132+
if (!form) {
133+
throw new Error('Verification form missing');
134+
}
135+
form.requestSubmit();
136+
expect(props.onSubmit).not.toHaveBeenCalled();
137+
});
138+
139+
it('keeps the entered code when switching between QR and manual setup', async () => {
140+
const user = userEvent.setup();
141+
const onSubmit = vi.fn();
142+
render(<VerificationExample onSubmit={onSubmit} />);
143+
await user.click(screen.getByRole('textbox', { name: 'Verification code' }));
144+
await user.keyboard('123');
145+
await user.click(screen.getByRole('button', { name: 'Can’t scan? View setup key' }));
146+
await user.click(screen.getByRole('button', { name: 'Scan QR code instead' }));
147+
await user.click(screen.getByRole('textbox', { name: 'Character 4 of 6' }));
148+
await user.keyboard('456');
149+
expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456');
150+
});
151+
});

0 commit comments

Comments
 (0)