Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4196e4f
feat(ui): add MFA row capabilities and default badges
austincalvelage Sep 16, 2026
4c2ae3e
docs(swingset): demonstrate MFA row capabilities
austincalvelage Sep 16, 2026
b5544e3
feat(ui): confirm MFA method removal
austincalvelage Sep 16, 2026
c4879a0
docs(swingset): demonstrate MFA removal and retry
austincalvelage Sep 16, 2026
d779090
feat(ui): show MFA default change progress and errors
austincalvelage Sep 16, 2026
9003f35
docs(swingset): demonstrate MFA default change retry
austincalvelage Sep 16, 2026
53db5d1
feat(ui): add MFA method picker
austincalvelage Sep 16, 2026
41a72fb
docs(swingset): demonstrate MFA method selection
austincalvelage Sep 16, 2026
4ef98cc
refactor(ui): align MFA picker with reverification options
austincalvelage Sep 16, 2026
fe65693
feat(ui): add authenticator QR and manual setup view
austincalvelage Sep 16, 2026
f9653ae
docs(swingset): demonstrate authenticator setup
austincalvelage Sep 16, 2026
b1d1127
feat(ui): add authenticator code verification dialog
austincalvelage Sep 16, 2026
b859995
docs(swingset): demonstrate authenticator verification retry
austincalvelage Sep 16, 2026
839e645
feat(ui): reuse Mosaic phone steps for SMS verification
austincalvelage Sep 17, 2026
e06b3d2
docs(swingset): demonstrate SMS setup and verification
austincalvelage Sep 17, 2026
894a1ed
fix(mosaic): adapt MFA setup to extracted package
austincalvelage Sep 17, 2026
fb5f11d
docs(mosaic): describe two-step verification improvements
austincalvelage Sep 17, 2026
6fda4da
refactor(mosaic): avoid allocating empty MFA method defaults
austincalvelage Sep 17, 2026
29bbfff
feat(mosaic): add backup codes and connect MFA setup flow
austincalvelage Sep 17, 2026
237da3b
fix(mosaic): restore MFA setup focus to Add button
austincalvelage Sep 17, 2026
c3cf5ce
fix(mosaic): refine MFA backup code setup and examples
austincalvelage Sep 17, 2026
eae79ca
refactor(mosaic): extract reusable MFA setup views and align behavior
austincalvelage Sep 17, 2026
0ac7087
refactor(swingset): share MFA flows and simplify examples
austincalvelage Sep 17, 2026
051a41f
fix(swingset): reuse MFA flows in Security and Profile examples
austincalvelage Sep 17, 2026
fb3ba18
test(mosaic): remove redundant MFA coverage and unused fixture
austincalvelage Sep 17, 2026
e0acbdf
refactor(mosaic): align MFA default action with phone and email
austincalvelage Sep 17, 2026
e718268
fix(mosaic): localize MFA views
austincalvelage Sep 17, 2026
a0ec923
fix(mosaic): focus primary backup code actions
austincalvelage Sep 17, 2026
66370ea
chore(mosaic): account for MFA localization in bundle budget
austincalvelage Sep 17, 2026
9672458
refactor(mosaic): share MFA setup flow and simplify dialog examples
austincalvelage Sep 18, 2026
bdecd18
test(mosaic): isolate MFA picker interaction in security panel
austincalvelage Sep 18, 2026
0bba99d
fix(test):Remove redundant phone input check
austincalvelage Sep 18, 2026
687514d
refactor(mosaic): remove standalone MFA dialogs
austincalvelage Sep 18, 2026
9684fd7
fix(mosaic): focus authenticator code when setup is ready
austincalvelage Sep 18, 2026
ea6c3b1
fix(mosaic): return MFA setup to method picker on Back
austincalvelage Sep 18, 2026
0d78404
fix(mosaic): use pulse easing token and preview backup-code loading
austincalvelage Sep 18, 2026
c556e04
fix(mosaic): keep authenticator copy feedback within each field
austincalvelage Sep 18, 2026
40eb4be
fix(mosaic): make backup-code recovery wording method-neutral
austincalvelage Sep 18, 2026
c959bcc
Merge branch 'main' into austin/mfa-section
austincalvelage Sep 18, 2026
0473adc
Merge branch 'main' into austin/mfa-section
austincalvelage Sep 18, 2026
572907f
Merge branch 'austin/mfa-section' of https://github.com/clerk/javascr…
austincalvelage Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/quiet-mfa-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
12 changes: 10 additions & 2 deletions .claude/skills/mosaic/references/motion.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Motion: entrances and exits
# Motion: entrances, exits, and pulses

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

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

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

## Repeating pulses

Use `--cl-ease-pulse` for repeating opacity fades such as loading skeletons. Its
symmetric curve slows at both ends of each fade, keeping the reversal smooth.
Keep the pulse duration on the component and disable the animation under
`prefers-reduced-motion: reduce`.

## A curve has a direction — don't run the entrance curve backwards

The single most common motion bug in this codebase. `--cl-ease-default` is
Expand Down
2 changes: 1 addition & 1 deletion packages/mosaic/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"files": [
{ "path": "./dist/index.js", "maxSize": "123KB" },
{ "path": "./dist/index.js", "maxSize": "125KB" },
{ "path": "./dist/styles.css", "maxSize": "12KB" }
]
}
1 change: 1 addition & 0 deletions packages/mosaic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"bundlewatch": "^0.4.2",
"qrcode.react": "4.2.0",
"react": "catalog:react",
"react-dom": "catalog:react",
"tsdown": "catalog:repo"
Expand Down
16 changes: 0 additions & 16 deletions packages/mosaic/src/components/phone-input/phone-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,6 @@ describe('Mosaic PhoneInput', () => {
expect(ref).toHaveBeenLastCalledWith(null);
});

it('keeps the country indicator on the selection while hovering another country', async () => {
const user = userEvent.setup();
render(<PhoneInput aria-label='Phone number' />);
await user.click(screen.getByRole('button', { name: 'Country, United States' }));
const us = screen.getByRole('option', { name: /United States/ });
const uk = screen.getByRole('option', { name: /United Kingdom/ });
await user.hover(uk);
expect(us.querySelector('.cl-combobox-option-indicator')).toBeVisible();
expect(uk.querySelector('.cl-combobox-option-indicator')).not.toBeInTheDocument();
await user.click(uk);
await user.click(screen.getByRole('button', { name: 'Country, United Kingdom' }));
expect(
screen.getByRole('option', { name: /United Kingdom/ }).querySelector('.cl-combobox-option-indicator'),
).toBeVisible();
});

it('positions the country popup against the full phone field', async () => {
const user = userEvent.setup();
render(<PhoneInput aria-label='Phone number' />);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { vi } from 'vitest';

import { MosaicProvider } from '../../../MosaicProvider';
import { UserProfileAddMfaDialog } from '../user-profile-add-mfa.dialog';
import type { UserProfileMfaSetupViewProps } from '../user-profile-mfa-setup.view';
import { UserProfileMfaSetupView } from '../user-profile-mfa-setup.view';

export function MfaSetupDialog(props: Partial<UserProfileMfaSetupViewProps>) {
return (
<MosaicProvider>
<UserProfileAddMfaDialog
open
onOpenChange={vi.fn()}
>
<UserProfileMfaSetupView
step='select'
methods={['sms', 'authenticator', 'backup-codes']}
onSelect={vi.fn()}
onBack={vi.fn()}
onCancel={vi.fn()}
authenticator={{
onRetry: vi.fn(),
code: '',
onCodeChange: vi.fn(),
onSubmit: vi.fn(),
}}
sms={{
step: 'select',
phoneNumbers: [],
selectedPhoneId: '',
onSelectedPhoneIdChange: vi.fn(),
onAddPhone: vi.fn(),
onBack: vi.fn(),
phoneNumber: '',
onPhoneNumberChange: vi.fn(),
code: '',
onCodeChange: vi.fn(),
onSubmit: vi.fn(),
onResend: vi.fn(),
}}
backupCodes={{
codes: [],
onRetry: vi.fn(),
onCopy: vi.fn(),
onDownload: vi.fn(),
}}
{...props}
/>
</UserProfileAddMfaDialog>
</MosaicProvider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';

import type { UserProfileMfaSetupViewProps } from '../user-profile-mfa-setup.view';
import { MfaSetupDialog } from './mfa-test-utils';

type ViewProps = UserProfileMfaSetupViewProps['authenticator'];

const setup = {
secret: 'JBSWY3DPEHPK3PXP',
uri: 'otpauth://totp/Swingset:demo@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Swingset',
};

function renderView(overrides: Partial<ViewProps> = {}, step: UserProfileMfaSetupViewProps['step'] = 'authenticator') {
const props: ViewProps = {
setup,
onRetry: vi.fn(),
code: '',
onCodeChange: vi.fn(),
onSubmit: vi.fn(),
...overrides,
};
return {
props,
...render(
<MfaSetupDialog
step={step}
authenticator={props}
/>,
),
};
}

function VerificationExample({ onSubmit }: Pick<ViewProps, 'onSubmit'>) {
const [code, setCode] = useState('');
return (
<MfaSetupDialog
step='authenticator'
authenticator={{ setup, onRetry: () => undefined, code, onCodeChange: setCode, onSubmit }}
/>
);
}

describe('UserProfileAddAuthenticatorView', () => {
it('shows preparation, offers retry on failure, and waits for setup data before verification', async () => {
const user = userEvent.setup();
const { props, rerender } = renderView({ setup: undefined }, 'select');
rerender(
<MfaSetupDialog
step='authenticator'
authenticator={props}
/>,
);
const dialog = screen.getByRole('dialog', { name: 'Add an authenticator app' });
expect(screen.getByRole('status', { name: 'Preparing authenticator…' })).toBeVisible();
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Verify', exact: true })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Back' })).toBeEnabled();
expect(screen.getByRole('button', { name: /Preparing authenticator/ })).toHaveFocus();

rerender(
<MfaSetupDialog
step='authenticator'
authenticator={{ ...props, setupErrorMessage: 'Unable to prepare your authenticator.' }}
/>,
);
expect(screen.getByRole('alert')).toHaveTextContent('Unable to prepare your authenticator.');
expect(screen.queryByRole('status', { name: 'Preparing authenticator…' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Try again' })).toHaveFocus();
await user.click(screen.getByRole('button', { name: 'Try again' }));
expect(props.onRetry).toHaveBeenCalledOnce();
expect(props.onSubmit).not.toHaveBeenCalled();

rerender(
<MfaSetupDialog
step='authenticator'
authenticator={props}
/>,
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByRole('status', { name: 'Preparing authenticator…' })).toBeVisible();
expect(screen.getByRole('button', { name: /Preparing authenticator/ })).toHaveFocus();

rerender(
<MfaSetupDialog
step='authenticator'
authenticator={{ ...props, setup }}
/>,
);
expect(screen.getByRole('dialog')).toBe(dialog);
expect(screen.queryByRole('status', { name: 'Preparing authenticator…' })).not.toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Authenticator setup QR code' })).toBeVisible();
expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus();
expect(screen.getByRole('button', { name: 'Verify', exact: true })).toHaveAttribute('aria-disabled', 'true');
await user.keyboard('{Enter}');
expect(props.onSubmit).not.toHaveBeenCalled();
rerender(
<MfaSetupDialog
step='authenticator'
authenticator={{ ...props, setup, code: '123456' }}
/>,
);
expect(props.onSubmit).toHaveBeenCalledExactlyOnceWith('123456');
});

it('blocks incomplete and pending submissions, including native form submission', async () => {
const user = userEvent.setup();
const { props, rerender } = renderView({ code: '123' });
const verify = screen.getByRole('button', { name: 'Verify', exact: true });
expect(verify).toHaveAttribute('aria-disabled', 'true');
await user.click(screen.getByRole('textbox', { name: 'Verification code' }));
await user.keyboard('{Enter}');
expect(props.onSubmit).not.toHaveBeenCalled();

rerender(
<MfaSetupDialog
step='authenticator'
authenticator={{ ...props, code: '123456', isPending: true }}
/>,
);
expect(verify).toHaveAttribute('aria-busy', 'true');
expect(screen.getByRole('progressbar', { name: 'Verifying code' })).toBeInTheDocument();
for (const slot of screen.getAllByRole('textbox')) {
expect(slot).toBeDisabled();
}
expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled();
await user.click(verify);
const form = screen.getByRole('textbox', { name: 'Verification code' }).closest('form');
if (!form) {
throw new Error('Verification form missing');
}
form.requestSubmit();
expect(props.onSubmit).not.toHaveBeenCalled();
});

it('keeps the entered code when switching between QR and manual setup', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<VerificationExample onSubmit={onSubmit} />);
await user.click(screen.getByRole('textbox', { name: 'Verification code' }));
await user.keyboard('123');
await user.click(screen.getByRole('button', { name: 'Can’t scan? View setup key' }));
await user.click(screen.getByRole('button', { name: 'Scan QR code instead' }));
await user.click(screen.getByRole('textbox', { name: 'Character 4 of 6' }));
await user.keyboard('456');
expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456');
});
});
Loading
Loading