From 4247cffc9a656b1897e2cd95e8bac1991193c38f Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 17 Sep 2026 09:52:38 -0600 Subject: [PATCH 1/6] feat(mosaic): add active device dialogs and the DataList component Every active device takes a menu with View details and Sign out. View details opens a dialog listing that device's fields as a DataList. Sign out from the menu confirms first, since it acts on a row the user only pointed at; inside the details dialog it signs out directly, holding the button pending until the device is gone and staying open with the reason if it fails. The current device offers details only, and closes from its footer. Signing a device out unmounts the row the dialog was opened from, so the section hands focus to whichever row took its place rather than letting it fall to the body. Confirmation takes a finalFocus for the same reason, and the mosaic skill documents the trap. Adds neutral badges for the current, the user's own, and an impersonator's device, and a color prop on Confirmation so a reversible action confirms without reading destructive. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mosaic-active-device-dialogs.md | 7 + .claude/skills/mosaic/references/views.md | 42 +++ .../src/blocks/confirmation/confirmation.tsx | 43 ++- .../mosaic/src/blocks/confirmation/index.ts | 1 + .../mosaic/src/components/card/card.styles.ts | 2 +- .../components/data-list/data-list.styles.ts | 56 ++++ .../components/data-list/data-list.test.tsx | 48 +++ .../src/components/data-list/data-list.tsx | 109 +++++++ .../mosaic/src/components/data-list/index.ts | 2 + .../user-profile-device-actions.test.tsx | 288 ++++++++++++++++++ .../user-profile-security-panel.view.test.tsx | 17 +- .../user-profile/user-profile-action-menu.tsx | 18 +- ...er-profile-active-devices-section.view.tsx | 178 ++++++++--- .../user-profile-active-devices.messages.ts | 32 ++ .../user-profile-active-devices.types.ts | 20 ++ .../user-profile-device-details.dialog.tsx | 138 +++++++++ .../user-profile-security-panel.styles.ts | 8 +- .../user-profile-security-panel.view.tsx | 2 - packages/mosaic/src/styles/index.ts | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 12 + .../swingset/src/stories/confirmation.mdx | 12 +- packages/swingset/src/stories/data-list.mdx | 59 ++++ .../src/stories/data-list.stories.tsx | 53 ++++ .../fixtures/user-profile-active-devices.ts | 109 +++++++ .../src/stories/fixtures/user-profile.ts | 33 +- .../user-profile-active-devices-section.mdx | 28 +- ...profile-active-devices-section.stories.tsx | 49 ++- .../user-profile-security-panel.stories.tsx | 31 +- 29 files changed, 1251 insertions(+), 149 deletions(-) create mode 100644 .changeset/mosaic-active-device-dialogs.md create mode 100644 packages/mosaic/src/components/data-list/data-list.styles.ts create mode 100644 packages/mosaic/src/components/data-list/data-list.test.tsx create mode 100644 packages/mosaic/src/components/data-list/data-list.tsx create mode 100644 packages/mosaic/src/components/data-list/index.ts create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices.types.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx create mode 100644 packages/swingset/src/stories/data-list.mdx create mode 100644 packages/swingset/src/stories/data-list.stories.tsx create mode 100644 packages/swingset/src/stories/fixtures/user-profile-active-devices.ts diff --git a/.changeset/mosaic-active-device-dialogs.md b/.changeset/mosaic-active-device-dialogs.md new file mode 100644 index 00000000000..d5176aace84 --- /dev/null +++ b/.changeset/mosaic-active-device-dialogs.md @@ -0,0 +1,7 @@ +--- +'@clerk/mosaic': patch +--- + +Tighten the gap between a `Card` header's title and description from `4px` to `2px`. This applies to every card and card dialog, including the ones `UserButton` renders. + +`styles.css` also grows rules for the new internal `DataList` component and the user profile's active device dialogs. Neither is exported yet, so nothing else changes for consumers. 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..b5af7b3f93d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx @@ -0,0 +1,288 @@ +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('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 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(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 = 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 index = signedOut.current; + signedOut.current = undefined; + if (index === undefined) { + return null; + } + const remaining = devices.filter(device => !device.isCurrent); + const next = remaining[Math.min(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 +117,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 +159,74 @@ 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): string[] { + return [ + device.isCurrent ? m.thisDevice : null, + device.isUserDevice ? m.userDevice : null, + device.isImpersonationDevice ? m.impersonationDevice : null, + ].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 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).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..615497457b8 --- /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', + }, +}; 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..0cccd8f80d7 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx @@ -0,0 +1,138 @@ +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 } from '../../utils/messages'; +import { userProfileActiveDevicesMessages as m } from './user-profile-active-devices.messages'; +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 [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 details = [ + { 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 }, + ].filter((detail): detail is { label: string; value: string } => Boolean(detail.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/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..81a328540cb 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -105,6 +105,7 @@ import { meta as inputGroupMeta, Sizes as InputGroupSizes, } from '../stories/input-group.stories'; +import { Default as DataListDefault, meta as dataListMeta, Plain as DataListPlain } from '../stories/data-list.stories'; import { Default as ItemDefault, Group as ItemGroup, @@ -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..0bc95b2fe1f 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 { + useUserProfileActiveDevicesFixture, + userProfileImpersonationDevices, +} 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} /> ); } From d6a25f86dae88f0d5b40dba838c39373ae067248 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 17 Sep 2026 11:45:54 -0600 Subject: [PATCH 2/6] fix(swingset): sort the imports the build lints Co-Authored-By: Claude Opus 5 (1M context) --- packages/swingset/src/lib/registry.ts | 2 +- .../src/stories/user-profile-active-devices-section.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 81a328540cb..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, @@ -105,7 +106,6 @@ import { meta as inputGroupMeta, Sizes as InputGroupSizes, } from '../stories/input-group.stories'; -import { Default as DataListDefault, meta as dataListMeta, Plain as DataListPlain } from '../stories/data-list.stories'; import { Default as ItemDefault, Group as ItemGroup, 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 0bc95b2fe1f..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 @@ -3,8 +3,8 @@ import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user import type { StoryMeta } from '@/lib/types'; import { - useUserProfileActiveDevicesFixture, userProfileImpersonationDevices, + useUserProfileActiveDevicesFixture, } from './fixtures/user-profile-active-devices'; export { default as __source } from './user-profile-active-devices-section.stories?raw'; From 129b9b30baea4a14c489028737437d0c8fa11c1e Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 17 Sep 2026 11:53:28 -0600 Subject: [PATCH 3/6] docs(mosaic): reword the active device dialogs changeset Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mosaic-active-device-dialogs.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/mosaic-active-device-dialogs.md b/.changeset/mosaic-active-device-dialogs.md index d5176aace84..f04225c0caa 100644 --- a/.changeset/mosaic-active-device-dialogs.md +++ b/.changeset/mosaic-active-device-dialogs.md @@ -2,6 +2,4 @@ '@clerk/mosaic': patch --- -Tighten the gap between a `Card` header's title and description from `4px` to `2px`. This applies to every card and card dialog, including the ones `UserButton` renders. - -`styles.css` also grows rules for the new internal `DataList` component and the user profile's active device dialogs. Neither is exported yet, so nothing else changes for consumers. +Add DataList component and dialog interactions for Active Devices section in user profile. From 273405bed6e7f68372d5730f36dd3b0d2d980817 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 17 Sep 2026 15:22:54 -0600 Subject: [PATCH 4/6] fix(mosaic): keep focus off the row a sign out is removing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The focus target was picked by index alone, so a caller whose device list only catches up on a later refetch handed focus back to the row being signed out — and lost it again when that row went. It is now picked by id as well, so the row leaving is never the answer. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mosaic-active-device-dialogs.md | 2 +- .../user-profile-device-actions.test.tsx | 27 +++++++++++++++++++ ...er-profile-active-devices-section.view.tsx | 15 ++++++----- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/.changeset/mosaic-active-device-dialogs.md b/.changeset/mosaic-active-device-dialogs.md index f04225c0caa..3d26a936586 100644 --- a/.changeset/mosaic-active-device-dialogs.md +++ b/.changeset/mosaic-active-device-dialogs.md @@ -2,4 +2,4 @@ '@clerk/mosaic': patch --- -Add DataList component and dialog interactions for Active Devices section in user profile. +Tighten the spacing between a `Card` header's title and description from `0.25rem` to `0.125rem`. 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 index b5af7b3f93d..2bd61025e42 100644 --- 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 @@ -276,6 +276,33 @@ describe('focus after signing a device 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(); 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 aeb3a4d78b5..eeeb5be8535 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 @@ -36,7 +36,7 @@ export function UserProfileActiveDevicesSectionView({ const openSignOut = onSignOutDevice ? (device: UserProfileDevice) => signOutDevice.open(device) : undefined; const triggers = useRef(new Map()); - const signedOut = useRef(undefined); + const signedOut = useRef<{ id: string; index: number } | undefined>(undefined); const registerTrigger = (id: string) => (element: HTMLButtonElement | null) => { if (element) { @@ -52,20 +52,23 @@ export function UserProfileActiveDevicesSectionView({ 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 = index; + 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 index = signedOut.current; + const removed = signedOut.current; signedOut.current = undefined; - if (index === undefined) { + if (!removed) { return null; } - const remaining = devices.filter(device => !device.isCurrent); - const next = remaining[Math.min(index, remaining.length - 1)] ?? currentDevices[0]; + // 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; }; From be3715a4197a2b962c2a0e2dd1b80afeb5d4668a Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 17 Sep 2026 15:58:01 -0600 Subject: [PATCH 5/6] docs(mosaic): empty the active device dialogs changeset Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mosaic-active-device-dialogs.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.changeset/mosaic-active-device-dialogs.md b/.changeset/mosaic-active-device-dialogs.md index 3d26a936586..a845151cc84 100644 --- a/.changeset/mosaic-active-device-dialogs.md +++ b/.changeset/mosaic-active-device-dialogs.md @@ -1,5 +1,2 @@ --- -'@clerk/mosaic': patch --- - -Tighten the spacing between a `Card` header's title and description from `0.25rem` to `0.125rem`. From 9fb6fe93365ebe17fe41320bdec1b4c5c32f5f4d Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 17 Sep 2026 16:17:11 -0600 Subject: [PATCH 6/6] fix(mosaic): read the device strings through the localization provider `utils/messages` is gone: #9801 moved `fill` into `localization` and put every message bundle behind `useMessages`. The active device strings now go through the same registry, which is what the merged build was failing on. Co-Authored-By: Claude Opus 5 (1M context) --- .../user-profile-active-devices-section.view.tsx | 15 +++++++++------ .../user-profile-active-devices.messages.ts | 2 +- .../user-profile-device-details.dialog.tsx | 9 +++++---- packages/mosaic/src/localization/registry.ts | 2 ++ 4 files changed, 17 insertions(+), 11 deletions(-) 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 eeeb5be8535..f6ade3eee26 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 @@ -6,10 +6,10 @@ import { Badge } from '../../components/badge'; import { SubmitButton } from '../../components/button'; import { Dialog } from '../../components/dialog'; import { Section } from '../../components/section'; -import { fill } from '../../utils/messages'; +import type { MosaicMessages } from '../../localization'; +import { fill, useMessages } from '../../localization'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; -import { userProfileActiveDevicesMessages as m } from './user-profile-active-devices.messages'; import type { UserProfileDevice } from './user-profile-active-devices.types'; import { UserProfileDeviceDetailsDialog } from './user-profile-device-details.dialog'; import { UserProfileSecurityIcon } from './user-profile-security-icon'; @@ -28,6 +28,7 @@ export function UserProfileActiveDevicesSectionView({ onSignOutDevice, onSignOutAllOtherDevices, }: UserProfileActiveDevicesSectionViewProps) { + const m = useMessages('userProfileActiveDevices'); const deviceDetails = useMemo(() => Dialog.createHandle(), []); const signOutDevice = useMemo(() => Confirmation.createHandle(), []); const currentDevices = devices.filter(device => device.isCurrent); @@ -183,12 +184,13 @@ export function UserProfileActiveDevicesSectionView({ ); } -function deviceBadges(device: UserProfileDevice): string[] { - return [ +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, - ].filter((label): label is string => label !== null); + ]; + return labels.filter((label): label is string => label !== null); } function DeviceItem({ @@ -202,6 +204,7 @@ function DeviceItem({ onViewDetails: (device: UserProfileDevice) => void; onSignOut?: (device: UserProfileDevice) => void; }) { + const m = useMessages('userProfileActiveDevices'); const actions: UserProfileMenuAction[] = [{ label: m.viewDetails, onClick: () => onViewDetails(device) }]; if (onSignOut) { @@ -214,7 +217,7 @@ function DeviceItem({ {device.name} - {deviceBadges(device).map(label => ( + {deviceBadges(device, m).map(label => ( ; onSignOut: UserProfileDeviceDetailsDialogProps['onSignOut']; }) { + const m = useMessages('userProfileActiveDevices'); const [isSigningOut, setIsSigningOut] = useState(false); const [errorMessage, setErrorMessage] = useState(); const signingOut = useRef(false); @@ -68,13 +68,14 @@ function DeviceDetailsCard({ } }; - const details = [ + 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 }, - ].filter((detail): detail is { label: string; value: string } => Boolean(detail.value)); + ]; + const details = fields.filter((field): field is { label: string; value: string } => Boolean(field.value)); return (