From 21c906028863abeaeb3117d08d2c034f6e1fa2c7 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 00:19:06 -0600 Subject: [PATCH 01/25] feat(ui): add passkey rename and removal dialogs --- .changeset/calm-passkeys-views.md | 2 + ...ser-profile-passkeys-section.view.test.tsx | 195 ++++++++++++++++++ .../user-profile-security-panel.view.test.tsx | 2 + .../user-profile-passkey-row.view.tsx | 98 +++++++++ .../user-profile-passkeys-section.messages.ts | 17 ++ .../user-profile-passkeys-section.view.tsx | 123 +++++++---- .../user-profile-rename-passkey.dialog.tsx | 116 +++++++++++ 7 files changed, 509 insertions(+), 44 deletions(-) create mode 100644 .changeset/calm-passkeys-views.md create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx diff --git a/.changeset/calm-passkeys-views.md b/.changeset/calm-passkeys-views.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/calm-passkeys-views.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx new file mode 100644 index 00000000000..9266d3fdf6b --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx @@ -0,0 +1,195 @@ +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 { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view'; + +const passkeys = [ + { id: 'laptop', name: 'MacBook' }, + { id: 'phone', name: 'iPhone' }, +]; + +describe('passkeys section', () => { + it('confirms the selected passkey and returns focus on cancellation', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Manage MacBook' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' })); + expect(screen.getByRole('dialog')).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('dialog')).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('dialog'); + 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('dialog')).not.toBeInTheDocument()); + }); + 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( + + + , + ); + 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.click(screen.getByRole('button', { name: 'Save' })); + expect(onRename).toHaveBeenLastCalledWith('laptop', 'Work laptop'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('hides an unavailable section and keeps Add available with an empty list or creation error', async () => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + const { rerender } = render( + , + ); + expect(screen.queryByRole('heading', { name: 'Authentication' })).not.toBeInTheDocument(); + expect(screen.queryByText('Passkeys')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByRole('alert')).toHaveTextContent('Could not create passkey'); + await user.click(screen.getByRole('button', { name: 'Add passkey' })); + expect(onAdd).toHaveBeenCalledOnce(); + }); + + it('finishes removing the final passkey after pending work and keeps Add available', async () => { + const user = userEvent.setup(); + let finish = () => {}; + const pending = new Promise(resolve => { + finish = resolve; + }); + function Example() { + const [items, setItems] = useState([passkeys[0]]); + return ( + + setItems([passkeys[0]])} + onRemove={async () => { + await pending; + setItems([]); + }} + /> + + ); + } + render(); + 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('dialog')).not.toBeInTheDocument()); + expect(screen.queryByText('MacBook')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Add passkey' })); + expect(screen.getByText('MacBook')).toBeVisible(); + }); + + 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( + + + , + ); + 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('dialog')).not.toBeInTheDocument()); + }); + it('preserves literal passkey names in menu labels and removal copy', async () => { + const user = userEvent.setup(); + render( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Manage $& laptop' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' })); + expect(screen.getByRole('dialog')).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(resolve => { + finish = resolve; + }), + ); + render( + + + , + ); + 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'); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 1d3d5109cdd..69ec7ff3cca 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -109,6 +109,8 @@ describe('UserProfileSecurityPanelView', () => { await user.click(screen.getByRole('menuitem', { name: 'Rename' })); await user.click(screen.getByRole('button', { name: 'Manage Passkey' })); await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove', exact: true })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); const otherDevices = screen.getByRole('region', { name: 'Other devices' }); await user.click(within(otherDevices).getByRole('button', { name: 'Manage Safari on iOS' })); diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx new file mode 100644 index 00000000000..ed79eb11400 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx @@ -0,0 +1,98 @@ +import { useMemo, useState } from 'react'; + +import { Dialog } from '../../components/dialog'; +import { Section } from '../../components/section'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; +import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; +import { UserProfileRenamePasskeyDialog } from './user-profile-rename-passkey.dialog'; +import { UserProfileSecurityIcon } from './user-profile-security-icon'; + +export function UserProfilePasskeyRowView({ + passkey, + onRename, + onManage, + onRemove, +}: { + passkey: UserProfilePasskey; + onRename?: (id: string, name: string) => void | Promise; + onManage?: (id: string) => void; + onRemove?: () => void; +}) { + const renameDialog = useMemo(() => Dialog.createHandle(), []); + const [open, setOpen] = useState(false); + const [name, setName] = useState(passkey.name); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(); + + const onOpenChange = (nextOpen: boolean) => { + if (isSaving) { + return; + } + if (nextOpen) { + setName(passkey.name); + setError(undefined); + } + setOpen(nextOpen); + }; + const onSubmit = async () => { + if (!onRename || isSaving || name.length < 2 || name === passkey.name) { + return; + } + setIsSaving(true); + setError(undefined); + try { + await onRename(passkey.id, name); + setOpen(false); + } catch (cause) { + setError(cause instanceof Error ? cause.message : m.saveError); + } finally { + setIsSaving(false); + } + }; + + const actions: UserProfileMenuAction[] = []; + if (onRename) { + actions.push({ label: m.rename, onClick: () => renameDialog.open(undefined) }); + } else if (onManage) { + actions.push({ label: m.rename, onClick: () => onManage(passkey.id) }); + } + if (onRemove) { + actions.push({ label: m.removeAction, color: 'negative', onClick: onRemove }); + } + + return ( + <> + + + + {passkey.name} + {passkey.createdAtLabel ? {passkey.createdAtLabel} : null} + {passkey.lastUsedAtLabel ? {passkey.lastUsedAtLabel} : null} + + {actions.length > 0 ? ( + + passkey.name)} + /> + + ) : null} + + {onRename ? ( + 1 && name !== passkey.name} + onSubmit={() => void onSubmit()} + /> + ) : null} + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts new file mode 100644 index 00000000000..aade8051ad5 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts @@ -0,0 +1,17 @@ +export const userProfilePasskeysMessages = { + label: 'Passkeys', + add: 'Add passkey', + empty: 'No passkeys added', + manage: 'Manage {name}', + rename: 'Rename', + renameTitle: 'Rename passkey', + renameDescription: 'You can change the passkey name to make it easier to find.', + nameLabel: 'Passkey name', + save: 'Save', + cancel: 'Cancel', + saveError: 'Something went wrong. Please try again.', + removeAction: 'Remove passkey', + removeTitle: 'Remove passkey', + removeDescription: '{name} will be removed from this account.', + remove: 'Remove', +}; diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx index d9f744fbe11..079176a671b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx @@ -1,8 +1,11 @@ +import { useMemo } from 'react'; + +import { Confirmation } from '../../blocks/confirmation'; +import { Button } from '../../components/button'; +import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; -import type { UserProfileMenuAction } from './user-profile-action-menu'; -import { UserProfileActionMenu } from './user-profile-action-menu'; -import { UserProfileSecurityIcon } from './user-profile-security-icon'; -import { UserProfileSecurityList } from './user-profile-security-list'; +import { UserProfilePasskeyRowView } from './user-profile-passkey-row.view'; +import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; export interface UserProfilePasskey { id: string; @@ -15,56 +18,88 @@ export interface UserProfilePasskeysSectionViewProps { passkeys: UserProfilePasskey[]; sectionTitle?: string; onAdd?: () => void; + addError?: string; + onRename?: (id: string, name: string) => void | Promise; onManage?: (id: string) => void; - onRemove?: (id: string) => void; + onRemove?: (id: string) => void | Promise; } export function UserProfilePasskeysSectionView({ passkeys, sectionTitle, onAdd, + addError, + onRename, onManage, onRemove, }: UserProfilePasskeysSectionViewProps) { - return ( - 0} - label='Passkeys' - sectionTitle={sectionTitle} - onAdd={onAdd} - > - {passkeys.map(passkey => { - const actions: UserProfileMenuAction[] = []; + const removePasskey = useMemo(() => Confirmation.createHandle(), []); - if (onManage) { - actions.push({ label: 'Rename', onClick: () => onManage(passkey.id) }); - } - if (onRemove) { - actions.push({ label: 'Remove passkey', color: 'negative', onClick: () => onRemove(passkey.id) }); - } - - return ( - - - - {passkey.name} - {passkey.createdAtLabel || passkey.lastUsedAtLabel ? ( - - {[passkey.createdAtLabel, passkey.lastUsedAtLabel].filter(Boolean).join(' · ')} - - ) : null} - - - - - - ); - })} - + return ( + <> + {passkeys.length > 0 || onAdd ? ( + + {sectionTitle ? {sectionTitle} : null} + + + + + {m.label} + + {onAdd ? ( + + + + ) : null} + + {addError ? {addError} : null} + {passkeys.length > 0 ? ( + + {passkeys.map(passkey => ( + removePasskey.open(passkey) : undefined} + /> + ))} + + ) : ( + + + + {m.empty} + + + + )} + + + + ) : null} + {onRemove ? ( + m.removeDescription.replace('{name}', () => passkey.name)} + actionLabel={m.remove} + onConfirm={passkey => onRemove(passkey.id)} + /> + ) : null} + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx new file mode 100644 index 00000000000..498fbde019d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx @@ -0,0 +1,116 @@ +import { useId, useRef } from 'react'; + +import { Banner } from '../../components/banner'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import type { DialogHandle } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Input } from '../../components/input'; +import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; + +export interface UserProfileRenamePasskeyDialogProps { + handle: DialogHandle; + open: boolean; + onOpenChange: (open: boolean) => void; + name: string; + onNameChange: (name: string) => void; + canSave: boolean; + isSaving?: boolean; + error?: string; + onSubmit: () => void; +} + +export function UserProfileRenamePasskeyDialog({ + handle, + open, + onOpenChange, + name, + onNameChange, + canSave, + isSaving = false, + error, + onSubmit, +}: UserProfileRenamePasskeyDialogProps) { + const formId = useId(); + const inputRef = useRef(null); + return ( + + + + + {m.renameTitle} + {m.renameDescription} + + { + event.preventDefault(); + if (canSave && !isSaving) { + onSubmit(); + } + }} + /> + } + > + {error ? ( + + {error} + + ) : null} + + {m.nameLabel} + onNameChange(event.target.value)} + /> + + + + + {m.cancel} + + } + /> + + {m.save} + + + + + + ); +} From 243827f3ec0c12ac38ba5f8cbe9a409ae8ae8beb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 00:19:22 -0600 Subject: [PATCH 02/25] docs(swingset): demonstrate passkey management states --- packages/swingset/src/lib/registry.ts | 2 + .../stories/user-profile-passkeys-section.mdx | 49 ++++++++--- .../user-profile-passkeys-section.stories.tsx | 82 ++++++++++++------- 3 files changed, 89 insertions(+), 44 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 0cf1cd1eda3..b0acccd1552 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -304,6 +304,7 @@ import { import { Default as UserProfilePasskeysSectionDefault, Empty as UserProfilePasskeysSectionEmpty, + RecoverableErrors as UserProfilePasskeysSectionRecoverableErrors, meta as userProfilePasskeysSectionMeta, } from '../stories/user-profile-passkeys-section.stories'; import { @@ -651,6 +652,7 @@ const userProfilePasskeysSectionModule: StoryModule = { meta: userProfilePasskeysSectionMeta, Default: UserProfilePasskeysSectionDefault, Empty: UserProfilePasskeysSectionEmpty, + RecoverableErrors: UserProfilePasskeysSectionRecoverableErrors, }; const userProfileMfaSectionModule: StoryModule = { meta: userProfileMfaSectionMeta, diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.mdx b/packages/swingset/src/stories/user-profile-passkeys-section.mdx index b4ba13cebb8..2602f696c6b 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.mdx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.mdx @@ -2,16 +2,39 @@ import * as Stories from './user-profile-passkeys-section.stories'; # UserProfilePasskeysSection -Passkey management composed with the shared Security list treatment. - - - - +Manage passkey names and confirm removal. These examples mock creation; browser and authenticator interactions belong to the controller. + +## Playground + + + +## Props + +| Prop | Type | Default | Description | +| -------------- | ----------------------------------------------------- | ------------ | ---------------------------------------------------------------- | +| `passkeys` | `UserProfilePasskey[]` | — (required) | Names and formatted creation/last-used labels. | +| `sectionTitle` | `string` | — | Optional surrounding section heading. | +| `onAdd` | `() => void` | — | Shows Add when supplied. | +| `addError` | `string` | — | Inline creation error. | +| `onRename` | `(id: string, name: string) => void \| Promise` | — | Resolve to close; reject with an Error to display the failure. | +| `onRemove` | `(id: string) => void \| Promise` | — | Shared confirmation awaits removal and displays rejected errors. | + +## Usage + +### No passkeys yet + +Add remains available after removing the final passkey. With neither passkeys nor an Add callback, the section is hidden. + + + +### Recoverable errors + +The first Add, Rename, and Remove attempt fails. Retry completes the action; saving and removal use a short simulated delay. + + diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx index 42e928ae9dd..dd6fce632bf 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx @@ -1,6 +1,7 @@ import type { UserProfilePasskey } from '@clerk/mosaic/features/user-profile/user-profile-passkeys-section.view'; import { UserProfilePasskeysSectionView } from '@clerk/mosaic/features/user-profile/user-profile-passkeys-section.view'; -import { useState } from 'react'; +import type { ReactElement } from 'react'; +import { useRef, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -15,46 +16,65 @@ export const meta: StoryMeta = { source: 'packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx', }; -export function Default() { - const [passkeys, setPasskeys] = useState([ - { - id: 'passkey', - name: 'Passkey', - createdAtLabel: 'Created today at 10:12 PM', - lastUsedAtLabel: 'Last used 1h ago', - }, - ]); +function PasskeysExample({ empty = false, failOnce = false }: { empty?: boolean; failOnce?: boolean }): ReactElement { + const [passkeys, setPasskeys] = useState( + empty + ? [] + : [ + { id: 'laptop', name: 'MacBook', createdAtLabel: 'Created today', lastUsedAtLabel: 'Last used 1 hour ago' }, + { id: 'phone', name: 'iPhone', createdAtLabel: 'Created yesterday' }, + ], + ); + const [addError, setAddError] = useState(); + const failures = useRef(new Set()); + const nextId = useRef(1); + + const attempt = async (action: string) => { + await new Promise(resolve => setTimeout(resolve, 600)); + if (failOnce && !failures.current.has(action)) { + failures.current.add(action); + throw new Error('Something went wrong. Please try again.'); + } + }; return ( + addError={addError} + onAdd={() => { + if (failOnce && !failures.current.has('add')) { + failures.current.add('add'); + setAddError('Could not create a passkey. Please try again.'); + return; + } + setAddError(undefined); + const id = nextId.current++; setPasskeys(current => [ ...current, - { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, - ]) - } - onManage={() => undefined} - onRemove={id => setPasskeys(current => current.filter(passkey => passkey.id !== id))} + { id: `new-${id}`, name: `New passkey ${id}`, createdAtLabel: 'Created just now' }, + ]); + }} + onRename={async (id, name) => { + await attempt('rename'); + setPasskeys(current => current.map(passkey => (passkey.id === id ? { ...passkey, name } : passkey))); + }} + onRemove={async id => { + await attempt('remove'); + setPasskeys(current => current.filter(passkey => passkey.id !== id)); + }} /> ); } -export function Empty() { - const [passkeys, setPasskeys] = useState([]); +export function Default(): ReactElement { + return ; +} - return ( - - setPasskeys(current => [ - ...current, - { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, - ]) - } - onRemove={id => setPasskeys(current => current.filter(passkey => passkey.id !== id))} - /> - ); +export function Empty(): ReactElement { + return ; +} + +export function RecoverableErrors(): ReactElement { + return ; } From 1646f9b249a819d2d4427439aec3a672aeac07bf Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 00:25:46 -0600 Subject: [PATCH 03/25] fix(ui): wire passkey actions through composed profile views --- .../user-profile-security-panel.view.test.tsx | 11 +++- .../user-profile-security-panel.view.tsx | 8 ++- .../stories/fixtures/user-profile-passkeys.ts | 61 +++++++++++++++++++ .../src/stories/fixtures/user-profile.ts | 24 +++----- .../user-profile-passkeys-section.stories.tsx | 49 ++------------- .../user-profile-security-panel.stories.tsx | 25 +++----- 6 files changed, 94 insertions(+), 84 deletions(-) create mode 100644 packages/swingset/src/stories/fixtures/user-profile-passkeys.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 69ec7ff3cca..ffdbc5646fe 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -77,7 +77,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(); @@ -91,7 +91,7 @@ describe('UserProfileSecurityPanelView', () => { { id: 'backup_1', type: 'backup-codes' }, ], onAddPasskey, - onManagePasskey, + onRenamePasskey, onRemovePasskey, onAddMfaMethod, onSignOutDevice, @@ -107,6 +107,11 @@ 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('dialog')).getByRole('button', { name: 'Remove', exact: true })); @@ -125,7 +130,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'); diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 526c14eeb83..a4bceb30cfb 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -41,8 +41,10 @@ export interface UserProfileSecurityPanelViewProps mfaMethods?: UserProfileMfaMethod[]; devices?: UserProfileDevice[]; onAddPasskey?: () => void; + addPasskeyError?: string; + onRenamePasskey?: (id: string, name: string) => void | Promise; onManagePasskey?: (id: string) => void; - onRemovePasskey?: (id: string) => void; + onRemovePasskey?: (id: string) => void | Promise; onAddMfaMethod?: (type: UserProfileMfaAddableMethod) => void; onRegenerateBackupCodes?: () => void; onRemoveMfaMethod?: (id: string) => void; @@ -59,6 +61,8 @@ export function UserProfileSecurityPanelView({ devices, onSubmitPassword, onAddPasskey, + addPasskeyError, + onRenamePasskey, onManagePasskey, onRemovePasskey, onAddMfaMethod, @@ -90,6 +94,8 @@ export function UserProfileSecurityPanelView({ passkeys={passkeys} sectionTitle={showPassword ? undefined : 'Authentication'} onAdd={onAddPasskey} + addError={addPasskeyError} + onRename={onRenamePasskey} onManage={onManagePasskey} onRemove={onRemovePasskey} /> diff --git a/packages/swingset/src/stories/fixtures/user-profile-passkeys.ts b/packages/swingset/src/stories/fixtures/user-profile-passkeys.ts new file mode 100644 index 00000000000..710aa48e575 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-passkeys.ts @@ -0,0 +1,61 @@ +import type { UserProfilePasskey } from '@clerk/ui/mosaic/features/user-profile/user-profile-passkeys-section.view'; +import { useRef, useState } from 'react'; + +interface PasskeysFixture { + passkeys: UserProfilePasskey[]; + addError: string | undefined; + onAdd: () => void; + onRename: (id: string, name: string) => Promise; + onRemove: (id: string) => Promise; +} + +export function usePasskeysFixture({ + empty = false, + failOnce = false, +}: { empty?: boolean; failOnce?: boolean } = {}): PasskeysFixture { + const [passkeys, setPasskeys] = useState( + empty + ? [] + : [ + { id: 'laptop', name: 'MacBook', createdAtLabel: 'Created today', lastUsedAtLabel: 'Last used 1 hour ago' }, + { id: 'phone', name: 'iPhone', createdAtLabel: 'Created yesterday' }, + ], + ); + const [addError, setAddError] = useState(); + const failures = useRef(new Set()); + const nextId = useRef(1); + + const attempt = async (action: string) => { + await new Promise(resolve => setTimeout(resolve, 600)); + if (failOnce && !failures.current.has(action)) { + failures.current.add(action); + throw new Error('Something went wrong. Please try again.'); + } + }; + + return { + passkeys, + addError, + onAdd: () => { + if (failOnce && !failures.current.has('add')) { + failures.current.add('add'); + setAddError('Could not create a passkey. Please try again.'); + return; + } + setAddError(undefined); + const id = nextId.current++; + setPasskeys(current => [ + ...current, + { id: `new-${id}`, name: `New passkey ${id}`, createdAtLabel: 'Created just now' }, + ]); + }, + onRename: async (id, name) => { + await attempt('rename'); + setPasskeys(current => current.map(passkey => (passkey.id === id ? { ...passkey, name } : passkey))); + }, + onRemove: async id => { + await attempt('remove'); + setPasskeys(current => current.filter(passkey => passkey.id !== id)); + }, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index e3e4f450f82..4c0b3bd0f57 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -10,7 +10,6 @@ import type { } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; import type { UserProfileMfaMethod, - UserProfilePasskey, } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; import { useMemo, useState } from 'react'; @@ -22,6 +21,7 @@ import { useConnectedAccountsFixture } from './user-profile-connected-accounts'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; import { useUserProfileEditPasswordFixture } from './user-profile-edit-password'; import { useUserProfileEditUsernameFixture } from './user-profile-edit-username'; +import { usePasskeysFixture } from './user-profile-passkeys'; import { useWeb3WalletsFixture } from './user-profile-web3-wallets'; export interface UserProfileFixtureOptions { @@ -65,14 +65,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const [phones, setPhones] = useState([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, ]); - const [passkeys, setPasskeys] = useState([ - { - id: 'passkey', - name: 'MacBook Pro', - createdAtLabel: 'Created today at 10:12 PM', - lastUsedAtLabel: 'Last used 1h ago', - }, - ]); + const passkeys = usePasskeysFixture(); const [mfaMethods, setMfaMethods] = useState([ { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, { id: 'backup', type: 'backup-codes' }, @@ -146,7 +139,9 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions }, security: { ...editPassword, - passkeys, + passkeys: passkeys.passkeys, + addPasskeyError: passkeys.addError, + onRenamePasskey: passkeys.onRename, mfaMethods, devices: activeDevices.devices, onAddMfaMethod: type => @@ -154,16 +149,11 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions ...current, { id: `${type}-${Date.now()}`, type, description: type === 'sms' ? '+1 801-555-0100' : undefined }, ]), - onAddPasskey: () => - setPasskeys(current => [ - ...current, - { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, - ]), + onAddPasskey: passkeys.onAdd, onDeleteAccount: () => Promise.resolve(), - onManagePasskey: () => undefined, onRegenerateBackupCodes: () => undefined, onRemoveMfaMethod: id => setMfaMethods(current => current.filter(method => method.id !== id)), - onRemovePasskey: id => setPasskeys(current => current.filter(passkey => passkey.id !== id)), + onRemovePasskey: passkeys.onRemove, onSignOutAllOtherDevices: activeDevices.onSignOutAllOtherDevices, onSignOutDevice: activeDevices.onSignOutDevice, }, diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx index dd6fce632bf..f8f3a4445a6 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx @@ -1,10 +1,10 @@ -import type { UserProfilePasskey } from '@clerk/mosaic/features/user-profile/user-profile-passkeys-section.view'; import { UserProfilePasskeysSectionView } from '@clerk/mosaic/features/user-profile/user-profile-passkeys-section.view'; import type { ReactElement } from 'react'; -import { useRef, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; +import { usePasskeysFixture } from './fixtures/user-profile-passkeys'; + export { default as __source } from './user-profile-passkeys-section.stories?raw'; export const meta: StoryMeta = { @@ -17,52 +17,11 @@ export const meta: StoryMeta = { }; function PasskeysExample({ empty = false, failOnce = false }: { empty?: boolean; failOnce?: boolean }): ReactElement { - const [passkeys, setPasskeys] = useState( - empty - ? [] - : [ - { id: 'laptop', name: 'MacBook', createdAtLabel: 'Created today', lastUsedAtLabel: 'Last used 1 hour ago' }, - { id: 'phone', name: 'iPhone', createdAtLabel: 'Created yesterday' }, - ], - ); - const [addError, setAddError] = useState(); - const failures = useRef(new Set()); - const nextId = useRef(1); - - const attempt = async (action: string) => { - await new Promise(resolve => setTimeout(resolve, 600)); - if (failOnce && !failures.current.has(action)) { - failures.current.add(action); - throw new Error('Something went wrong. Please try again.'); - } - }; - + const fixture = usePasskeysFixture({ empty, failOnce }); return ( { - if (failOnce && !failures.current.has('add')) { - failures.current.add('add'); - setAddError('Could not create a passkey. Please try again.'); - return; - } - setAddError(undefined); - const id = nextId.current++; - setPasskeys(current => [ - ...current, - { id: `new-${id}`, name: `New passkey ${id}`, createdAtLabel: 'Created just now' }, - ]); - }} - onRename={async (id, name) => { - await attempt('rename'); - setPasskeys(current => current.map(passkey => (passkey.id === id ? { ...passkey, name } : passkey))); - }} - onRemove={async id => { - await attempt('remove'); - setPasskeys(current => current.filter(passkey => passkey.id !== id)); - }} /> ); } diff --git a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx index b89f1db42b9..74cbf99f3b2 100644 --- a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx @@ -1,6 +1,5 @@ import type { UserProfileMfaMethod, - UserProfilePasskey, } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; import { UserProfileSecurityPanelView } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; import { useState } from 'react'; @@ -9,6 +8,7 @@ import type { StoryMeta } from '@/lib/types'; import { useUserProfileActiveDevicesFixture } from './fixtures/user-profile-active-devices'; import { useUserProfileEditPasswordFixture } from './fixtures/user-profile-edit-password'; +import { usePasskeysFixture } from './fixtures/user-profile-passkeys'; export { default as __source } from './user-profile-security-panel.stories?raw'; @@ -23,14 +23,7 @@ export const meta: StoryMeta = { export function Default() { const editPassword = useUserProfileEditPasswordFixture(); - const [passkeys, setPasskeys] = useState([ - { - id: 'passkey', - name: 'Passkey', - createdAtLabel: 'Created today at 10:12 PM', - lastUsedAtLabel: 'Last used 1h ago', - }, - ]); + const passkeys = usePasskeysFixture(); const [mfaMethods, setMfaMethods] = useState([ { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, { id: 'backup', type: 'backup-codes' }, @@ -42,7 +35,9 @@ export function Default() { {...editPassword} devices={devices.devices} mfaMethods={mfaMethods} - passkeys={passkeys} + passkeys={passkeys.passkeys} + addPasskeyError={passkeys.addError} + onRenamePasskey={passkeys.onRename} onAddMfaMethod={type => setMfaMethods(current => { const timestamp = Date.now(); @@ -59,21 +54,15 @@ export function Default() { ]; }) } - onAddPasskey={() => - setPasskeys(current => [ - ...current, - { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, - ]) - } + onAddPasskey={passkeys.onAdd} onDeleteAccount={() => Promise.resolve()} - onManagePasskey={() => undefined} onRegenerateBackupCodes={() => setMfaMethods(current => current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), ) } onRemoveMfaMethod={id => setMfaMethods(current => current.filter(method => method.id !== id))} - onRemovePasskey={id => setPasskeys(current => current.filter(passkey => passkey.id !== id))} + onRemovePasskey={passkeys.onRemove} onSignOutAllOtherDevices={devices.onSignOutAllOtherDevices} onSignOutDevice={devices.onSignOutDevice} /> From 760132075316eb2ec9e5060c5ad24967441a77f4 Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Wed, 16 Sep 2026 10:42:58 -0400 Subject: [PATCH 04/25] fix(swingset): sort passkey story imports in registry --- packages/swingset/src/lib/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index b0acccd1552..50c07c5cedc 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -304,8 +304,8 @@ import { import { Default as UserProfilePasskeysSectionDefault, Empty as UserProfilePasskeysSectionEmpty, - RecoverableErrors as UserProfilePasskeysSectionRecoverableErrors, meta as userProfilePasskeysSectionMeta, + RecoverableErrors as UserProfilePasskeysSectionRecoverableErrors, } from '../stories/user-profile-passkeys-section.stories'; import { Default as UserProfilePasswordSectionDefault, From 785309e86278584655644b36055dbb1b9bbc22c5 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 09:17:27 -0600 Subject: [PATCH 05/25] refactor(ui): use fill for passkey messages --- .../features/user-profile/user-profile-passkey-row.view.tsx | 3 ++- .../user-profile/user-profile-passkeys-section.view.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx index ed79eb11400..ad245aae66d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import { Dialog } from '../../components/dialog'; import { Section } from '../../components/section'; +import { fill } from './user-profile-account-section/user-profile-account-section.messages'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; @@ -75,7 +76,7 @@ export function UserProfilePasskeyRowView({ passkey.name)} + label={fill(m.manage, { name: passkey.name })} /> ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx index 079176a671b..0d272104ee9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx @@ -4,6 +4,7 @@ import { Confirmation } from '../../blocks/confirmation'; import { Button } from '../../components/button'; import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; +import { fill } from './user-profile-account-section/user-profile-account-section.messages'; import { UserProfilePasskeyRowView } from './user-profile-passkey-row.view'; import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; @@ -95,7 +96,7 @@ export function UserProfilePasskeysSectionView({ m.removeDescription.replace('{name}', () => passkey.name)} + description={passkey => fill(m.removeDescription, { name: passkey.name })} actionLabel={m.remove} onConfirm={passkey => onRemove(passkey.id)} /> From 5558b8983037075537fd8d83f51c83bb42128d52 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 12:23:16 -0600 Subject: [PATCH 06/25] fix(ui): align passkey rows with the design --- .../user-profile-passkey-row.view.tsx | 31 ++++++++++++++++--- .../user-profile-passkeys-section.messages.ts | 1 + 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx index ad245aae66d..f1d0d8f7298 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx @@ -1,14 +1,16 @@ +import * as stylex from '@stylexjs/stylex'; import { useMemo, useState } from 'react'; import { Dialog } from '../../components/dialog'; +import { Icon, IconFrame } from '../../components/icon'; import { Section } from '../../components/section'; +import { colorVars } from '../../tokens.stylex'; import { fill } from './user-profile-account-section/user-profile-account-section.messages'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; import { UserProfileRenamePasskeyDialog } from './user-profile-rename-passkey.dialog'; -import { UserProfileSecurityIcon } from './user-profile-security-icon'; export function UserProfilePasskeyRowView({ passkey, @@ -26,6 +28,10 @@ export function UserProfilePasskeyRowView({ const [name, setName] = useState(passkey.name); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(); + const description = + passkey.createdAtLabel && passkey.lastUsedAtLabel + ? fill(m.details, { createdAt: passkey.createdAtLabel, lastUsedAt: passkey.lastUsedAtLabel }) + : passkey.createdAtLabel || passkey.lastUsedAtLabel; const onOpenChange = (nextOpen: boolean) => { if (isSaving) { @@ -66,11 +72,22 @@ export function UserProfilePasskeyRowView({ return ( <> - + + + + + {passkey.name} - {passkey.createdAtLabel ? {passkey.createdAtLabel} : null} - {passkey.lastUsedAtLabel ? {passkey.lastUsedAtLabel} : null} + {description ? {description} : null} {actions.length > 0 ? ( @@ -97,3 +114,9 @@ export function UserProfilePasskeyRowView({ ); } + +const styles = stylex.create({ + icon: { + color: colorVars['--cl-color-neutral-faded'], + }, +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts index aade8051ad5..e07f1bfaa48 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts @@ -2,6 +2,7 @@ export const userProfilePasskeysMessages = { label: 'Passkeys', add: 'Add passkey', empty: 'No passkeys added', + details: '{createdAt} · {lastUsedAt}', manage: 'Manage {name}', rename: 'Rename', renameTitle: 'Rename passkey', From abb0230b24a52cdf8a24a8196ccb075799388966 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 13:36:43 -0600 Subject: [PATCH 07/25] fix(ui): align passkeys with shared messages and color tokens --- .../features/user-profile/user-profile-passkey-row.view.tsx | 4 ++-- .../user-profile/user-profile-passkeys-section.view.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx index f1d0d8f7298..ea327ac4f16 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx @@ -5,7 +5,7 @@ import { Dialog } from '../../components/dialog'; import { Icon, IconFrame } from '../../components/icon'; import { Section } from '../../components/section'; import { colorVars } from '../../tokens.stylex'; -import { fill } from './user-profile-account-section/user-profile-account-section.messages'; +import { fill } from '../../utils/messages'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; @@ -117,6 +117,6 @@ export function UserProfilePasskeyRowView({ const styles = stylex.create({ icon: { - color: colorVars['--cl-color-neutral-faded'], + color: colorVars['--cl-color-foreground-secondary'], }, }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx index 0d272104ee9..f93cf634c69 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx @@ -4,7 +4,7 @@ import { Confirmation } from '../../blocks/confirmation'; import { Button } from '../../components/button'; import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; -import { fill } from './user-profile-account-section/user-profile-account-section.messages'; +import { fill } from '../../utils/messages'; import { UserProfilePasskeyRowView } from './user-profile-passkey-row.view'; import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; From 7a1fa4a0cb06410fe8c242b06a75499f2210e8b3 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 14:05:14 -0600 Subject: [PATCH 08/25] refactor(ui): move passkey rename state into a controller --- .../user-profile-passkey-row.view.tsx | 44 +-------- .../user-profile-rename-passkey.controller.ts | 99 +++++++++++++++++++ 2 files changed, 104 insertions(+), 39 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx index ea327ac4f16..12a01dce6e8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx @@ -1,5 +1,5 @@ import * as stylex from '@stylexjs/stylex'; -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { Dialog } from '../../components/dialog'; import { Icon, IconFrame } from '../../components/icon'; @@ -10,6 +10,7 @@ import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; +import { useUserProfileRenamePasskeyController } from './user-profile-rename-passkey.controller'; import { UserProfileRenamePasskeyDialog } from './user-profile-rename-passkey.dialog'; export function UserProfilePasskeyRowView({ @@ -24,41 +25,12 @@ export function UserProfilePasskeyRowView({ onRemove?: () => void; }) { const renameDialog = useMemo(() => Dialog.createHandle(), []); - const [open, setOpen] = useState(false); - const [name, setName] = useState(passkey.name); - const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(); + const controller = useUserProfileRenamePasskeyController({ id: passkey.id, name: passkey.name, onRename }); const description = passkey.createdAtLabel && passkey.lastUsedAtLabel ? fill(m.details, { createdAt: passkey.createdAtLabel, lastUsedAt: passkey.lastUsedAtLabel }) : passkey.createdAtLabel || passkey.lastUsedAtLabel; - const onOpenChange = (nextOpen: boolean) => { - if (isSaving) { - return; - } - if (nextOpen) { - setName(passkey.name); - setError(undefined); - } - setOpen(nextOpen); - }; - const onSubmit = async () => { - if (!onRename || isSaving || name.length < 2 || name === passkey.name) { - return; - } - setIsSaving(true); - setError(undefined); - try { - await onRename(passkey.id, name); - setOpen(false); - } catch (cause) { - setError(cause instanceof Error ? cause.message : m.saveError); - } finally { - setIsSaving(false); - } - }; - const actions: UserProfileMenuAction[] = []; if (onRename) { actions.push({ label: m.rename, onClick: () => renameDialog.open(undefined) }); @@ -100,15 +72,9 @@ export function UserProfilePasskeyRowView({ {onRename ? ( 1 && name !== passkey.name} - onSubmit={() => void onSubmit()} + open={controller.isOpen} /> ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts new file mode 100644 index 00000000000..6afbdb9aeb6 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts @@ -0,0 +1,99 @@ +import { setup } from '../../machine/setup'; +import { useMachine } from '../../machine/useMachine'; +import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; + +interface UserProfileRenamePasskeyContext { + passkeyId: string; + savedName: string; + name: string; + onRename: ((id: string, name: string) => void | Promise) | undefined; + error: string | undefined; +} + +type UserProfileRenamePasskeyEvent = + | { type: 'OPEN' } + | { type: 'TYPE'; value: string } + | { type: 'SAVE' } + | { type: 'CANCEL' }; + +const { createMachine, assign, fromPromise } = setup(); + +function isSaveable(context: UserProfileRenamePasskeyContext): boolean { + return Boolean(context.onRename) && context.name.length > 1 && context.name !== context.savedName; +} + +const userProfileRenamePasskeyMachine = createMachine({ + id: 'renamePasskey', + initial: 'idle', + context: { + passkeyId: '', + savedName: '', + name: '', + onRename: undefined, + error: undefined, + }, + states: { + idle: { + on: { + OPEN: { + target: 'editing', + actions: assign(context => ({ name: context.savedName, error: undefined })), + }, + }, + }, + editing: { + on: { + TYPE: { actions: assign((_, event) => ({ name: event.value })) }, + SAVE: { target: 'saving', guard: isSaveable, actions: assign(() => ({ error: undefined })) }, + CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, + }, + }, + saving: { + invoke: fromPromise(async context => context.onRename?.(context.passkeyId, context.name), { + onDone: { target: 'idle' }, + onError: { + target: 'editing', + actions: assign((_, event) => ({ error: event.error instanceof Error ? event.error.message : m.saveError })), + }, + }), + }, + }, +}); + +interface UserProfileRenamePasskeyControllerOptions { + id: string; + name: string; + onRename?: (id: string, name: string) => void | Promise; +} + +interface UserProfileRenamePasskeyController { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + name: string; + onNameChange: (name: string) => void; + onSubmit: () => void; + canSave: boolean; + isSaving: boolean; + error: string | undefined; +} + +export function useUserProfileRenamePasskeyController({ + id, + name, + onRename, +}: UserProfileRenamePasskeyControllerOptions): UserProfileRenamePasskeyController { + const [snapshot, send] = useMachine(userProfileRenamePasskeyMachine, { + context: { passkeyId: id, savedName: name, onRename }, + }); + + return { + isOpen: snapshot.value === 'editing' || snapshot.value === 'saving', + onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), + name: snapshot.context.name, + onNameChange: value => send({ type: 'TYPE', value }), + onSubmit: () => send({ type: 'SAVE' }), + canSave: isSaveable(snapshot.context), + isSaving: snapshot.value === 'saving', + error: snapshot.context.error, + }; +} From 98ed03f70e457f5acc052c0b1e9809bebe9789a0 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 14:05:37 -0600 Subject: [PATCH 09/25] fix(ui): align passkey visibility in the security panel --- .../user-profile-security-panel.view.test.tsx | 42 +++++++++++- .../user-profile-security-panel.styles.ts | 3 + .../user-profile-security-panel.view.tsx | 65 +++++++++---------- 3 files changed, 76 insertions(+), 34 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index ffdbc5646fe..9eec8ce2bfb 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -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'; @@ -165,6 +166,45 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByRole('menuitem', { name: 'Sign out' })).not.toBeInTheDocument(); }); + it('keeps the authentication heading on MFA when passkeys are unavailable', () => { + renderView({ hasPassword: false, passkeys: [] }); + + expect(screen.queryByText('Passkeys')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible(); + expect(screen.getByText('2-step verification')).toBeVisible(); + }); + + it('keeps the final passkey confirmation mounted after authentication becomes empty', 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('dialog')).getByRole('button', { name: 'Remove' })); + + rerender( + + + , + ); + expect(screen.queryByRole('heading', { name: 'Authentication' })).not.toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Remove passkey' })).toBeVisible(); + + await act(async () => { + removal.resolve(); + await removal.promise; + }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(screen.queryByText('Passkeys')).not.toBeInTheDocument(); + }); + it('only shows backup codes with another verification method and only allows regeneration', async () => { const onRegenerateBackupCodes = vi.fn(); const onRemoveMfaMethod = vi.fn(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-security-panel.styles.ts index a743e88cd52..438797596fa 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.styles.ts @@ -33,6 +33,9 @@ export const styles = stylex.create({ flexDirection: 'column', width: '100%', }, + emptySectionCards: { + display: 'none', + }, sections: { gap: space['10'], display: 'flex', diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index a4bceb30cfb..a2963286ca2 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -73,44 +73,43 @@ export function UserProfileSecurityPanelView({ onDeleteAccount, }: UserProfileSecurityPanelViewProps): ReactElement { const showPassword = hasPassword || Boolean(onSubmitPassword) || Boolean(managedBy); - const hasAuthentication = showPassword || passkeys !== undefined || mfaMethods !== undefined; + const showPasskeys = passkeys !== undefined && (passkeys.length > 0 || Boolean(onAddPasskey)); + const hasAuthentication = showPassword || showPasskeys || mfaMethods !== undefined; return (
Security
- {hasAuthentication ? ( -
- {showPassword ? ( - - ) : null} - {passkeys !== undefined ? ( - - ) : null} - {mfaMethods !== undefined ? ( - - ) : null} -
- ) : null} +
+ {showPassword ? ( + + ) : null} + {passkeys !== undefined ? ( + + ) : null} + {mfaMethods !== undefined ? ( + + ) : null} +
{devices ? ( Date: Wed, 16 Sep 2026 15:32:39 -0600 Subject: [PATCH 10/25] fix(ui): preserve legacy mosaic passkey section visibility --- ...ser-profile-passkeys-section.view.test.tsx | 57 ++++++--- .../user-profile-security-panel.view.test.tsx | 32 +++++- .../user-profile-passkeys-section.view.tsx | 108 +++++++++--------- .../user-profile-security-panel.view.tsx | 5 +- 4 files changed, 126 insertions(+), 76 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx index 9266d3fdf6b..4ba4c8141c9 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx @@ -4,14 +4,40 @@ 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 = [ +const passkeys: UserProfilePasskey[] = [ { id: 'laptop', name: 'MacBook' }, { id: 'phone', name: 'iPhone' }, ]; +function renderView(overrides: Partial = {}) { + const props: UserProfilePasskeysSectionViewProps = { + passkeys, + onAdd: vi.fn(), + onRename: vi.fn(), + onRemove: vi.fn(), + ...overrides, + }; + return render( + + + , + ); +} + describe('passkeys section', () => { + it('hides existing passkeys and their actions when the caller hides the section', () => { + renderView({ isVisible: false, sectionTitle: 'Authentication' }); + + expect(screen.queryByRole('heading', { name: 'Authentication' })).not.toBeInTheDocument(); + expect(screen.queryByText('Passkeys')).not.toBeInTheDocument(); + expect(screen.queryByText('MacBook')).not.toBeInTheDocument(); + expect(screen.queryByText('iPhone')).not.toBeInTheDocument(); + 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(); @@ -67,25 +93,20 @@ describe('passkeys section', () => { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); - it('hides an unavailable section and keeps Add available with an empty list or creation error', async () => { + 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(); - const { rerender } = render( - , - ); - expect(screen.queryByRole('heading', { name: 'Authentication' })).not.toBeInTheDocument(); - expect(screen.queryByText('Passkeys')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); - rerender( - , - ); + renderView({ passkeys: [], onAdd, addError: 'Could not create passkey' }); + expect(screen.getByRole('alert')).toHaveTextContent('Could not create passkey'); await user.click(screen.getByRole('button', { name: 'Add passkey' })); expect(onAdd).toHaveBeenCalledOnce(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 9eec8ce2bfb..b7a5a9cd665 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -166,15 +166,34 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByRole('menuitem', { name: 'Sign out' })).not.toBeInTheDocument(); }); - it('keeps the authentication heading on MFA when passkeys are unavailable', () => { - renderView({ hasPassword: false, passkeys: [] }); + 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 the final passkey confirmation mounted after authentication becomes empty', async () => { + 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 () => { @@ -194,7 +213,7 @@ describe('UserProfileSecurityPanelView', () => { /> , ); - expect(screen.queryByRole('heading', { name: 'Authentication' })).not.toBeInTheDocument(); + expect(screen.getByText('Authentication')).toBeInTheDocument(); expect(screen.getByRole('dialog', { name: 'Remove passkey' })).toBeVisible(); await act(async () => { @@ -202,7 +221,10 @@ describe('UserProfileSecurityPanelView', () => { await removal.promise; }); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(screen.queryByText('Passkeys')).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 () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx index f93cf634c69..9b9efbc6a5b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx @@ -17,6 +17,7 @@ export interface UserProfilePasskey { export interface UserProfilePasskeysSectionViewProps { passkeys: UserProfilePasskey[]; + isVisible?: boolean; sectionTitle?: string; onAdd?: () => void; addError?: string; @@ -27,6 +28,7 @@ export interface UserProfilePasskeysSectionViewProps { export function UserProfilePasskeysSectionView({ passkeys, + isVisible = true, sectionTitle, onAdd, addError, @@ -36,62 +38,64 @@ export function UserProfilePasskeysSectionView({ }: UserProfilePasskeysSectionViewProps) { const removePasskey = useMemo(() => Confirmation.createHandle(), []); + if (!isVisible) { + return null; + } + return ( <> - {passkeys.length > 0 || onAdd ? ( - - {sectionTitle ? {sectionTitle} : null} - - - - - {m.label} - - {onAdd ? ( - - - - ) : null} - - {addError ? {addError} : null} - {passkeys.length > 0 ? ( - - {passkeys.map(passkey => ( - removePasskey.open(passkey) : undefined} /> - ))} - - ) : ( - - - - {m.empty} - - - - )} - - - - ) : null} + {m.add} + + + ) : null} + + {addError ? {addError} : null} + {passkeys.length > 0 ? ( + + {passkeys.map(passkey => ( + removePasskey.open(passkey) : undefined} + /> + ))} + + ) : ( + + + + {m.empty} + + + + )} + + + {onRemove ? ( { passkeys?: UserProfilePasskey[]; + passkeysVisible?: boolean; mfaMethods?: UserProfileMfaMethod[]; devices?: UserProfileDevice[]; onAddPasskey?: () => void; @@ -57,6 +58,7 @@ export function UserProfileSecurityPanelView({ requiresCurrentPassword, managedBy, passkeys, + passkeysVisible = true, mfaMethods, devices, onSubmitPassword, @@ -73,7 +75,7 @@ export function UserProfileSecurityPanelView({ onDeleteAccount, }: UserProfileSecurityPanelViewProps): ReactElement { const showPassword = hasPassword || Boolean(onSubmitPassword) || Boolean(managedBy); - const showPasskeys = passkeys !== undefined && (passkeys.length > 0 || Boolean(onAddPasskey)); + const showPasskeys = passkeys !== undefined && passkeysVisible; const hasAuthentication = showPassword || showPasskeys || mfaMethods !== undefined; return ( @@ -92,6 +94,7 @@ export function UserProfileSecurityPanelView({ {passkeys !== undefined ? ( Date: Wed, 16 Sep 2026 15:32:57 -0600 Subject: [PATCH 11/25] docs(swingset): explain legacy passkey visibility --- packages/swingset/src/lib/registry.ts | 2 ++ .../stories/user-profile-passkeys-section.mdx | 31 +++++++++++++------ .../user-profile-passkeys-section.stories.tsx | 11 +++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 50c07c5cedc..240125f4246 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -302,6 +302,7 @@ import { meta as userProfileMfaSectionMeta, } from '../stories/user-profile-mfa-section.stories'; import { + CreationUnavailable as UserProfilePasskeysSectionCreationUnavailable, Default as UserProfilePasskeysSectionDefault, Empty as UserProfilePasskeysSectionEmpty, meta as userProfilePasskeysSectionMeta, @@ -650,6 +651,7 @@ const userProfilePasswordSectionModule: StoryModule = { }; const userProfilePasskeysSectionModule: StoryModule = { meta: userProfilePasskeysSectionMeta, + CreationUnavailable: UserProfilePasskeysSectionCreationUnavailable, Default: UserProfilePasskeysSectionDefault, Empty: UserProfilePasskeysSectionEmpty, RecoverableErrors: UserProfilePasskeysSectionRecoverableErrors, diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.mdx b/packages/swingset/src/stories/user-profile-passkeys-section.mdx index 2602f696c6b..80333662a78 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.mdx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.mdx @@ -16,23 +16,36 @@ Manage passkey names and confirm removal. These examples mock creation; browser ## Props -| Prop | Type | Default | Description | -| -------------- | ----------------------------------------------------- | ------------ | ---------------------------------------------------------------- | -| `passkeys` | `UserProfilePasskey[]` | — (required) | Names and formatted creation/last-used labels. | -| `sectionTitle` | `string` | — | Optional surrounding section heading. | -| `onAdd` | `() => void` | — | Shows Add when supplied. | -| `addError` | `string` | — | Inline creation error. | -| `onRename` | `(id: string, name: string) => void \| Promise` | — | Resolve to close; reject with an Error to display the failure. | -| `onRemove` | `(id: string) => void \| Promise` | — | Shared confirmation awaits removal and displays rejected errors. | +| Prop | Type | Default | Description | +| -------------- | ----------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------- | +| `passkeys` | `UserProfilePasskey[]` | — (required) | Names and formatted creation/last-used labels. | +| `isVisible` | `boolean` | `true` | Caller-derived section visibility, independent of Add availability and list length. | +| `sectionTitle` | `string` | — | Optional surrounding section heading. | +| `onAdd` | `() => void` | — | Shows Add when supplied. | +| `addError` | `string` | — | Inline creation error. | +| `onRename` | `(id: string, name: string) => void \| Promise` | — | Resolve to close; reject with an Error to display the failure. | +| `onRemove` | `(id: string) => void \| Promise` | — | Shared confirmation awaits removal and displays rejected errors. | ## Usage +### Legacy visibility + +Legacy hides the entire section when passkeys are disabled for the instance or identification creation is disallowed, even if the user already has passkeys. Future model/controller wiring must express that decision with `isVisible={false}` (`passkeysVisible={false}` in the Security panel). These views do not read instance settings themselves. + +On a satellite domain, an otherwise visible section remains visible even when empty; only Add is unavailable. Represent that by omitting `onAdd`, without hiding the section. + ### No passkeys yet -Add remains available after removing the final passkey. With neither passkeys nor an Add callback, the section is hidden. +The section remains visible after removing the final passkey. Add remains available when its callback is supplied. +### Creation unavailable + +Rename and remove existing passkeys. Removing the final passkey leaves the empty section visible, matching a satellite domain where creation is unavailable. + + + ### Recoverable errors The first Add, Rename, and Remove attempt fails. Retry completes the action; saving and removal use a short simulated delay. diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx index f8f3a4445a6..f57448d1b14 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx @@ -34,6 +34,17 @@ export function Empty(): ReactElement { return ; } +export function CreationUnavailable(): ReactElement { + const fixture = usePasskeysFixture(); + return ( + + ); +} + export function RecoverableErrors(): ReactElement { return ; } From 5f447cbbafff8d523f345bb00975c7b030e34920 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 15:45:35 -0600 Subject: [PATCH 12/25] refactor(ui): remove unused passkey manage fallback --- .../features/user-profile/user-profile-passkey-row.view.tsx | 4 ---- .../user-profile/user-profile-passkeys-section.view.tsx | 3 --- .../user-profile/user-profile-security-panel.view.tsx | 3 --- 3 files changed, 10 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx index 12a01dce6e8..618b052e65f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx @@ -16,12 +16,10 @@ import { UserProfileRenamePasskeyDialog } from './user-profile-rename-passkey.di export function UserProfilePasskeyRowView({ passkey, onRename, - onManage, onRemove, }: { passkey: UserProfilePasskey; onRename?: (id: string, name: string) => void | Promise; - onManage?: (id: string) => void; onRemove?: () => void; }) { const renameDialog = useMemo(() => Dialog.createHandle(), []); @@ -34,8 +32,6 @@ export function UserProfilePasskeyRowView({ const actions: UserProfileMenuAction[] = []; if (onRename) { actions.push({ label: m.rename, onClick: () => renameDialog.open(undefined) }); - } else if (onManage) { - actions.push({ label: m.rename, onClick: () => onManage(passkey.id) }); } if (onRemove) { actions.push({ label: m.removeAction, color: 'negative', onClick: onRemove }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx index 9b9efbc6a5b..5ea425a079a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx @@ -22,7 +22,6 @@ export interface UserProfilePasskeysSectionViewProps { onAdd?: () => void; addError?: string; onRename?: (id: string, name: string) => void | Promise; - onManage?: (id: string) => void; onRemove?: (id: string) => void | Promise; } @@ -33,7 +32,6 @@ export function UserProfilePasskeysSectionView({ onAdd, addError, onRename, - onManage, onRemove, }: UserProfilePasskeysSectionViewProps) { const removePasskey = useMemo(() => Confirmation.createHandle(), []); @@ -79,7 +77,6 @@ export function UserProfilePasskeysSectionView({ key={passkey.id} passkey={passkey} onRename={onRename} - onManage={onManage} onRemove={onRemove ? () => removePasskey.open(passkey) : undefined} /> ))} diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index cc1ebb7ae7e..7929f8a4e67 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -44,7 +44,6 @@ export interface UserProfileSecurityPanelViewProps onAddPasskey?: () => void; addPasskeyError?: string; onRenamePasskey?: (id: string, name: string) => void | Promise; - onManagePasskey?: (id: string) => void; onRemovePasskey?: (id: string) => void | Promise; onAddMfaMethod?: (type: UserProfileMfaAddableMethod) => void; onRegenerateBackupCodes?: () => void; @@ -65,7 +64,6 @@ export function UserProfileSecurityPanelView({ onAddPasskey, addPasskeyError, onRenamePasskey, - onManagePasskey, onRemovePasskey, onAddMfaMethod, onRegenerateBackupCodes, @@ -99,7 +97,6 @@ export function UserProfileSecurityPanelView({ onAdd={onAddPasskey} addError={addPasskeyError} onRename={onRenamePasskey} - onManage={onManagePasskey} onRemove={onRemovePasskey} /> ) : null} From 29e89b1a375b89c5492e0bd92efac801a82142a0 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 15:51:18 -0600 Subject: [PATCH 13/25] refactor(ui): colocate passkey styles with feature styles --- .../user-profile/user-profile-passkey-row.view.tsx | 9 +-------- .../user-profile/user-profile-passkeys-section.styles.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-passkeys-section.styles.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx index 618b052e65f..f90dae272f5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx @@ -1,14 +1,13 @@ -import * as stylex from '@stylexjs/stylex'; import { useMemo } from 'react'; import { Dialog } from '../../components/dialog'; import { Icon, IconFrame } from '../../components/icon'; import { Section } from '../../components/section'; -import { colorVars } from '../../tokens.stylex'; import { fill } from '../../utils/messages'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; +import { styles } from './user-profile-passkeys-section.styles'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; import { useUserProfileRenamePasskeyController } from './user-profile-rename-passkey.controller'; import { UserProfileRenamePasskeyDialog } from './user-profile-rename-passkey.dialog'; @@ -76,9 +75,3 @@ export function UserProfilePasskeyRowView({ ); } - -const styles = stylex.create({ - icon: { - color: colorVars['--cl-color-foreground-secondary'], - }, -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.styles.ts new file mode 100644 index 00000000000..ab368ec2077 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.styles.ts @@ -0,0 +1,9 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + icon: { + color: colorVars['--cl-color-foreground-secondary'], + }, +}); From 5485f59a6383fa673b02831b5eb0dfb5beca13cf Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 15:55:36 -0600 Subject: [PATCH 14/25] refactor(ui): let the panel control passkey section visibility --- ...ser-profile-passkeys-section.view.test.tsx | 12 ++++++------ .../user-profile-passkeys-section.view.tsx | 6 ------ .../user-profile-security-panel.view.tsx | 3 +-- .../stories/user-profile-passkeys-section.mdx | 19 +++++++++---------- 4 files changed, 16 insertions(+), 24 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx index 4ba4c8141c9..c1216710c7e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx @@ -28,13 +28,13 @@ function renderView(overrides: Partial = {} } describe('passkeys section', () => { - it('hides existing passkeys and their actions when the caller hides the section', () => { - renderView({ isVisible: false, sectionTitle: 'Authentication' }); + it('shows existing passkeys without actions when their callbacks are unavailable', () => { + renderView({ onAdd: undefined, onRename: undefined, onRemove: undefined, sectionTitle: 'Authentication' }); - expect(screen.queryByRole('heading', { name: 'Authentication' })).not.toBeInTheDocument(); - expect(screen.queryByText('Passkeys')).not.toBeInTheDocument(); - expect(screen.queryByText('MacBook')).not.toBeInTheDocument(); - expect(screen.queryByText('iPhone')).not.toBeInTheDocument(); + 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(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx index 5ea425a079a..89a6cc9eb13 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx @@ -17,7 +17,6 @@ export interface UserProfilePasskey { export interface UserProfilePasskeysSectionViewProps { passkeys: UserProfilePasskey[]; - isVisible?: boolean; sectionTitle?: string; onAdd?: () => void; addError?: string; @@ -27,7 +26,6 @@ export interface UserProfilePasskeysSectionViewProps { export function UserProfilePasskeysSectionView({ passkeys, - isVisible = true, sectionTitle, onAdd, addError, @@ -36,10 +34,6 @@ export function UserProfilePasskeysSectionView({ }: UserProfilePasskeysSectionViewProps) { const removePasskey = useMemo(() => Confirmation.createHandle(), []); - if (!isVisible) { - return null; - } - return ( <> diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 7929f8a4e67..5d3f67ccab4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -89,10 +89,9 @@ export function UserProfileSecurityPanelView({ onSubmitPassword={onSubmitPassword} /> ) : null} - {passkeys !== undefined ? ( + {showPasskeys ? ( void` | — | Shows Add when supplied. | -| `addError` | `string` | — | Inline creation error. | -| `onRename` | `(id: string, name: string) => void \| Promise` | — | Resolve to close; reject with an Error to display the failure. | -| `onRemove` | `(id: string) => void \| Promise` | — | Shared confirmation awaits removal and displays rejected errors. | +| Prop | Type | Default | Description | +| -------------- | ----------------------------------------------------- | ------------ | ---------------------------------------------------------------- | +| `passkeys` | `UserProfilePasskey[]` | — (required) | Names and formatted creation/last-used labels. | +| `sectionTitle` | `string` | — | Optional surrounding section heading. | +| `onAdd` | `() => void` | — | Shows Add when supplied. | +| `addError` | `string` | — | Inline creation error. | +| `onRename` | `(id: string, name: string) => void \| Promise` | — | Resolve to close; reject with an Error to display the failure. | +| `onRemove` | `(id: string) => void \| Promise` | — | Shared confirmation awaits removal and displays rejected errors. | ## Usage ### Legacy visibility -Legacy hides the entire section when passkeys are disabled for the instance or identification creation is disallowed, even if the user already has passkeys. Future model/controller wiring must express that decision with `isVisible={false}` (`passkeysVisible={false}` in the Security panel). These views do not read instance settings themselves. +Legacy hides the entire section when passkeys are disabled for the instance or identification creation is disallowed, even if the user already has passkeys. The caller conditionally renders the section using that policy decision. The Security panel accepts `passkeysVisible={false}` to omit it; future model/controller wiring supplies that value. The section itself always renders when mounted and does not read instance settings. On a satellite domain, an otherwise visible section remains visible even when empty; only Add is unavailable. Represent that by omitting `onAdd`, without hiding the section. From b36ed87678011a547452ba6adc8b97bf51a29239 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 16:09:09 -0600 Subject: [PATCH 15/25] docs(swingset): describe passkey visibility directly --- .../swingset/src/stories/user-profile-passkeys-section.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.mdx b/packages/swingset/src/stories/user-profile-passkeys-section.mdx index 7019d37929c..320da8d411d 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.mdx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.mdx @@ -27,9 +27,9 @@ Manage passkey names and confirm removal. These examples mock creation; browser ## Usage -### Legacy visibility +### Visibility -Legacy hides the entire section when passkeys are disabled for the instance or identification creation is disallowed, even if the user already has passkeys. The caller conditionally renders the section using that policy decision. The Security panel accepts `passkeysVisible={false}` to omit it; future model/controller wiring supplies that value. The section itself always renders when mounted and does not read instance settings. +The caller hides the entire section when passkeys are disabled for the instance or identification creation is disallowed, even if the user already has passkeys. Set `passkeysVisible={false}` on the Security panel to omit it. The standalone section always renders when mounted and does not read instance settings. On a satellite domain, an otherwise visible section remains visible even when empty; only Add is unavailable. Represent that by omitting `onAdd`, without hiding the section. From 42b43a994394530244dacaba5b7a194e0df38c4c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 16:24:04 -0600 Subject: [PATCH 16/25] fix(ui): align passkey Add label and clarify satellite behavior --- .../__tests__/user-profile-passkeys-section.view.test.tsx | 4 +++- .../user-profile/user-profile-passkeys-section.messages.ts | 3 ++- .../user-profile/user-profile-passkeys-section.view.tsx | 2 +- .../swingset/src/stories/user-profile-passkeys-section.mdx | 2 ++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx index c1216710c7e..ec855e62264 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx @@ -108,7 +108,9 @@ describe('passkeys section', () => { renderView({ passkeys: [], onAdd, addError: 'Could not create passkey' }); expect(screen.getByRole('alert')).toHaveTextContent('Could not create passkey'); - await user.click(screen.getByRole('button', { name: 'Add passkey' })); + const addButton = screen.getByRole('button', { name: 'Add passkey' }); + expect(addButton).toHaveTextContent(/^Add$/); + await user.click(addButton); expect(onAdd).toHaveBeenCalledOnce(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts index e07f1bfaa48..537fad1cc35 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts @@ -1,6 +1,7 @@ export const userProfilePasskeysMessages = { label: 'Passkeys', - add: 'Add passkey', + add: 'Add', + addLabel: 'Add passkey', empty: 'No passkeys added', details: '{createdAt} · {lastUsedAt}', manage: 'Manage {name}', diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx index 89a6cc9eb13..7023a102274 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx @@ -47,7 +47,7 @@ export function UserProfilePasskeysSectionView({ {onAdd ? (