diff --git a/.changeset/mosaic-active-device-dialogs.md b/.changeset/mosaic-active-device-dialogs.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-active-device-dialogs.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/mosaic/references/views.md b/.claude/skills/mosaic/references/views.md index 1bc0f536243..3a6a9cca927 100644 --- a/.claude/skills/mosaic/references/views.md +++ b/.claude/skills/mosaic/references/views.md @@ -80,6 +80,48 @@ Which affordance lands in which slot, and how a consumer's `order` array rearranges a list, are decisions with no React in them. They live in `*.layout.ts` / `*.utils.ts` and get their own tests — the view calls the result. +## Removing a row takes focus with it + +A dialog returns focus to its trigger on close. When the action removes the row +that trigger sits on — signing a device out, removing an email — the trigger +unmounts and focus falls to ``. Nothing catches it: the menu item that +opened the dialog unmounted with the menu, and a dialog mounted at the section +rather than inside the menu has no floating-tree ancestor to walk back to. A +keyboard user loses their place mid-list and a screen reader announces nothing. + +Hand focus to a surviving element. `finalFocus` on `Dialog.Popup` and on the +`Confirmation` block takes a function, resolved when the dialog closes — which is +after the row has gone, so it can pick from what is left: + +```tsx +const triggers = useRef(new Map()); +const removed = useRef(undefined); + +const removeRow = async (row: Row) => { + const index = rows.findIndex(candidate => candidate.id === row.id); + await onRemove(row.id); + // Only once it is really gone: a cancelled or failed attempt keeps its own trigger. + removed.current = index; +}; + +const focusAfterRemove = () => { + const index = removed.current; + removed.current = undefined; + if (index === undefined) { + return null; // null keeps the default — the trigger, which is still there + } + const next = rows[Math.min(index, rows.length - 1)] ?? anchorRow; + return (next && triggers.current.get(next.id)) ?? null; +}; +``` + +Prefer the row that took the removed one's place, the last row when it was the +last, and a control that outlives the list once it is empty. + +Test the removal, not just the cancel: `toHaveFocus()` on the row that should +have caught it. A suite that only asserts focus after cancelling passes while +every successful removal drops focus on the floor. + ## Testing Render the view directly with **plain props and `vi.fn()` callbacks**. No Clerk diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.tsx b/packages/mosaic/src/blocks/confirmation/confirmation.tsx index 43ff3ce7cbc..2b38b622b71 100644 --- a/packages/mosaic/src/blocks/confirmation/confirmation.tsx +++ b/packages/mosaic/src/blocks/confirmation/confirmation.tsx @@ -3,11 +3,16 @@ import type { ReactNode } from 'react'; import { Banner } from '../../components/banner'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; -import type { DialogHandle, DialogTriggerProps } from '../../components/dialog'; +import type { DialogFocusTarget, DialogHandle, DialogTriggerProps } from '../../components/dialog'; import { Dialog } from '../../components/dialog'; import { useConfirmationController } from './confirmation.controller'; +/** The weight the confirming button carries: an undoable action takes `primary`. */ +export type ConfirmationColor = 'negative' | 'primary'; + interface ConfirmationCardProps { + color: ConfirmationColor; + finalFocus: DialogFocusTarget | undefined; title: string; description: ReactNode; actionLabel: string; @@ -18,6 +23,8 @@ interface ConfirmationCardProps { } function ConfirmationCard({ + color, + finalFocus, title, description, actionLabel, @@ -27,7 +34,10 @@ function ConfirmationCard({ errorMessage, }: ConfirmationCardProps) { return ( - + @@ -75,6 +85,13 @@ function ConfirmationCard({ export interface ConfirmationControlledProps { /** Whether the dialog is open */ open: boolean; + /** The weight the confirming button carries. An action that can be undone takes `primary` (default: `negative`) */ + color?: ConfirmationColor; + /** + * Where focus returns when the dialog closes. Default: the trigger — which a confirmed removal + * may have taken off the page, so a list hands back the row that replaced it instead. + */ + finalFocus?: DialogFocusTarget; /** Callback when open state changes */ onOpenChange: (open: boolean) => void; /** Element that opens the dialog */ @@ -97,6 +114,8 @@ export interface ConfirmationControlledProps { function ControlledConfirmation({ open, + color = 'negative', + finalFocus, onOpenChange, trigger, title, @@ -115,6 +134,8 @@ function ControlledConfirmation({ > {trigger ? : null} (value: FromPayload, payload: Pa export interface ConfirmationHandleProps { /** Opens the dialog with a payload from anywhere: `handle.open(payload)` */ handle: ConfirmationHandle; + /** The weight the confirming button carries. An action that can be undone takes `primary` (default: `negative`) */ + color?: ConfirmationColor; + /** + * Where focus returns when the dialog closes. Default: the trigger — which a confirmed removal + * may have taken off the page, so a list hands back the row that replaced it instead. + */ + finalFocus?: DialogFocusTarget; /** Dialog heading, or a function of the payload */ title: FromPayload; /** What the action does and why it warrants a second look, or a function of the payload. Takes markup, for a name to emphasise */ @@ -166,6 +194,8 @@ export interface ConfirmationHandleProps { function HandleConfirmation({ handle, + color = 'negative', + finalFocus, title, description, actionLabel, @@ -184,6 +214,8 @@ function HandleConfirmation({ {({ payload }) => payload === undefined ? null : ( ({ export type ConfirmationProps = ConfirmationControlledProps | ConfirmationHandleProps; /** - * Confirmation dialog for a destructive action that is worth a second look but not worth - * making the user type for. Use `Destructive` for the actions that are. + * Confirmation dialog for an action worth a second look but not worth making the user type for. + * Use `Destructive` for the destructive actions that are. `color` sets the weight the confirming + * button carries: `negative` for what cannot be undone, `primary` for what can. * * An `alertdialog`: it announces as an interruption, an outside press cannot answer it, and the * card withholds its corner dismiss. Escape still closes it, the way the cancel action does. Under diff --git a/packages/mosaic/src/blocks/confirmation/index.ts b/packages/mosaic/src/blocks/confirmation/index.ts index 75e24b29ed1..b161658f063 100644 --- a/packages/mosaic/src/blocks/confirmation/index.ts +++ b/packages/mosaic/src/blocks/confirmation/index.ts @@ -1,5 +1,6 @@ export { Confirmation } from './confirmation'; export type { + ConfirmationColor, ConfirmationControlledProps, ConfirmationHandle, ConfirmationHandleProps, diff --git a/packages/mosaic/src/components/card/card.styles.ts b/packages/mosaic/src/components/card/card.styles.ts index 11a696bd8c3..fadcdc64cb5 100644 --- a/packages/mosaic/src/components/card/card.styles.ts +++ b/packages/mosaic/src/components/card/card.styles.ts @@ -49,7 +49,7 @@ export const header = stylex.create({ display: 'flex', flexDirection: 'column', flexGrow: '1', - rowGap: space['1'], + rowGap: space['0.5'], }, title: { color: colorVars['--cl-color-foreground'], diff --git a/packages/mosaic/src/components/data-list/data-list.styles.ts b/packages/mosaic/src/components/data-list/data-list.styles.ts new file mode 100644 index 00000000000..87cb102bcf5 --- /dev/null +++ b/packages/mosaic/src/components/data-list/data-list.styles.ts @@ -0,0 +1,56 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const list = stylex.create({ + base: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-lg'], + borderStyle: 'solid', + borderWidth: '1px', + paddingInline: space['3'], + backgroundColor: colorVars['--cl-color-background-subtle'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +}); + +export const item = stylex.create({ + base: { + paddingBlock: space['3'], + alignItems: 'baseline', + columnGap: space['6'], + display: 'flex', + justifyContent: 'space-between', + }, + divided: { + borderBlockEndColor: colorVars['--cl-color-border'], + borderBlockEndStyle: 'solid', + borderBlockEndWidth: { + default: '1px', + ':last-child': 0, + }, + }, +}); + +export const label = stylex.create({ + base: { + color: colorVars['--cl-color-foreground'], + flexShrink: 0, + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, +}); + +export const value = stylex.create({ + base: { + color: colorVars['--cl-color-foreground-secondary'], + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-normal'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + textAlign: 'end', + minWidth: 0, + }, +}); diff --git a/packages/mosaic/src/components/data-list/data-list.test.tsx b/packages/mosaic/src/components/data-list/data-list.test.tsx new file mode 100644 index 00000000000..39d51ba980c --- /dev/null +++ b/packages/mosaic/src/components/data-list/data-list.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { DataList } from './data-list'; + +function renderList(props?: { divided?: boolean }) { + return render( + + + + IP address + 2600:100e:b10b:787b + + + , + ); +} + +describe('DataList', () => { + it('pairs each value with its label', () => { + const { container } = renderList(); + + const label = screen.getByText('IP address'); + const value = screen.getByText('2600:100e:b10b:787b'); + expect(label.tagName).toBe('DT'); + expect(value.tagName).toBe('DD'); + expect(container.querySelector('dl')).toContainElement(label); + }); + + it('carries the slot classes a theme targets', () => { + const { container } = renderList(); + + expect(container.querySelector('.cl-data-list')).toBeInTheDocument(); + expect(container.querySelector('.cl-data-list-item')).toBeInTheDocument(); + expect(screen.getByText('IP address')).toHaveClass('cl-data-list-label'); + expect(screen.getByText('2600:100e:b10b:787b')).toHaveClass('cl-data-list-value'); + }); + + it('reflects whether the items are ruled', () => { + const { container, unmount } = renderList(); + expect(container.querySelector('.cl-data-list')).toHaveAttribute('data-divided'); + unmount(); + + const plain = renderList({ divided: false }); + expect(plain.container.querySelector('.cl-data-list')).not.toHaveAttribute('data-divided'); + }); +}); diff --git a/packages/mosaic/src/components/data-list/data-list.tsx b/packages/mosaic/src/components/data-list/data-list.tsx new file mode 100644 index 00000000000..5763a07326f --- /dev/null +++ b/packages/mosaic/src/components/data-list/data-list.tsx @@ -0,0 +1,109 @@ +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { truncationStyles } from '../../utils/typography.styles'; +import * as slots from './data-list.styles'; + +export type DataListProps = MosaicComponentProps<'dl'> & { + /** + * Rules between the items, for a list read row by row. Turn it off where the pairs are few + * enough to scan as a block. + * + * @default true + */ + divided?: boolean; +}; + +const DataListContext = React.createContext(true); + +/** + * Root list. Renders a `
` and provides its `divided` to the items within it. + * + * @example + * + * + * IP address + * 2600:100e:b10b:787b + * + * + */ +const Root = React.forwardRef(function MosaicDataList( + { divided = true, render, xstyle, ...rest }, + ref, +) { + const element = useRender({ + defaultTagName: 'dl', + render, + ref, + props: { + ...mergeStyleProps(themeProps('data-list', { divided }), stylex.props(reset.base, slots.list.base, xstyle), rest), + }, + }); + + return {element}; +}); + +const Item = React.forwardRef>(function MosaicDataListItem( + { render, xstyle, ...rest }, + ref, +) { + const divided = React.useContext(DataListContext); + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('data-list-item'), + stylex.props(reset.base, slots.item.base, divided && slots.item.divided, xstyle), + rest, + ), + }, + }); +}); + +const Label = React.forwardRef>(function MosaicDataListLabel( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'dt', + render, + ref, + props: { + ...mergeStyleProps(themeProps('data-list-label'), stylex.props(reset.base, slots.label.base, xstyle), rest), + }, + }); +}); + +const Value = React.forwardRef>(function MosaicDataListValue( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'dd', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('data-list-value'), + stylex.props(reset.base, slots.value.base, truncationStyles.singleLine, xstyle), + rest, + ), + }, + }); +}); + +/** + * Mosaic `DataList` — label/value pairs for read-only detail, such as the fields describing a + * device or a payment method. Composed via dot syntax: `DataList.Root`, `DataList.Item`, + * `DataList.Label`, `DataList.Value`. Every part takes a `render` prop and forwards a ref. + * + * Semantics come free: the parts render `dl`/`dt`/`dd`, so assistive technology reads each value + * with the label it belongs to. + */ +export const DataList = { Root, Item, Label, Value }; diff --git a/packages/mosaic/src/components/data-list/index.ts b/packages/mosaic/src/components/data-list/index.ts new file mode 100644 index 00000000000..5ba0c71d82c --- /dev/null +++ b/packages/mosaic/src/components/data-list/index.ts @@ -0,0 +1,2 @@ +export { DataList } from './data-list'; +export type { DataListProps } from './data-list'; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx new file mode 100644 index 00000000000..2bd61025e42 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx @@ -0,0 +1,315 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +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 { UserProfileDevice } from '../user-profile-active-devices.types'; +import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section.view'; + +const current: UserProfileDevice = { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, +}; + +const mobile: UserProfileDevice = { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + lastActive: '4 days ago', + model: 'iPhone 16 Pro', + browser: 'Safari 18.4', + ipAddress: '2600:100e:b10b:787b:e8ae:6e75', + location: 'Orem, UT, United States', + signedInAt: 'July 5th, 2026', +}; + +function renderDevices(onSignOutDevice?: (id: string) => void | Promise) { + return render( + + + , + ); +} + +async function openMenu(user: ReturnType, device: UserProfileDevice) { + await user.click(screen.getByRole('button', { name: `Manage ${device.name}` })); +} + +describe('active device details', () => { + it('opens the details of the device the menu belongs to', async () => { + const user = userEvent.setup(); + renderDevices(); + await openMenu(user, mobile); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByRole('heading', { name: 'Safari on iOS' })).toBeInTheDocument(); + expect(within(dialog).getByText('Last active 4 days ago')).toBeInTheDocument(); + expect(within(dialog).getByText('iPhone 16 Pro')).toBeInTheDocument(); + expect(within(dialog).getByText('2600:100e:b10b:787b:e8ae:6e75')).toBeInTheDocument(); + expect(within(dialog).getByText('July 5th, 2026')).toBeInTheDocument(); + }); + + it('omits the rows a device has no detail for', async () => { + const user = userEvent.setup(); + renderDevices(); + await openMenu(user, current); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + + expect(within(screen.getByRole('dialog')).queryByText('Browser')).not.toBeInTheDocument(); + }); + + it('closes the current device from the footer, with nothing to sign out', async () => { + const user = userEvent.setup(); + renderDevices(vi.fn()); + await openMenu(user, current); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).queryByRole('button', { name: 'Sign out' })).not.toBeInTheDocument(); + // The header's corner dismiss is also named Close, so this picks the footer's by its text. + await user.click(within(dialog).getByText('Close')); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); +}); + +describe('active device sign out', () => { + it('confirms before signing a device out', async () => { + const user = userEvent.setup(); + const onSignOutDevice = vi.fn(); + renderDevices(onSignOutDevice); + await openMenu(user, mobile); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + + const confirmation = screen.getByRole('alertdialog'); + expect(within(confirmation).getByText(/Safari on iOS will be signed out/)).toBeInTheDocument(); + expect(onSignOutDevice).not.toHaveBeenCalled(); + + await user.click(within(confirmation).getByRole('button', { name: 'Sign out' })); + expect(onSignOutDevice).toHaveBeenCalledWith('mobile'); + }); + + it('shows a failure in the confirmation and allows retrying', async () => { + const user = userEvent.setup(); + const onSignOutDevice = vi.fn().mockRejectedValueOnce(new Error('Unable to sign out')).mockResolvedValue(undefined); + renderDevices(onSignOutDevice); + await openMenu(user, mobile); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to sign out'); + + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onSignOutDevice).toHaveBeenCalledTimes(2); + }); + + it('signs out from the details dialog without a second confirmation', async () => { + const user = userEvent.setup(); + function Example() { + const [devices, setDevices] = useState([current, mobile]); + return ( + + setDevices(list => list.filter(device => device.id !== id))} + /> + + ); + } + render(); + await openMenu(user, mobile); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument(); + }); + + it('holds the details dialog open while the sign out is in flight', async () => { + const user = userEvent.setup(); + const signOut = createDeferredPromise(); + renderDevices(() => signOut.promise); + await openMenu(user, mobile); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + const dialog = screen.getByRole('dialog'); + await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy')); + expect(dialog).toBeInTheDocument(); + + await act(async () => { + signOut.resolve(); + await signOut.promise; + }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('keeps the details dialog open and explains a failed sign out', async () => { + const user = userEvent.setup(); + const onSignOutDevice = vi.fn().mockRejectedValueOnce(new Error('Unable to sign out')).mockResolvedValue(undefined); + renderDevices(onSignOutDevice); + await openMenu(user, mobile); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to sign out'); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(onSignOutDevice).toHaveBeenCalledTimes(2); + }); +}); + +describe('signing out of all other devices', () => { + it('holds the button pending, then explains a failure under the row', async () => { + const user = userEvent.setup(); + const onSignOutAllOtherDevices = vi + .fn() + .mockRejectedValueOnce(new Error('Unable to sign out of all devices')) + .mockResolvedValue(undefined); + render( + + + , + ); + + const signOutAll = screen.getByRole('button', { name: 'Sign out of all devices' }); + await user.click(signOutAll); + + expect(await screen.findByText('Unable to sign out of all devices')).toBeInTheDocument(); + expect(signOutAll).not.toHaveAttribute('aria-busy'); + + await user.click(signOutAll); + await waitFor(() => expect(screen.queryByText('Unable to sign out of all devices')).not.toBeInTheDocument()); + expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(2); + }); + + it('ignores a second press while one is in flight', async () => { + const user = userEvent.setup(); + const signOutAll = createDeferredPromise(); + const onSignOutAllOtherDevices = vi.fn(() => signOutAll.promise); + render( + + + , + ); + + const button = screen.getByRole('button', { name: 'Sign out of all devices' }); + await user.click(button); + await waitFor(() => expect(button).toHaveAttribute('aria-busy')); + await user.click(button); + + expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(1); + + await act(async () => { + signOutAll.resolve(); + await signOutAll.promise; + }); + await waitFor(() => expect(button).not.toHaveAttribute('aria-busy')); + }); +}); + +describe('focus after signing a device out', () => { + const desktop: UserProfileDevice = { id: 'desktop', name: 'Clerk App on macOS', type: 'desktop' }; + + function Example({ devices: initial }: { devices: UserProfileDevice[] }) { + const [devices, setDevices] = useState(initial); + return ( + + setDevices(list => list.filter(device => device.id !== id))} + /> + + ); + } + + it('hands focus to the row that took its place, from the confirmation', async () => { + const user = userEvent.setup(); + render(); + await openMenu(user, mobile); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); + }); + + it('hands focus to the row that took its place, from the details dialog', async () => { + const user = userEvent.setup(); + render(); + await openMenu(user, mobile); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); + }); + + it('falls back to the last row, then to the current device', async () => { + const user = userEvent.setup(); + render(); + await openMenu(user, desktop); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toHaveFocus()); + + await openMenu(user, mobile); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus()); + }); + + it('skips the signed-out row when the list only catches up later', async () => { + const user = userEvent.setup(); + function LateExample() { + const [devices, setDevices] = useState([current, mobile, desktop]); + return ( + + { + setTimeout(() => setDevices(list => list.filter(device => device.id !== id)), 10); + return Promise.resolve(); + }} + /> + + ); + } + render(); + await openMenu(user, mobile); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus(); + }); + + it('still returns focus to the row itself when the sign out is cancelled', async () => { + const user = userEvent.setup(); + render(); + await openMenu(user, mobile); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toHaveFocus()); + }); +}); 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 5889a203db0..1d3d5109cdd 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,4 @@ -import { render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -113,6 +113,8 @@ describe('UserProfileSecurityPanelView', () => { const otherDevices = screen.getByRole('region', { name: 'Other devices' }); await user.click(within(otherDevices).getByRole('button', { name: 'Manage Safari on iOS' })); await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); // The danger zone confirms in a modal, so it goes last: nothing else is clickable while it is open. await user.click(screen.getByRole('button', { name: 'Delete account' })); @@ -147,14 +149,13 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByText('Password')).not.toBeInTheDocument(); }); - it('does not render actions for the current device', () => { - renderView({ - onManageDevice: vi.fn(), - onSignOutDevice: vi.fn(), - }); + it('withholds sign out from the current device', async () => { + const user = userEvent.setup(); + renderView({ onSignOutDevice: vi.fn() }); - expect(screen.queryByRole('button', { name: 'Manage Safari on macOS' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Manage Safari on macOS' })); + expect(screen.getByRole('menuitem', { name: 'View details' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Sign out' })).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-action-menu.tsx b/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx index 968831f482b..19750de3c17 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx @@ -1,3 +1,5 @@ +import type { Ref } from 'react'; + import { Icon } from '../../components/icon'; import { Menu } from '../../components/menu'; import type { IconName } from '../../icons/registry'; @@ -9,14 +11,26 @@ export interface UserProfileMenuAction { onClick: () => void; } -export function UserProfileActionMenu({ label, actions }: { label: string; actions: UserProfileMenuAction[] }) { +export function UserProfileActionMenu({ + label, + actions, + triggerRef, +}: { + label: string; + actions: UserProfileMenuAction[]; + /** The trigger element, for a caller that has to hand focus back to this row. */ + triggerRef?: Ref; +}) { if (actions.length === 0) { return null; } return ( - + {actions.map(action => ( void; - onSignOutDevice?: (id: string) => void; - onSignOutAllOtherDevices?: () => void; + onSignOutDevice?: (id: string) => void | Promise; + onSignOutAllOtherDevices?: () => void | Promise; } export function UserProfileActiveDevicesSectionView({ devices, - onManageDevice, onSignOutDevice, onSignOutAllOtherDevices, }: UserProfileActiveDevicesSectionViewProps) { + const m = useMessages('userProfileActiveDevices'); + const deviceDetails = useMemo(() => Dialog.createHandle(), []); + const signOutDevice = useMemo(() => Confirmation.createHandle(), []); const currentDevices = devices.filter(device => device.isCurrent); const otherDevices = devices.filter(device => !device.isCurrent); + 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 [isSigningOutAll, setIsSigningOutAll] = useState(false); + const [signOutAllError, setSignOutAllError] = useState(); + const signingOutAll = useRef(false); + + const signOutAllOtherDevices = async () => { + if (!onSignOutAllOtherDevices || signingOutAll.current) { + return; + } + signingOutAll.current = true; + setIsSigningOutAll(true); + setSignOutAllError(undefined); + try { + await onSignOutAllOtherDevices(); + } catch (error) { + setSignOutAllError(error instanceof Error ? error.message : m.signOutAllError); + } finally { + signingOutAll.current = false; + setIsSigningOutAll(false); + } + }; + return (
- Active devices + {m.title} {currentDevices.length > 0 ? ( currentDevices.map(device => ( - + deviceDetails.open(device)} + /> )) ) : ( - No current device available + {m.emptyCurrent} @@ -54,35 +121,41 @@ export function UserProfileActiveDevicesSectionView({ {otherDevices.length > 0 ? ( - + - {otherDevices.length} other {otherDevices.length === 1 ? 'device' : 'devices'} + {fill(otherDevices.length === 1 ? m.otherDevice : m.otherDevices, { + count: String(otherDevices.length), + })} {onSignOutAllOtherDevices ? ( - + {m.signOutAll} + ) : null} + {signOutAllError ? {signOutAllError} : null} {otherDevices.map(device => ( deviceDetails.open(device)} /> ))} @@ -90,45 +163,76 @@ export function UserProfileActiveDevicesSectionView({ ) : null} + + {onSignOutDevice ? ( + fill(m.signOutDialog.description, { name: device.name })} + actionLabel={m.signOutDialog.confirm} + cancelLabel={m.signOutDialog.cancel} + finalFocus={focusAfterSignOut} + onConfirm={device => signOutDeviceAt?.(device)} + /> + ) : null}
); } +function deviceBadges(device: UserProfileDevice, m: MosaicMessages['userProfileActiveDevices']): string[] { + const labels: (string | null)[] = [ + device.isCurrent ? m.thisDevice : null, + device.isUserDevice ? m.userDevice : null, + device.isImpersonationDevice ? m.impersonationDevice : null, + ]; + return labels.filter((label): label is string => label !== null); +} + function DeviceItem({ device, - onManage, + triggerRef, + onViewDetails, onSignOut, }: { device: UserProfileDevice; - onManage?: (id: string) => void; - onSignOut?: (id: string) => void; + triggerRef: (element: HTMLButtonElement | null) => void; + onViewDetails: (device: UserProfileDevice) => void; + onSignOut?: (device: UserProfileDevice) => void; }) { - const actions: UserProfileMenuAction[] = []; + const m = useMessages('userProfileActiveDevices'); + const actions: UserProfileMenuAction[] = [{ label: m.viewDetails, onClick: () => onViewDetails(device) }]; - if (onManage) { - actions.push({ label: 'Manage', onClick: () => onManage(device.id) }); - } if (onSignOut) { - actions.push({ label: 'Sign out', color: 'negative', onClick: () => onSignOut(device.id) }); + actions.push({ label: m.signOut, onClick: () => onSignOut(device) }); } return ( - {device.name} - {device.isCurrent || device.description ? ( - - {device.isCurrent ? This device : null} - {device.isCurrent && device.description ? · : null} - {device.description ? {device.description} : null} - - ) : null} + + {device.name} + {deviceBadges(device, m).map(label => ( + + {label} + + ))} + + {device.description ? {device.description} : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts new file mode 100644 index 00000000000..c4406934a8c --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts @@ -0,0 +1,32 @@ +export const userProfileActiveDevicesMessages = { + title: 'Active devices', + otherDevicesTitle: 'Other devices', + emptyCurrent: 'No current device available', + otherDevice: '{count} other device', + otherDevices: '{count} other devices', + signOutAll: 'Sign out of all devices', + signOutAllError: 'Something went wrong signing these devices out. Please try again.', + manageLabel: 'Manage {name}', + thisDevice: 'This device', + userDevice: "User's device", + impersonationDevice: 'Impersonation device', + viewDetails: 'View details', + signOut: 'Sign out', + detailsDialog: { + lastActive: 'Last active {lastActive}', + model: 'Device', + browser: 'Browser', + ipAddress: 'IP address', + location: 'Last location', + signedInAt: 'Original sign in', + signOut: 'Sign out', + signOutError: 'Something went wrong signing this device out. Please try again.', + close: 'Close', + }, + signOutDialog: { + title: 'Sign out of device', + description: '{name} will be signed out. Signing back in on that device will need your credentials again.', + confirm: 'Sign out', + cancel: 'Cancel', + }, +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices.types.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices.types.ts new file mode 100644 index 00000000000..0accde7ea7d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices.types.ts @@ -0,0 +1,20 @@ +export interface UserProfileDevice { + id: string; + name: string; + description?: string; + type: 'desktop' | 'mobile'; + isCurrent?: boolean; + /** The impersonated user's own device, seen from an impersonation session. */ + isUserDevice?: boolean; + /** A session an impersonator opened. */ + isImpersonationDevice?: boolean; + /** How long ago the device was last seen, already phrased: "4 days ago". */ + lastActive?: string; + /** The device itself, as the session reports it: "Macbook Pro". */ + model?: string; + browser?: string; + ipAddress?: string; + location?: string; + /** When the session on this device began, already formatted. */ + signedInAt?: string; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx new file mode 100644 index 00000000000..198e4d607d0 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx @@ -0,0 +1,139 @@ +import { useRef, useState } from 'react'; + +import { Banner } from '../../components/banner'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { DataList } from '../../components/data-list'; +import type { DialogFocusTarget, DialogHandle } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { fill, useMessages } from '../../localization'; +import type { UserProfileDevice } from './user-profile-active-devices.types'; + +export interface UserProfileDeviceDetailsDialogProps { + handle: DialogHandle; + finalFocus?: DialogFocusTarget; + onSignOut?: (device: UserProfileDevice) => void | Promise; +} + +export function UserProfileDeviceDetailsDialog({ handle, finalFocus, onSignOut }: UserProfileDeviceDetailsDialogProps) { + return ( + + {({ payload: device }) => + device === undefined ? null : ( + + + + ) + } + + ); +} + +function DeviceDetailsCard({ + device, + handle, + onSignOut, +}: { + device: UserProfileDevice; + handle: DialogHandle; + onSignOut: UserProfileDeviceDetailsDialogProps['onSignOut']; +}) { + const m = useMessages('userProfileActiveDevices'); + const [isSigningOut, setIsSigningOut] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + const signingOut = useRef(false); + + const signOut = async () => { + if (!onSignOut || signingOut.current) { + return; + } + signingOut.current = true; + setIsSigningOut(true); + setErrorMessage(undefined); + try { + await onSignOut(device); + handle.close(); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : m.detailsDialog.signOutError); + } finally { + signingOut.current = false; + setIsSigningOut(false); + } + }; + + const fields: { label: string; value: string | undefined }[] = [ + { label: m.detailsDialog.model, value: device.model }, + { label: m.detailsDialog.browser, value: device.browser }, + { label: m.detailsDialog.ipAddress, value: device.ipAddress }, + { label: m.detailsDialog.location, value: device.location }, + { label: m.detailsDialog.signedInAt, value: device.signedInAt }, + ]; + const details = fields.filter((field): field is { label: string; value: string } => Boolean(field.value)); + + return ( + + + {device.name} + {device.lastActive ? ( + {fill(m.detailsDialog.lastActive, { lastActive: device.lastActive })} + ) : null} + + {errorMessage || details.length > 0 ? ( + + {errorMessage ? ( + + {errorMessage} + + ) : null} + {details.length > 0 ? ( + + {details.map(detail => ( + + {detail.label} + {detail.value} + + ))} + + ) : null} + + ) : null} + + {onSignOut && !device.isCurrent ? ( + void signOut()} + > + {m.detailsDialog.signOut} + + ) : ( + + } + > + {m.detailsDialog.close} + + )} + + + ); +} 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 e2e2881ce5f..a743e88cd52 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 @@ -3,11 +3,9 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, radiusVars, space } from '../../tokens.stylex'; export const styles = stylex.create({ - currentDevice: { - color: colorVars['--cl-color-positive'], - }, - descriptionLine: { - columnGap: space['1'], + deviceLabel: { + alignItems: 'center', + columnGap: space['2'], display: 'flex', flexWrap: 'wrap', }, 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 2a652d3941c..526c14eeb83 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 @@ -64,7 +64,6 @@ export function UserProfileSecurityPanelView({ onAddMfaMethod, onRegenerateBackupCodes, onRemoveMfaMethod, - onManageDevice, onSignOutDevice, onSignOutAllOtherDevices, onDeleteAccount, @@ -109,7 +108,6 @@ export function UserProfileSecurityPanelView({ {devices ? ( diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index ef918abc594..278ebe1d727 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -6,6 +6,7 @@ import { userProfileAddEmailMessages } from '../features/user-profile/user-profi import { userProfileAddPhoneMessages } from '../features/user-profile/user-profile-account-section/user-profile-add-phone.messages'; import { userProfileVerifyEmailLinkMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-link.messages'; import { userProfileVerifyEmailSsoMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages'; +import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices.messages'; import { userProfileConnectedAccountsMessages } from '../features/user-profile/user-profile-connected-accounts.messages'; import { userProfileDeleteSectionMessages } from '../features/user-profile/user-profile-delete-section/user-profile-delete-section.messages'; import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages'; @@ -17,6 +18,7 @@ export const mosaicMessages = { userButton: userButtonMessages, userProfile: userProfileMessages, userProfileAccountSection: userProfileAccountSectionMessages, + userProfileActiveDevices: userProfileActiveDevicesMessages, userProfileAddEmail: userProfileAddEmailMessages, userProfileAddPhone: userProfileAddPhoneMessages, userProfileVerifyEmailLink: userProfileVerifyEmailLinkMessages, diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index 587d97c2e1a..1685197b650 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -44,6 +44,8 @@ export type { ComboboxRootProps, ComboboxSize, } from '../components/combobox'; +export { DataList } from '../components/data-list'; +export type { DataListProps } from '../components/data-list'; export { Dialog } from '../components/dialog'; export type { DialogCloseButtonProps, diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index f0288aabf09..373059cda2a 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -54,6 +54,7 @@ const docModules: Record> = { card: dynamic(() => import('../stories/card.component.mdx')), checkbox: dynamic(() => import('../stories/checkbox.mdx')), combobox: dynamic(() => import('../stories/combobox.mdx')), + 'data-list': dynamic(() => import('../stories/data-list.mdx')), input: dynamic(() => import('../stories/input.mdx')), 'input-group': dynamic(() => import('../stories/input-group.mdx')), 'phone-input': dynamic(() => import('../stories/phone-input.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index ecac1814886..0cf1cd1eda3 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -42,6 +42,7 @@ import { meta as confirmationMeta, WithError as ConfirmationWithError, } from '../stories/confirmation.stories'; +import { Default as DataListDefault, meta as dataListMeta, Plain as DataListPlain } from '../stories/data-list.stories'; import { Default as DestructiveDefault, meta as destructiveMeta, @@ -253,7 +254,9 @@ import { } from '../stories/user-profile-account-section.stories'; import { Default as UserProfileActiveDevicesSectionDefault, + Impersonation as UserProfileActiveDevicesSectionImpersonation, meta as userProfileActiveDevicesSectionMeta, + SignOutError as UserProfileActiveDevicesSectionSignOutError, } from '../stories/user-profile-active-devices-section.stories'; import { Default as UserProfileApiKeysPanelDefault, @@ -449,6 +452,12 @@ const profileComponentModule: StoryModule = { Transitions: ProfileTransitions, }; +const dataListModule: StoryModule = { + meta: dataListMeta, + Default: DataListDefault, + Plain: DataListPlain, +}; + const itemModule: StoryModule = { meta: itemMeta, Default: ItemDefault, @@ -651,6 +660,8 @@ const userProfileMfaSectionModule: StoryModule = { const userProfileActiveDevicesSectionModule: StoryModule = { meta: userProfileActiveDevicesSectionMeta, Default: UserProfileActiveDevicesSectionDefault, + SignOutError: UserProfileActiveDevicesSectionSignOutError, + Impersonation: UserProfileActiveDevicesSectionImpersonation, }; const userProfileSubscriptionSectionModule: StoryModule = { meta: userProfileSubscriptionSectionMeta, @@ -770,6 +781,7 @@ export const registry: StoryModule[] = [ inputModule, inputGroupModule, phoneInputModule, + dataListModule, itemModule, dialogComponentModule, drawerComponentModule, diff --git a/packages/swingset/src/stories/confirmation.mdx b/packages/swingset/src/stories/confirmation.mdx index 9c6bb2f53a1..14d91983307 100644 --- a/packages/swingset/src/stories/confirmation.mdx +++ b/packages/swingset/src/stories/confirmation.mdx @@ -17,7 +17,9 @@ import * as Stories from './confirmation.stories'; ## Usage -A confirmation for a destructive action that is worth a second look but not worth making the user type for. Removing a connected account, revoking a session, signing out everywhere. For the actions that do warrant typing, use [Destructive](/components/destructive). +A confirmation for an action worth a second look but not worth making the user type for. Removing a connected account, revoking a session, signing out everywhere. For the destructive actions that do warrant typing, use [Destructive](/components/destructive). + +`color` sets the weight the confirming button carries: `negative` for what cannot be undone, `primary` for what can. It is an `alertdialog`: it announces as an interruption, an outside press cannot dismiss it, and the card carries no corner dismiss. Escape and the footer's Cancel action dismiss it. Under the phone band it arrives as a bottom sheet. @@ -103,8 +105,10 @@ Controlled: | `trigger` | `ReactNode` | — | The button that asks to open the dialog. | | `title` | `string` | — (required) | Names what is about to happen. | | `description` | `ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | -| `actionLabel` | `string` | — (required) | The destructive button's label. | +| `actionLabel` | `string` | — (required) | The confirming button's label. | | `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | +| `color` | `'negative' \| 'primary'` | `'negative'` | The weight the confirming button carries. | +| `finalFocus` | `DialogFocusTarget` | the trigger | Where focus returns on close, for a removal that unmounts the trigger. | | `onConfirm` | `() => void` | — (required) | Asks the caller to run the action. | | `isConfirming` | `boolean` | `false` | Renders the action pending and ignores further presses. | | `errorMessage` | `string` | — | Renders as a negative banner above the actions. | @@ -116,8 +120,10 @@ With a handle: | `handle` | `ConfirmationHandle` | — (required) | From `Confirmation.createHandle()`. `handle.open(payload)` opens the block. | | `title` | `string \| (payload: Payload) => string` | — (required) | Names what is about to happen. | | `description` | `ReactNode \| (payload: Payload) => ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | -| `actionLabel` | `string \| (payload: Payload) => string` | — (required) | The destructive button's label. | +| `actionLabel` | `string \| (payload: Payload) => string` | — (required) | The confirming button's label. | | `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | +| `color` | `'negative' \| 'primary'` | `'negative'` | The weight the confirming button carries. | +| `finalFocus` | `DialogFocusTarget` | the trigger | Where focus returns on close, for a removal that unmounts the trigger. | | `onConfirm` | `(payload: Payload) => Promise \| void` | — (required) | Runs the action. Resolve to close; reject with an `Error` to keep it open showing why. | ## Driving it from a machine diff --git a/packages/swingset/src/stories/data-list.mdx b/packages/swingset/src/stories/data-list.mdx new file mode 100644 index 00000000000..74fcce41bf9 --- /dev/null +++ b/packages/swingset/src/stories/data-list.mdx @@ -0,0 +1,59 @@ +import * as DataListStories from './data-list.stories'; + +# DataList + + + +## Usage + +```tsx +import { DataList } from '@clerk/mosaic/components/data-list'; + + + + IP address + 2600:100e:b10b:787b:e8ae:6e75 + +; +``` + +`divided={false}` drops the rules between items, for pairs few enough to scan as a block. + + + +## Parts + +| Part | Element | Class | Description | +| ---------------- | ------- | --------------------- | ---------------------------------------------------------------------------- | +| `DataList.Root` | `dl` | `.cl-data-list` | The panel. Owns the gutter and `divided`, and passes `divided` to the items. | +| `DataList.Item` | `div` | `.cl-data-list-item` | One pair, laid out as a row. | +| `DataList.Label` | `dt` | `.cl-data-list-label` | What the value is. | +| `DataList.Value` | `dd` | `.cl-data-list-value` | The value itself. | + +## Props + +### `DataList.Root` + +| Prop | Type | Default | Description | +| --------- | --------- | ------- | ------------------------ | +| `divided` | `boolean` | `true` | Rules between the items. | + +`DataList.Item`, `DataList.Label`, and `DataList.Value` take no props of their own. Every part accepts `render` and `xstyle` and forwards its ref. + +## Styling + +```css +.cl-data-list { + background-color: var(--cl-color-background); +} + +.cl-data-list[data-divided] .cl-data-list-item { + border-block-end-color: var(--cl-color-border-subtle); +} +``` diff --git a/packages/swingset/src/stories/data-list.stories.tsx b/packages/swingset/src/stories/data-list.stories.tsx new file mode 100644 index 00000000000..61bb6b890c4 --- /dev/null +++ b/packages/swingset/src/stories/data-list.stories.tsx @@ -0,0 +1,53 @@ +import { DataList } from '@clerk/mosaic/components/data-list'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './data-list.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + status: 'stable', + title: 'DataList', + label: 'Data list', + source: 'packages/mosaic/src/components/data-list/data-list.tsx', +}; + +export function Default() { + return ( + + + Device + Macbook Pro + + + Browser + Chrome 150.0.0.0 + + + IP address + 2600:100e:b10b:787b:e8ae:6e75 + + + Last location + 🇺🇸 Salt Lake City, UT, United States + + + ); +} + +export function Plain() { + return ( + + + Plan + Pro + + + Renews + July 5th, 2026 + + + ); +} diff --git a/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts b/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts new file mode 100644 index 00000000000..d0cdfa35327 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts @@ -0,0 +1,109 @@ +import type { UserProfileDevice } from '@clerk/mosaic/features/user-profile/user-profile-active-devices.types'; +import { useRef, useState } from 'react'; + +export const userProfileDevices: UserProfileDevice[] = [ + { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, + lastActive: 'just now', + model: 'Macbook Pro', + browser: 'Safari 18.4', + ipAddress: '2600:100e:b10b:787b:e8ae:6e75', + location: '🇺🇸 Salt Lake City, UT, United States', + signedInAt: 'July 5th, 2026', + }, + { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + lastActive: '4 days ago', + model: 'iPhone 16 Pro', + browser: 'Safari 18.4', + ipAddress: '2600:100e:b10b:787b:e8ae:6e75', + location: '🇺🇸 Orem, UT, United States', + signedInAt: 'May 2nd, 2026', + }, + { + id: 'desktop', + name: 'Clerk App on macOS', + description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', + type: 'desktop', + lastActive: 'May 14th, 2026', + model: 'Macbook Air', + browser: 'Chrome 150.0.0.0', + ipAddress: '192.168.1.24', + location: '🇺🇸 San Francisco, CA, United States', + signedInAt: 'March 3rd, 2026', + }, +]; + +export const userProfileImpersonationDevices: UserProfileDevice[] = [ + { + id: 'current', + name: 'Chrome on macOS', + description: 'San Francisco, CA, United States', + type: 'desktop', + isCurrent: true, + isImpersonationDevice: true, + lastActive: 'just now', + model: 'Macbook Pro', + browser: 'Chrome 150.0.0.0', + ipAddress: '192.168.1.24', + location: '🇺🇸 San Francisco, CA, United States', + signedInAt: 'July 5th, 2026', + }, + { + id: 'user', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + isUserDevice: true, + lastActive: '4 days ago', + model: 'iPhone 16 Pro', + browser: 'Safari 18.4', + ipAddress: '2600:100e:b10b:787b:e8ae:6e75', + location: '🇺🇸 Orem, UT, United States', + signedInAt: 'May 2nd, 2026', + }, +]; + +export interface UserProfileActiveDevicesFixtureOptions { + devices?: UserProfileDevice[]; + /** How long a sign out takes, so the pending button and its spinner are visible. */ + latency?: number; + /** Rejects the first sign out of each kind with this message, then succeeds. */ + failWith?: string; +} + +export function useUserProfileActiveDevicesFixture({ + devices: initialDevices = userProfileDevices, + latency = 1200, + failWith, +}: UserProfileActiveDevicesFixtureOptions = {}) { + const [devices, setDevices] = useState(initialDevices); + const failed = useRef({ device: false, all: false }); + + const settle = async (kind: 'device' | 'all') => { + await new Promise(resolve => setTimeout(resolve, latency)); + if (failWith && !failed.current[kind]) { + failed.current[kind] = true; + throw new Error(failWith); + } + }; + + return { + devices, + onSignOutDevice: async (id: string) => { + await settle('device'); + setDevices(current => current.filter(device => device.id !== id)); + }, + onSignOutAllOtherDevices: async () => { + await settle('all'); + setDevices(current => current.filter(device => device.isCurrent)); + }, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index e073830cadb..e3e4f450f82 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -9,13 +9,13 @@ import type { UserProfilePhone, } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; import type { - UserProfileDevice, UserProfileMfaMethod, UserProfilePasskey, } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; import { useMemo, useState } from 'react'; import { usePreviewImage } from './use-preview-image'; +import { useUserProfileActiveDevicesFixture } from './user-profile-active-devices'; import { createUserProfileAddEmailFixture } from './user-profile-add-email'; import { createUserProfileAddPhoneFixture } from './user-profile-add-phone'; import { useConnectedAccountsFixture } from './user-profile-connected-accounts'; @@ -77,27 +77,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, { id: 'backup', type: 'backup-codes' }, ]); - const [devices, setDevices] = useState([ - { - id: 'current', - name: 'Safari on macOS', - description: 'Salt Lake City, UT, United States', - type: 'desktop', - isCurrent: true, - }, - { - id: 'mobile', - name: 'Safari on iOS', - description: 'Last seen 2 weeks ago · Orem, UT, United States', - type: 'mobile', - }, - { - id: 'desktop', - name: 'Clerk App on macOS', - description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', - type: 'desktop', - }, - ]); + const activeDevices = useUserProfileActiveDevicesFixture(); const [subscription, setSubscription] = useState({ planName: 'Basic Plan', @@ -168,7 +148,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions ...editPassword, passkeys, mfaMethods, - devices, + devices: activeDevices.devices, onAddMfaMethod: type => setMfaMethods(current => [ ...current, @@ -180,13 +160,12 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, ]), onDeleteAccount: () => Promise.resolve(), - onManageDevice: () => undefined, onManagePasskey: () => undefined, onRegenerateBackupCodes: () => undefined, onRemoveMfaMethod: id => setMfaMethods(current => current.filter(method => method.id !== id)), onRemovePasskey: id => setPasskeys(current => current.filter(passkey => passkey.id !== id)), - onSignOutAllOtherDevices: () => setDevices(current => current.filter(device => device.isCurrent)), - onSignOutDevice: id => setDevices(current => current.filter(device => device.id !== id)), + onSignOutAllOtherDevices: activeDevices.onSignOutAllOtherDevices, + onSignOutDevice: activeDevices.onSignOutDevice, }, billing: { subscription, @@ -255,5 +234,5 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions }, }; - return { activePage, setActivePage, pages, addEmail, devices }; + return { activePage, setActivePage, pages, addEmail, devices: activeDevices.devices }; } diff --git a/packages/swingset/src/stories/user-profile-active-devices-section.mdx b/packages/swingset/src/stories/user-profile-active-devices-section.mdx index 6aa7f659b7e..f1a04716f56 100644 --- a/packages/swingset/src/stories/user-profile-active-devices-section.mdx +++ b/packages/swingset/src/stories/user-profile-active-devices-section.mdx @@ -2,10 +2,32 @@ import * as Stories from './user-profile-active-devices-section.stories'; # UserProfileActiveDevicesSection -The current device and other active sessions composed with `Section`. - + +## Sign out failure + +The first attempt of each kind fails, second attempt succeeds. + + + +## Impersonation + + diff --git a/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx index 96b45b3ad0f..5127d7e25a1 100644 --- a/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx @@ -1,9 +1,12 @@ -import type { UserProfileDevice } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section.view'; import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section.view'; -import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; +import { + userProfileImpersonationDevices, + useUserProfileActiveDevicesFixture, +} from './fixtures/user-profile-active-devices'; + export { default as __source } from './user-profile-active-devices-section.stories?raw'; export const meta: StoryMeta = { @@ -16,34 +19,26 @@ export const meta: StoryMeta = { }; export function Default() { - const [devices, setDevices] = useState([ - { - id: 'current', - name: 'Safari on macOS', - description: 'Salt Lake City, UT, United States', - type: 'desktop', - isCurrent: true, - }, - { - id: 'mobile', - name: 'Safari on iOS', - description: 'Last seen 2 weeks ago · Orem, UT, United States', - type: 'mobile', - }, - { - id: 'desktop', - name: 'Clerk App on macOS', - description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', - type: 'desktop', - }, - ]); + const devices = useUserProfileActiveDevicesFixture(); + + return ; +} + +export function SignOutError() { + const devices = useUserProfileActiveDevicesFixture({ + failWith: 'This device could not be signed out. Please try again.', + }); + + return ; +} + +export function Impersonation() { + const devices = useUserProfileActiveDevicesFixture({ devices: userProfileImpersonationDevices }); return ( undefined} - onSignOutAllOtherDevices={() => setDevices(current => current.filter(device => device.isCurrent))} - onSignOutDevice={id => setDevices(current => current.filter(device => device.id !== id))} + devices={devices.devices} + onSignOutDevice={devices.onSignOutDevice} /> ); } 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 5cb391dad9a..b89f1db42b9 100644 --- a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx @@ -1,5 +1,4 @@ import type { - UserProfileDevice, UserProfileMfaMethod, UserProfilePasskey, } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; @@ -8,6 +7,7 @@ import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; +import { useUserProfileActiveDevicesFixture } from './fixtures/user-profile-active-devices'; import { useUserProfileEditPasswordFixture } from './fixtures/user-profile-edit-password'; export { default as __source } from './user-profile-security-panel.stories?raw'; @@ -35,32 +35,12 @@ export function Default() { { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, { id: 'backup', type: 'backup-codes' }, ]); - const [devices, setDevices] = useState([ - { - id: 'current', - name: 'Safari on macOS', - description: 'Salt Lake City, UT, United States', - type: 'desktop', - isCurrent: true, - }, - { - id: 'mobile', - name: 'Safari on iOS', - description: 'Last seen 2 weeks ago · Orem, UT, United States', - type: 'mobile', - }, - { - id: 'desktop', - name: 'Clerk App on macOS', - description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', - type: 'desktop', - }, - ]); + const devices = useUserProfileActiveDevicesFixture(); return ( @@ -86,7 +66,6 @@ export function Default() { ]) } onDeleteAccount={() => Promise.resolve()} - onManageDevice={() => undefined} onManagePasskey={() => undefined} onRegenerateBackupCodes={() => setMfaMethods(current => @@ -95,8 +74,8 @@ export function Default() { } onRemoveMfaMethod={id => setMfaMethods(current => current.filter(method => method.id !== id))} onRemovePasskey={id => setPasskeys(current => current.filter(passkey => passkey.id !== id))} - onSignOutAllOtherDevices={() => setDevices(current => current.filter(device => device.isCurrent))} - onSignOutDevice={id => setDevices(current => current.filter(device => device.id !== id))} + onSignOutAllOtherDevices={devices.onSignOutAllOtherDevices} + onSignOutDevice={devices.onSignOutDevice} /> ); }