Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
21c9060
feat(ui): add passkey rename and removal dialogs
austincalvelage Sep 16, 2026
243827f
docs(swingset): demonstrate passkey management states
austincalvelage Sep 16, 2026
1646f9b
fix(ui): wire passkey actions through composed profile views
austincalvelage Sep 16, 2026
7601320
fix(swingset): sort passkey story imports in registry
kylemac Sep 16, 2026
785309e
refactor(ui): use fill for passkey messages
austincalvelage Sep 16, 2026
5558b89
fix(ui): align passkey rows with the design
austincalvelage Sep 16, 2026
abb0230
fix(ui): align passkeys with shared messages and color tokens
austincalvelage Sep 16, 2026
7a1fa4a
refactor(ui): move passkey rename state into a controller
austincalvelage Sep 16, 2026
98ed03f
fix(ui): align passkey visibility in the security panel
austincalvelage Sep 16, 2026
729e5d8
fix(ui): preserve legacy mosaic passkey section visibility
austincalvelage Sep 16, 2026
2d20753
docs(swingset): explain legacy passkey visibility
austincalvelage Sep 16, 2026
5f447cb
refactor(ui): remove unused passkey manage fallback
austincalvelage Sep 16, 2026
29e89b1
refactor(ui): colocate passkey styles with feature styles
austincalvelage Sep 16, 2026
5485f59
refactor(ui): let the panel control passkey section visibility
austincalvelage Sep 16, 2026
b36ed87
docs(swingset): describe passkey visibility directly
austincalvelage Sep 16, 2026
42b43a9
fix(ui): align passkey Add label and clarify satellite behavior
austincalvelage Sep 16, 2026
23382b8
fix(ui): align passkeys with updated dialog API on main
austincalvelage Sep 16, 2026
25191a7
docs(swingset): focus passkey examples on view behavior
austincalvelage Sep 16, 2026
7f59ccf
fix(ui): clear passkey rename errors when editing
austincalvelage Sep 16, 2026
a545ae2
fix(ui): keep passkey edit errors consistent with other rows
austincalvelage Sep 16, 2026
ab5a34c
fix(swingset): use extracted Mosaic package in passkey fixtures
austincalvelage Sep 17, 2026
2acbe54
docs(mosaic): describe passkey management improvements
austincalvelage Sep 17, 2026
7521704
fix(mosaic): align passkey imports with localization on main
austincalvelage Sep 17, 2026
bfe657d
style(swingset): format imports after rebase
austincalvelage Sep 17, 2026
3add645
fix(mosaic): restore focus after passkey removal
austincalvelage Sep 17, 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/calm-passkeys-views.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';

import { MosaicProvider } from '../../../MosaicProvider';
import type { UserProfilePasskey, UserProfilePasskeysSectionViewProps } from '../user-profile-passkeys-section.view';
import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';

const passkeys: UserProfilePasskey[] = [
{ id: 'laptop', name: 'MacBook' },
{ id: 'phone', name: 'iPhone' },
];

function renderView(overrides: Partial<UserProfilePasskeysSectionViewProps> = {}) {
const props: UserProfilePasskeysSectionViewProps = {
passkeys,
onAdd: vi.fn(),
onRename: vi.fn(),
onRemove: vi.fn(),
...overrides,
};
return render(
<MosaicProvider>
<UserProfilePasskeysSectionView {...props} />
</MosaicProvider>,
);
}

describe('passkeys section', () => {
it('shows existing passkeys without actions when their callbacks are unavailable', () => {
renderView({ onAdd: undefined, onRename: undefined, onRemove: undefined, sectionTitle: 'Authentication' });

expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible();
expect(screen.getByText('Passkeys')).toBeVisible();
expect(screen.getByText('MacBook')).toBeVisible();
expect(screen.getByText('iPhone')).toBeVisible();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('confirms the selected passkey and returns focus on cancellation', async () => {
const user = userEvent.setup();
const onRemove = vi.fn();
render(
<MosaicProvider>
<UserProfilePasskeysSectionView
passkeys={passkeys}
onRemove={onRemove}
/>
</MosaicProvider>,
);

await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription('MacBook will be removed from this account.');
expect(onRemove).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Manage MacBook' })).toHaveFocus();

await user.click(screen.getByRole('button', { name: 'Manage iPhone' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
const dialog = screen.getByRole('alertdialog');
expect(dialog).toHaveAccessibleDescription('iPhone will be removed from this account.');
await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true }));
expect(onRemove).toHaveBeenCalledExactlyOnceWith('phone');
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
});
it.each([
['MacBook', 'iPhone'],
['iPhone', 'MacBook'],
])('returns focus from removed %s to remaining %s', async (removed, remaining) => {
const user = userEvent.setup();
function Example() {
const [items, setItems] = useState(passkeys);
return (
<MosaicProvider>
<UserProfilePasskeysSectionView
passkeys={items}
onRemove={id => setItems(current => current.filter(item => item.id !== id))}
/>
</MosaicProvider>
);
}
render(<Example />);
await user.click(screen.getByRole('button', { name: `Manage ${removed}` }));
await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
await waitFor(() => expect(screen.getByRole('button', { name: `Manage ${remaining}` })).toHaveFocus());
});

it('renames through a prefilled form, preserves the draft after failure, and retries', async () => {
const user = userEvent.setup();
const onRename = vi.fn().mockRejectedValueOnce(new Error('Try again')).mockResolvedValueOnce(undefined);
render(
<MosaicProvider>
<UserProfilePasskeysSectionView
passkeys={passkeys}
onRename={onRename}
/>
</MosaicProvider>,
);
await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
const input = screen.getByRole('textbox', { name: 'Passkey name' });
expect(input).toHaveValue('MacBook');
await waitFor(() => expect(input).toHaveFocus());
expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-disabled', 'true');
await user.clear(input);
await user.type(input, 'Work laptop');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Try again');
expect(input).toHaveValue('Work laptop');
await user.type(input, ' updated');
expect(screen.getByRole('alert')).toHaveTextContent('Try again');
expect(input).toHaveValue('Work laptop updated');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(onRename).toHaveBeenLastCalledWith('laptop', 'Work laptop updated');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});

it('keeps an empty section visible when Add is unavailable', () => {
renderView({ passkeys: [], onAdd: undefined, sectionTitle: 'Authentication' });

expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible();
expect(screen.getByText('Passkeys')).toBeVisible();
expect(screen.getByText('No passkeys added')).toBeVisible();
expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument();
});

it('keeps Add available with an empty list and creation error', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
renderView({ passkeys: [], onAdd, addError: 'Could not create passkey' });

expect(screen.getByRole('alert')).toHaveTextContent('Could not create passkey');
expect(screen.getByRole('alert')).toHaveAttribute('data-open');
expect(screen.getByRole('alert')).toHaveAttribute('data-starting-style');
const addButton = screen.getByRole('button', { name: 'Add passkey' });
expect(addButton).toHaveTextContent(/^Add$/);
await user.click(addButton);
expect(onAdd).toHaveBeenCalledOnce();
});

it.each([true, false])('restores focus after removing the final passkey with Add available: %s', async hasAdd => {
const user = userEvent.setup();
let finish = () => {};
const pending = new Promise<void>(resolve => {
finish = resolve;
});
function Example() {
const [items, setItems] = useState([passkeys[0]]);
return (
<MosaicProvider>
<UserProfilePasskeysSectionView
passkeys={items}
onAdd={hasAdd ? () => setItems([passkeys[0]]) : undefined}
onRemove={async () => {
await pending;
setItems([]);
}}
/>
</MosaicProvider>
);
}
render(<Example />);
await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
expect(screen.getByRole('button', { name: 'Remove', exact: true })).toHaveAttribute('aria-busy', 'true');
await act(async () => {
finish();
await pending;
});
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
expect(screen.queryByText('MacBook')).not.toBeInTheDocument();
if (hasAdd) {
expect(screen.getByRole('button', { name: 'Add passkey' })).toHaveFocus();
await user.click(screen.getByRole('button', { name: 'Add passkey' }));
expect(screen.getByText('MacBook')).toBeVisible();
} else {
expect(screen.getByRole('region', { name: 'Passkeys' })).toHaveFocus();
}
});

it('retries removal for the same passkey after a failure', async () => {
const user = userEvent.setup();
const onRemove = vi.fn().mockRejectedValueOnce(new Error('Removal failed')).mockResolvedValueOnce(undefined);
render(
<MosaicProvider>
<UserProfilePasskeysSectionView
passkeys={passkeys}
onRemove={onRemove}
/>
</MosaicProvider>,
);
await user.click(screen.getByRole('button', { name: 'Manage iPhone' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
expect(await screen.findByRole('alert')).toHaveTextContent('Removal failed');
await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
expect(onRemove.mock.calls).toEqual([['phone'], ['phone']]);
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
});
it('preserves literal passkey names in menu labels and removal copy', async () => {
const user = userEvent.setup();
render(
<MosaicProvider>
<UserProfilePasskeysSectionView
passkeys={[{ id: 'special', name: '$& laptop' }]}
onRemove={vi.fn()}
/>
</MosaicProvider>,
);
await user.click(screen.getByRole('button', { name: 'Manage $& laptop' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription('$& laptop will be removed from this account.');
});

it('keeps rename pending until saving finishes', async () => {
const user = userEvent.setup();
let finish = () => {};
const onRename = vi.fn(
() =>
new Promise<void>(resolve => {
finish = resolve;
}),
);
render(
<MosaicProvider>
<UserProfilePasskeysSectionView
passkeys={passkeys}
onRename={onRename}
/>
</MosaicProvider>,
);
await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
const input = screen.getByRole('textbox', { name: 'Passkey name' });
await user.clear(input);
await user.type(input, 'Work laptop');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(input).toBeDisabled();
expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-busy', 'true');
await user.keyboard('{Escape}');
expect(screen.getByRole('dialog')).toBeInTheDocument();
await act(() => finish());
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(onRename).toHaveBeenCalledExactlyOnceWith('laptop', 'Work laptop');
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import { createDeferredPromise } from '@clerk/shared/utils';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';

Expand Down Expand Up @@ -77,7 +78,7 @@ describe('UserProfileSecurityPanelView', () => {

it('forwards security actions', async () => {
const onAddPasskey = vi.fn();
const onManagePasskey = vi.fn();
const onRenamePasskey = vi.fn(() => Promise.resolve());
const onRemovePasskey = vi.fn();
const onAddMfaMethod = vi.fn();
const onSignOutDevice = vi.fn();
Expand All @@ -91,7 +92,7 @@ describe('UserProfileSecurityPanelView', () => {
{ id: 'backup_1', type: 'backup-codes' },
],
onAddPasskey,
onManagePasskey,
onRenamePasskey,
onRemovePasskey,
onAddMfaMethod,
onSignOutDevice,
Expand All @@ -107,8 +108,15 @@ describe('UserProfileSecurityPanelView', () => {

await user.click(screen.getByRole('button', { name: 'Manage Passkey' }));
await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
const passkeyName = screen.getByRole('textbox', { name: 'Passkey name' });
await user.clear(passkeyName);
await user.type(passkeyName, 'Work laptop');
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'Manage Passkey' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove', exact: true }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());

const otherDevices = screen.getByRole('region', { name: 'Other devices' });
await user.click(within(otherDevices).getByRole('button', { name: 'Manage Safari on iOS' }));
Expand All @@ -123,7 +131,7 @@ describe('UserProfileSecurityPanelView', () => {
await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' }));

expect(onAddPasskey).toHaveBeenCalledOnce();
expect(onManagePasskey).toHaveBeenCalledWith('passkey_1');
expect(onRenamePasskey).toHaveBeenCalledWith('passkey_1', 'Work laptop');
expect(onRemovePasskey).toHaveBeenCalledWith('passkey_1');
expect(onAddMfaMethod).toHaveBeenCalledWith('authenticator');
expect(onSignOutDevice).toHaveBeenCalledWith('mobile');
Expand Down Expand Up @@ -158,6 +166,67 @@ describe('UserProfileSecurityPanelView', () => {
expect(screen.queryByRole('menuitem', { name: 'Sign out' })).not.toBeInTheDocument();
});

it('keeps the authentication heading on MFA when existing passkeys are hidden', () => {
renderView({
hasPassword: false,
passkeysVisible: false,
onAddPasskey: vi.fn(),
onRenamePasskey: vi.fn(),
onRemovePasskey: vi.fn(),
});

expect(screen.queryByText('Passkeys')).not.toBeInTheDocument();
expect(screen.queryByText('Passkey')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible();
expect(screen.getByText('2-step verification')).toBeVisible();
});

it('keeps one authentication heading when passkeys are empty and Add is unavailable', () => {
renderView({ hasPassword: false, passkeys: [], onAddPasskey: undefined });

const section = screen.getByRole('region', { name: 'Authentication' });
expect(within(section).getByText('Passkeys')).toBeVisible();
expect(within(section).getByText('No passkeys added')).toBeVisible();
expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible();
expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument();
expect(screen.getByRole('region', { name: '2-step verification' })).toBeVisible();
});

it('keeps the empty section and final passkey confirmation mounted without Add', async () => {
const user = userEvent.setup();
const removal = createDeferredPromise();
const onRemovePasskey = vi.fn(async () => {
await removal.promise;
});
const { rerender } = renderView({ hasPassword: false, mfaMethods: undefined, onRemovePasskey });

await user.click(screen.getByRole('button', { name: 'Manage Passkey' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }));

rerender(
<MosaicProvider>
<UserProfileSecurityPanelView
passkeys={[]}
onRemovePasskey={onRemovePasskey}
/>
</MosaicProvider>,
);
expect(screen.getByText('Authentication')).toBeInTheDocument();
expect(screen.getByRole('alertdialog', { name: 'Remove passkey' })).toBeVisible();

await act(async () => {
removal.resolve();
await removal.promise;
});
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible();
expect(screen.getByText('Passkeys')).toBeVisible();
expect(screen.getByText('No passkeys added')).toBeVisible();
expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument();
});

it('only shows backup codes with another verification method and only allows regeneration', async () => {
const onRegenerateBackupCodes = vi.fn();
const onRemoveMfaMethod = vi.fn();
Expand Down
Loading
Loading