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..fd65d4206b1 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx @@ -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 = {}) { + const props: UserProfilePasskeysSectionViewProps = { + passkeys, + onAdd: vi.fn(), + onRename: vi.fn(), + onRemove: vi.fn(), + ...overrides, + }; + return render( + + + , + ); +} + +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( + + + , + ); + + 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 ( + + setItems(current => current.filter(item => item.id !== id))} + /> + + ); + } + render(); + 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( + + + , + ); + 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(resolve => { + finish = resolve; + }); + function Example() { + const [items, setItems] = useState([passkeys[0]]); + return ( + + setItems([passkeys[0]]) : undefined} + 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('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( + + + , + ); + 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( + + + , + ); + 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(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..86e4f296a76 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'; @@ -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(); @@ -91,7 +92,7 @@ describe('UserProfileSecurityPanelView', () => { { id: 'backup_1', type: 'backup-codes' }, ], onAddPasskey, - onManagePasskey, + onRenamePasskey, onRemovePasskey, onAddMfaMethod, onSignOutDevice, @@ -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' })); @@ -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'); @@ -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( + + + , + ); + 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(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx index f6ade3eee26..1e95393de41 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx @@ -1,4 +1,5 @@ import * as stylex from '@stylexjs/stylex'; +import type { Ref } from 'react'; import { useMemo, useRef, useState } from 'react'; import { Confirmation } from '../../blocks/confirmation'; @@ -6,6 +7,7 @@ import { Badge } from '../../components/badge'; import { SubmitButton } from '../../components/button'; import { Dialog } from '../../components/dialog'; import { Section } from '../../components/section'; +import { useListRemovalFocus } from '../../hooks/useListRemovalFocus'; import type { MosaicMessages } from '../../localization'; import { fill, useMessages } from '../../localization'; import type { UserProfileMenuAction } from './user-profile-action-menu'; @@ -36,42 +38,13 @@ export function UserProfileActiveDevicesSectionView({ const openSignOut = onSignOutDevice ? (device: UserProfileDevice) => signOutDevice.open(device) : undefined; - const triggers = useRef(new Map()); - const signedOut = useRef<{ id: string; index: number } | undefined>(undefined); - - const registerTrigger = (id: string) => (element: HTMLButtonElement | null) => { - if (element) { - triggers.current.set(id, element); - } else { - triggers.current.delete(id); - } - }; - - const signOutDeviceAt = onSignOutDevice - ? async (device: UserProfileDevice) => { - const index = otherDevices.findIndex(other => other.id === device.id); - await onSignOutDevice(device.id); - // Recorded only once the device is really gone, so a cancelled or failed attempt still - // returns focus to the row's own menu. - signedOut.current = { id: device.id, index }; - } - : undefined; - - // The row the dialog was opened from has just unmounted, so focus goes to whichever row took - // its place — the last one if it was the last, the current device once none are left. - const focusAfterSignOut = () => { - const removed = signedOut.current; - signedOut.current = undefined; - if (!removed) { - return null; - } - // Filtered by id as well as index: a caller whose list only catches up on a later refetch - // still has the signed-out row here, and handing focus back to it loses focus all over again - // when it goes. - const remaining = devices.filter(device => !device.isCurrent && device.id !== removed.id); - const next = remaining[Math.min(removed.index, remaining.length - 1)] ?? currentDevices[0]; - return (next && triggers.current.get(next.id)) ?? null; - }; + const currentDeviceTrigger = useRef(null); + const removalFocus = useListRemovalFocus({ + ids: otherDevices.map(device => device.id), + onRemove: onSignOutDevice, + fallback: () => currentDeviceTrigger.current, + }); + const signOutDeviceAt = onSignOutDevice ? (device: UserProfileDevice) => removalFocus.remove(device.id) : undefined; const [isSigningOutAll, setIsSigningOutAll] = useState(false); const [signOutAllError, setSignOutAllError] = useState(); @@ -104,7 +77,7 @@ export function UserProfileActiveDevicesSectionView({ deviceDetails.open(device)} /> @@ -153,7 +126,7 @@ export function UserProfileActiveDevicesSectionView({ deviceDetails.open(device)} /> @@ -165,7 +138,7 @@ export function UserProfileActiveDevicesSectionView({ ) : null} {onSignOutDevice ? ( @@ -176,7 +149,7 @@ export function UserProfileActiveDevicesSectionView({ description={device => fill(m.signOutDialog.description, { name: device.name })} actionLabel={m.signOutDialog.confirm} cancelLabel={m.signOutDialog.cancel} - finalFocus={focusAfterSignOut} + finalFocus={removalFocus.finalFocus} onConfirm={device => signOutDeviceAt?.(device)} /> ) : null} @@ -200,7 +173,7 @@ function DeviceItem({ onSignOut, }: { device: UserProfileDevice; - triggerRef: (element: HTMLButtonElement | null) => void; + triggerRef?: Ref; onViewDetails: (device: UserProfileDevice) => void; onSignOut?: (device: UserProfileDevice) => void; }) { 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..ee428cc0ef0 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx @@ -0,0 +1,81 @@ +import type { Ref } from 'react'; +import { useMemo } from 'react'; + +import { Dialog } from '../../components/dialog'; +import { Icon, IconFrame } from '../../components/icon'; +import { Section } from '../../components/section'; +import { fill } from '../../localization/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'; + +export function UserProfilePasskeyRowView({ + passkey, + triggerRef, + onRename, + onRemove, +}: { + passkey: UserProfilePasskey; + triggerRef?: Ref; + onRename?: (id: string, name: string) => void | Promise; + onRemove?: () => void; +}) { + const renameDialog = useMemo(() => Dialog.createHandle(), []); + 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 actions: UserProfileMenuAction[] = []; + if (onRename) { + actions.push({ label: m.rename, onClick: () => renameDialog.open(undefined) }); + } + if (onRemove) { + actions.push({ label: m.removeAction, color: 'negative', onClick: onRemove }); + } + + return ( + <> + + + + + + + + {passkey.name} + {description ? {description} : null} + + {actions.length > 0 ? ( + + + + ) : null} + + {onRename ? ( + + ) : 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..537fad1cc35 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts @@ -0,0 +1,19 @@ +export const userProfilePasskeysMessages = { + label: 'Passkeys', + add: 'Add', + addLabel: 'Add passkey', + empty: 'No passkeys added', + details: '{createdAt} · {lastUsedAt}', + 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.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.styles.ts new file mode 100644 index 00000000000..78853e524bf --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.styles.ts @@ -0,0 +1,15 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + addError: { + marginTop: { + default: space['2'], + ':where(:not([data-open]), [data-starting-style])': 0, + }, + }, + icon: { + 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 d9f744fbe11..f462de1c120 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,15 @@ +import { useMemo, useRef } from 'react'; + +import { Confirmation } from '../../blocks/confirmation'; +import { Button } from '../../components/button'; +import { Field } from '../../components/field'; +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 { useListRemovalFocus } from '../../hooks/useListRemovalFocus'; +import { fill } from '../../localization/messages'; +import { UserProfilePasskeyRowView } from './user-profile-passkey-row.view'; +import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; +import { styles } from './user-profile-passkeys-section.styles'; export interface UserProfilePasskey { id: string; @@ -15,56 +22,102 @@ export interface UserProfilePasskeysSectionViewProps { passkeys: UserProfilePasskey[]; sectionTitle?: string; onAdd?: () => void; - onManage?: (id: string) => void; - onRemove?: (id: string) => void; + addError?: string; + onRename?: (id: string, name: string) => void | Promise; + onRemove?: (id: string) => void | Promise; } export function UserProfilePasskeysSectionView({ passkeys, sectionTitle, onAdd, - onManage, + addError, + onRename, onRemove, }: UserProfilePasskeysSectionViewProps) { - return ( - 0} - label='Passkeys' - sectionTitle={sectionTitle} - onAdd={onAdd} - > - {passkeys.map(passkey => { - const actions: UserProfileMenuAction[] = []; + const addButton = useRef(null); + const section = useRef(null); + const removalFocus = useListRemovalFocus({ + ids: passkeys.map(passkey => passkey.id), + onRemove, + fallback: () => addButton.current ?? section.current, + }); + 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(' · ')} - + return ( + <> + + {sectionTitle ? {sectionTitle} : null} + + + + + {m.label} + + {onAdd ? ( + + + ) : null} - - - - - - ); - })} - + + + {addError} + + {passkeys.length > 0 ? ( + + {passkeys.map(passkey => ( + removePasskey.open(passkey) : undefined} + /> + ))} + + ) : ( + + + + {m.empty} + + + + )} + + + + {onRemove ? ( + fill(m.removeDescription, { name: passkey.name })} + actionLabel={m.remove} + finalFocus={removalFocus.finalFocus} + onConfirm={passkey => removalFocus.remove(passkey.id)} + /> + ) : 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, + }; +} 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..bba534685e3 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx @@ -0,0 +1,112 @@ +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} + + + + + + ); +} 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 526c14eeb83..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 @@ -38,11 +38,13 @@ export interface UserProfileSecurityPanelViewProps 'hasPassword' | 'requiresCurrentPassword' | 'managedBy' | 'onSubmitPassword' > { passkeys?: UserProfilePasskey[]; + passkeysVisible?: boolean; mfaMethods?: UserProfileMfaMethod[]; devices?: UserProfileDevice[]; onAddPasskey?: () => void; - onManagePasskey?: (id: string) => void; - onRemovePasskey?: (id: string) => void; + addPasskeyError?: string; + onRenamePasskey?: (id: string, name: string) => void | Promise; + onRemovePasskey?: (id: string) => void | Promise; onAddMfaMethod?: (type: UserProfileMfaAddableMethod) => void; onRegenerateBackupCodes?: () => void; onRemoveMfaMethod?: (id: string) => void; @@ -55,11 +57,13 @@ export function UserProfileSecurityPanelView({ requiresCurrentPassword, managedBy, passkeys, + passkeysVisible = true, mfaMethods, devices, onSubmitPassword, onAddPasskey, - onManagePasskey, + addPasskeyError, + onRenamePasskey, onRemovePasskey, onAddMfaMethod, onRegenerateBackupCodes, @@ -69,42 +73,42 @@ export function UserProfileSecurityPanelView({ onDeleteAccount, }: UserProfileSecurityPanelViewProps): ReactElement { const showPassword = hasPassword || Boolean(onSubmitPassword) || Boolean(managedBy); - const hasAuthentication = showPassword || passkeys !== undefined || mfaMethods !== undefined; + const showPasskeys = passkeys !== undefined && passkeysVisible; + const hasAuthentication = showPassword || showPasskeys || mfaMethods !== undefined; return (
Security
- {hasAuthentication ? ( -
- {showPassword ? ( - - ) : null} - {passkeys !== undefined ? ( - - ) : null} - {mfaMethods !== undefined ? ( - - ) : null} -
- ) : null} +
+ {showPassword ? ( + + ) : null} + {showPasskeys ? ( + + ) : null} + {mfaMethods !== undefined ? ( + + ) : null} +
{devices ? ( void | Promise; + fallback: () => HTMLElement | null; +}) { + const triggers = useRef(new Map()); + const removed = useRef<{ id: string; index: number } | undefined>(undefined); + + const registerTrigger = (id: string) => (element: HTMLButtonElement | null) => { + if (element) { + triggers.current.set(id, element); + } else { + triggers.current.delete(id); + } + }; + + const remove = async (id: string) => { + if (!onRemove) { + return; + } + const index = ids.indexOf(id); + await onRemove(id); + removed.current = { id, index }; + }; + + const finalFocus = () => { + const item = removed.current; + removed.current = undefined; + if (!item) { + return null; + } + const remaining = ids.filter(id => id !== item.id); + const next = remaining[Math.min(item.index, remaining.length - 1)]; + return (next ? triggers.current.get(next) : undefined) ?? fallback(); + }; + + return { registerTrigger, remove, finalFocus }; +} diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 0cf1cd1eda3..240125f4246 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -302,9 +302,11 @@ import { meta as userProfileMfaSectionMeta, } from '../stories/user-profile-mfa-section.stories'; import { + CreationUnavailable as UserProfilePasskeysSectionCreationUnavailable, Default as UserProfilePasskeysSectionDefault, Empty as UserProfilePasskeysSectionEmpty, meta as userProfilePasskeysSectionMeta, + RecoverableErrors as UserProfilePasskeysSectionRecoverableErrors, } from '../stories/user-profile-passkeys-section.stories'; import { Default as UserProfilePasswordSectionDefault, @@ -649,8 +651,10 @@ const userProfilePasswordSectionModule: StoryModule = { }; const userProfilePasskeysSectionModule: StoryModule = { meta: userProfilePasskeysSectionMeta, + CreationUnavailable: UserProfilePasskeysSectionCreationUnavailable, Default: UserProfilePasskeysSectionDefault, Empty: UserProfilePasskeysSectionEmpty, + RecoverableErrors: UserProfilePasskeysSectionRecoverableErrors, }; const userProfileMfaSectionModule: StoryModule = { meta: userProfileMfaSectionMeta, 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..3c1ecc5247b --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-passkeys.ts @@ -0,0 +1,61 @@ +import type { UserProfilePasskey } from '@clerk/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..24edb2b66ff 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -8,10 +8,7 @@ import type { UserProfileEmail, UserProfilePhone, } 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 type { UserProfileMfaMethod } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; import { useMemo, useState } from 'react'; import { usePreviewImage } from './use-preview-image'; @@ -22,6 +19,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 +63,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 +137,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 +147,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.mdx b/packages/swingset/src/stories/user-profile-passkeys-section.mdx index b4ba13cebb8..b36689c2f9c 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.mdx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.mdx @@ -2,16 +2,47 @@ import * as Stories from './user-profile-passkeys-section.stories'; # UserProfilePasskeysSection -Passkey management composed with the shared Security list treatment. - - - - +## 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 + +### Visibility + +The caller decides whether to render the section. When mounted, the section stays visible even with no passkeys or actions. + +### No passkeys yet + +The section remains visible after removing the final passkey. Add remains available when its callback is supplied. + + + +### Creation unavailable + +Omit `onAdd` to hide Add. Existing passkeys can still be renamed or removed, and removing the final passkey leaves the empty state visible. + + + +### 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..f57448d1b14 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx @@ -1,9 +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 { useState } from 'react'; +import type { ReactElement } 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 = { @@ -15,46 +16,35 @@ 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 fixture = usePasskeysFixture({ empty, failOnce }); return ( - 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))} /> ); } -export function Empty() { - const [passkeys, setPasskeys] = useState([]); +export function Default(): ReactElement { + return ; +} +export function Empty(): ReactElement { + return ; +} + +export function CreationUnavailable(): ReactElement { + const fixture = usePasskeysFixture(); 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))} + onAdd={undefined} /> ); } + +export function RecoverableErrors(): ReactElement { + return ; +} 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..8f310349c09 100644 --- a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx @@ -1,7 +1,4 @@ -import type { - UserProfileMfaMethod, - UserProfilePasskey, -} from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; +import type { UserProfileMfaMethod } 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 +6,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 +21,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 +33,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 +52,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} />