From f4c390227c24b92b2f44bf3fb4973678e4b55511 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Tue, 29 Sep 2026 19:26:09 -0600
Subject: [PATCH 01/10] feat(mosaic): wire up user profile active devices
---
.changeset/clear-active-devices.md | 2 +
...er-profile-active-devices.feature.test.tsx | 334 ++++++++++++++++++
.../user-profile-device-actions.test.tsx | 46 +++
...ofile-active-devices-section.controller.ts | 73 ++++
...er-profile-active-devices-section.model.ts | 129 +++++++
.../user-profile-active-devices-section.tsx | 49 +++
...er-profile-active-devices-section.view.tsx | 3 +-
.../user-profile-active-devices.messages.ts | 10 +
.../user-profile-device-details.controller.ts | 64 ++++
.../user-profile-device-details.dialog.tsx | 49 +--
.../__tests__/useListRemovalFocus.test.ts | 21 ++
.../src/hooks/use-list-removal-focus.ts | 7 +-
12 files changed, 752 insertions(+), 35 deletions(-)
create mode 100644 .changeset/clear-active-devices.md
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts
create mode 100644 packages/mosaic/src/hooks/__tests__/useListRemovalFocus.test.ts
diff --git a/.changeset/clear-active-devices.md b/.changeset/clear-active-devices.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/clear-active-devices.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
new file mode 100644
index 00000000000..401af9058a3
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
@@ -0,0 +1,334 @@
+import type { ActClaim, SessionWithActivitiesJSON } from '@clerk/shared/types';
+import { createDeferredPromise } from '@clerk/shared/utils';
+import { act, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { describe, expect, it, vi } from 'vitest';
+
+import { fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
+import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
+import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section';
+
+const alice = fapiUser({ id: 'user_1' });
+
+function device(id: string, status: string, activity: Partial = {}) {
+ const { user: _user, ...session } = fapiSession({ id, user: alice });
+ return {
+ ...session,
+ user: null,
+ status,
+ latest_activity: {
+ object: 'session_activity',
+ id: `activity_${id}`,
+ browser_name: 'Safari',
+ browser_version: '18',
+ device_type: 'MacBook Pro',
+ city: 'Paris',
+ country: 'France',
+ ip_address: '192.0.2.1',
+ ...activity,
+ },
+ } satisfies SessionWithActivitiesJSON;
+}
+
+function serveDevices(
+ initialDevices: SessionWithActivitiesJSON[],
+ options: { currentActor?: ActClaim; failOnceId?: string; reverifyOnceId?: string } = {},
+) {
+ const devices = [...initialDevices];
+ let failed = false;
+ serveFapi({ client: fapiClient([fapiSession({ id: 'sess_current', user: alice, actor: options.currentActor })]) });
+ worker.use(
+ http.get(fapiUrl('/v1/me/sessions/active'), () => HttpResponse.json(devices)),
+ http.post(fapiUrl('/v1/me/sessions/:id/revoke'), ({ params }) => {
+ if (params.id === options.failOnceId && !failed) {
+ failed = true;
+ return HttpResponse.json(
+ { errors: [{ code: 'revoke_failed', message: 'Could not revoke device' }] },
+ { status: 400 },
+ );
+ }
+ if (params.id === options.reverifyOnceId && !failed) {
+ failed = true;
+ return HttpResponse.json(
+ { errors: [{ code: 'session_reverification_required', message: 'Verification required' }] },
+ { status: 400 },
+ );
+ }
+ const index = devices.findIndex(item => item.id === params.id);
+ const selected = devices[index];
+ if (!selected) {
+ return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
+ }
+ const revoked = { ...selected, status: 'revoked' };
+ devices[index] = revoked;
+ return HttpResponse.json({ response: revoked, client: null });
+ }),
+ );
+ return devices;
+}
+
+describe('Active devices', () => {
+ it('honors the loading fallback, including rendering nothing by default', async () => {
+ serveDevices([device('sess_current', 'active')]);
+ const pending = createDeferredPromise();
+ worker.use(
+ http.get(fapiUrl('/v1/me/sessions/active'), async () => {
+ await pending.promise;
+ return HttpResponse.json([device('sess_current', 'active')]);
+ }),
+ );
+ try {
+ const view = await renderWithClerk();
+ expect(view.container).toBeEmptyDOMElement();
+ view.rerender(Loading devices
} />);
+ expect(screen.getByText('Loading devices')).toBeVisible();
+ view.rerender();
+ expect(view.container).toBeEmptyDOMElement();
+ } finally {
+ pending.resolve();
+ }
+ expect(await screen.findByText('This device')).toBeVisible();
+ });
+
+ it('shows signed-in sessions with metadata and signs out another device', async () => {
+ const devices = serveDevices([
+ device('sess_other', 'active', { device_type: 'iPhone', is_mobile: true }),
+ device('sess_current', 'active'),
+ device('sess_pending', 'pending', { browser_name: undefined, device_type: undefined }),
+ device('sess_expired', 'expired'),
+ ]);
+ const { clerk } = await renderWithClerk();
+
+ expect(await clerk.user?.getSessions()).toHaveLength(4);
+
+ expect(await screen.findByRole('button', { name: 'Manage Safari on MacBook Pro' })).toBeInTheDocument();
+ expect(screen.getByText('This device')).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).toBeNull();
+ expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Manage Web browser on Desktop device' })).toBeInTheDocument();
+ expect(screen.queryByText('sess_expired')).toBeNull();
+
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Safari on iPhone' }));
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+ expect(within(screen.getByRole('dialog')).getByText('192.0.2.1')).toBeInTheDocument();
+ expect(within(screen.getByRole('dialog')).getByText('Paris, France')).toBeInTheDocument();
+ expect(within(screen.getByRole('dialog')).queryByText('Original sign in')).toBeNull();
+ await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' }));
+
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull());
+ expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked');
+ expect(screen.getByRole('button', { name: 'Manage Safari on MacBook Pro' })).toBeInTheDocument();
+ });
+
+ it('maps impersonation sessions to the distinct badges', async () => {
+ serveDevices(
+ [
+ device('sess_current', 'active', { device_type: 'Current' }),
+ device('sess_user', 'active', { device_type: 'User phone' }),
+ { ...device('sess_other', 'active', { device_type: 'Other browser' }), actor: { sub: 'admin_2' } },
+ ],
+ { currentActor: { sub: 'admin_1' } },
+ );
+ await renderWithClerk();
+
+ expect(await screen.findByText('This device')).toBeInTheDocument();
+ expect(screen.getByText("User's device")).toBeInTheDocument();
+ expect(screen.getByText('Impersonation device')).toBeInTheDocument();
+ });
+
+ it('keeps a device after a failed revoke and allows retrying', async () => {
+ const devices = serveDevices(
+ [device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })],
+ { failOnceId: 'sess_other' },
+ );
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
+ 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('Could not revoke device');
+ expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active');
+ expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeInTheDocument();
+
+ await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull();
+ expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked');
+ expect(devices.find(item => item.id === 'sess_current')?.status).toBe('active');
+ });
+
+ it.each(['user', 'session'])(
+ 'does not retry an old revoke after switching %s during reverification',
+ async switchKind => {
+ const nextUser = switchKind === 'user' ? fapiUser({ id: 'user_2' }) : alice;
+ const fapi = serveFapi({
+ client: fapiClient([
+ fapiSession({ id: 'sess_current', user: alice }),
+ fapiSession({ id: 'sess_next', user: nextUser }),
+ ]),
+ });
+ const devices = [
+ device('sess_current', 'active'),
+ device('sess_next', 'active', { device_type: 'Next laptop' }),
+ device('sess_other', 'active', { device_type: 'iPhone' }),
+ ];
+ const nextDevices = switchKind === 'user' ? devices.filter(item => item.id === 'sess_next') : devices;
+ const attempts: string[] = [];
+ worker.use(
+ http.get(fapiUrl('/v1/me/sessions/active'), () =>
+ HttpResponse.json(fapi.client.last_active_session_id === 'sess_next' ? nextDevices : devices),
+ ),
+ http.post(fapiUrl('/v1/me/sessions/:id/revoke'), ({ params }) => {
+ attempts.push(String(params.id));
+ return HttpResponse.json(
+ { errors: [{ code: 'session_reverification_required', message: 'Verification required' }] },
+ { status: 400 },
+ );
+ }),
+ );
+ const view = await renderWithClerk();
+ const verification = createDeferredPromise<() => void>();
+ vi.spyOn(view.clerk, '__internal_openReverification').mockImplementation(props => {
+ if (props?.afterVerification) {
+ verification.resolve(props.afterVerification);
+ }
+ });
+ view.rerender();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+ await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
+ const complete = await verification.promise;
+
+ await act(() => view.clerk.setActive({ session: 'sess_next' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(view.clerk.session?.id).toBe('sess_next');
+ expect(fapi.client.last_active_session_id).toBe('sess_next');
+ await act(() => complete());
+
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on Next laptop' }));
+ expect(screen.queryByRole('menuitem', { name: 'Sign out' })).toBeNull();
+ expect(attempts).toEqual(['sess_other']);
+ expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active');
+ if (switchKind === 'user') {
+ expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull();
+ }
+ expect(screen.queryByRole('alert')).toBeNull();
+ },
+ );
+
+ it('localizes relative and formatted activity dates with surrounding text', async () => {
+ const now = new Date();
+ const yesterdayLate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 30);
+ const todayEarly = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 30);
+ const older = new Date('2024-01-05T12:00:00Z');
+ serveDevices([
+ { ...device('sess_current', 'active'), last_active_at: yesterdayLate.getTime() },
+ { ...device('sess_today', 'active'), last_active_at: todayEarly.getTime() },
+ { ...device('sess_other', 'active'), last_active_at: older.getTime() },
+ ]);
+ await renderWithClerk(
+
+
+ ,
+ );
+
+ expect(await screen.findByText(/Vu hier/)).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ `Vu ${new Intl.RelativeTimeFormat('fr-FR', { numeric: 'auto' }).format(0, 'day')} · Paris, France`,
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(`Vu ${new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(older)} · Paris, France`),
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole('button', { name: 'Manage Safari sur MacBook Pro' })).toHaveLength(3);
+ });
+
+ it('keeps a device and closes quietly when reverification is cancelled', async () => {
+ const devices = serveDevices([device('sess_current', 'active'), device('sess_other', 'active')], {
+ reverifyOnceId: 'sess_other',
+ });
+ const view = await renderWithClerk();
+ const openReverification = vi.spyOn(view.clerk, '__internal_openReverification').mockImplementation(props => {
+ props?.afterVerificationCancelled?.();
+ });
+ view.rerender();
+ const user = userEvent.setup();
+ const other = await screen.findAllByRole('button', { name: 'Manage Safari on MacBook Pro' });
+ await user.click(other[1]);
+ 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')).toBeNull());
+ expect(openReverification).toHaveBeenCalledOnce();
+ expect(screen.queryByRole('alert')).toBeNull();
+ expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active');
+ expect(screen.getAllByRole('button', { name: 'Manage Safari on MacBook Pro' })).toHaveLength(2);
+ });
+
+ it('retries the revoke after reverification succeeds', async () => {
+ const devices = serveDevices([device('sess_current', 'active'), device('sess_other', 'active')], {
+ reverifyOnceId: 'sess_other',
+ });
+ const view = await renderWithClerk();
+ const openReverification = vi.spyOn(view.clerk, '__internal_openReverification').mockImplementation(props => {
+ props?.afterVerification?.();
+ });
+ view.rerender();
+ const user = userEvent.setup();
+ const other = await screen.findAllByRole('button', { name: 'Manage Safari on MacBook Pro' });
+ await user.click(other[1]);
+ 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.getAllByRole('button', { name: 'Manage Safari on MacBook Pro' })).toHaveLength(1),
+ );
+ expect(openReverification).toHaveBeenCalledOnce();
+ expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked');
+ });
+
+ it('keeps device details open until its pending sign out completes', async () => {
+ const otherDevice = device('sess_other', 'active', { device_type: 'iPhone' });
+ serveDevices([device('sess_current', 'active'), otherDevice]);
+ const pending = createDeferredPromise();
+ let requests = 0;
+ worker.use(
+ http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), async () => {
+ requests += 1;
+ await pending.promise;
+ return HttpResponse.json({ response: { ...otherDevice, status: 'revoked' }, client: null });
+ }),
+ );
+ await renderWithClerk();
+ const user = userEvent.setup();
+ try {
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
+ 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(requests).toBe(1));
+ await user.keyboard('{Escape}');
+ expect(screen.getByRole('dialog')).toBe(dialog);
+ await user.click(within(dialog).getByRole('button', { name: 'Close' }));
+ expect(screen.getByRole('dialog')).toBe(dialog);
+ expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy');
+ } finally {
+ pending.resolve();
+ }
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull();
+ expect(requests).toBe(1);
+ });
+});
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 4b9fb1a540b..55920ca900d 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
@@ -366,3 +366,49 @@ describe('focus after signing a device out', () => {
await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toHaveFocus());
});
});
+
+describe('pending device sign out dismissal', () => {
+ it.each(['Escape', 'close button', 'outside press'])(
+ 'keeps details open on %s until sign out completes',
+ async dismiss => {
+ const pending = createDeferredPromise();
+ const onSignOut = vi.fn(() => pending.promise.then(() => undefined));
+ const user = userEvent.setup();
+ renderDevices(onSignOut);
+ 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' }));
+ if (dismiss === 'Escape') {
+ await user.keyboard('{Escape}');
+ } else if (dismiss === 'close button') {
+ await user.click(within(dialog).getByRole('button', { name: 'Close' }));
+ } else {
+ await user.click(document.body);
+ }
+ expect(screen.getByRole('dialog')).toBe(dialog);
+ expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy');
+ expect(onSignOut).toHaveBeenCalledOnce();
+ await act(async () => {
+ pending.resolve();
+ await pending.promise;
+ });
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ },
+ );
+
+ it('allows details dismissal after sign out fails', async () => {
+ const pending = createDeferredPromise();
+ const user = userEvent.setup();
+ renderDevices(() => pending.promise.then(() => undefined));
+ 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' }));
+ act(() => {
+ pending.reject(new Error('Sign out failed'));
+ });
+ expect(await screen.findByRole('alert')).toHaveTextContent('Sign out failed');
+ await user.keyboard('{Escape}');
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
new file mode 100644
index 00000000000..2e98f46a5e2
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
@@ -0,0 +1,73 @@
+import { useEffect } from 'react';
+
+import { setup } from '../../machine/setup';
+import { useMachine } from '../../machine/useMachine';
+import type { UserProfileDevice } from './user-profile-active-devices.types';
+import type { UserProfileActiveDevicesModel } from './user-profile-active-devices-section.model';
+
+interface Context {
+ devices: UserProfileDevice[];
+ loadSessions: () => Promise;
+}
+
+type Event =
+ | { type: 'LOAD'; loadSessions: Context['loadSessions'] }
+ | { type: 'RETRY' }
+ | { type: 'REMOVED'; id: string };
+
+const { createMachine, assign, fromPromise } = setup();
+const load = {
+ target: 'loading',
+ actions: assign((_, event: Extract) => ({ loadSessions: event.loadSessions })),
+};
+
+const machine = createMachine({
+ id: 'activeDevices',
+ initial: 'idle',
+ context: { devices: [], loadSessions: () => Promise.resolve([]) },
+ states: {
+ idle: { on: { LOAD: load } },
+ loading: {
+ on: { LOAD: load },
+ invoke: fromPromise(context => context.loadSessions(), {
+ onDone: { target: 'ready', actions: assign((_, event) => ({ devices: event.output })) },
+ onError: 'error',
+ }),
+ },
+ error: { on: { LOAD: load, RETRY: 'loading' } },
+ ready: {
+ on: {
+ LOAD: load,
+ REMOVED: {
+ actions: assign((context, event) => ({ devices: context.devices.filter(device => device.id !== event.id) })),
+ },
+ },
+ },
+ },
+});
+
+export function useUserProfileActiveDevicesController(
+ model: Extract,
+) {
+ const [snapshot, send] = useMachine(machine);
+ const { loadSessions } = model;
+ useEffect(() => {
+ send({ type: 'LOAD', loadSessions });
+ }, [loadSessions, send]);
+
+ const revoke = async (id: string) => {
+ const removed = await model.revoke(id);
+ if (removed) {
+ send({ type: 'REMOVED', id });
+ }
+ return removed;
+ };
+
+ return {
+ status: snapshot.value === 'ready' ? 'ready' : snapshot.value === 'error' ? 'error' : 'loading',
+ devices: snapshot.context.devices,
+ retry: () => send({ type: 'RETRY' }),
+ onSignOutDevice: revoke,
+ // TODO: Wire the model's future bulk action to onSignOutAllOtherDevices and refresh the device list after it settles.
+ };
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
new file mode 100644
index 00000000000..085c4361e13
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
@@ -0,0 +1,129 @@
+import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error';
+import { useClerk, useReverification, useSession, useUser } from '@clerk/shared/react';
+import type { SessionWithActivitiesResource } from '@clerk/shared/types';
+import { useCallback } from 'react';
+
+import { fill, useLocale, useMessages } from '../../localization';
+import type { UserProfileDevice } from './user-profile-active-devices.types';
+
+export type UserProfileActiveDevicesModel =
+ | { status: 'loading' }
+ | { status: 'hidden' }
+ | {
+ status: 'ready';
+ identity: string;
+ loadSessions: () => Promise;
+ revoke: (id: string) => Promise;
+ };
+
+function lastActiveLabel(date: Date, locale: string): string {
+ const today = new Date();
+ const days = Math.round(
+ (Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) -
+ Date.UTC(today.getFullYear(), today.getMonth(), today.getDate())) /
+ 86_400_000,
+ );
+ if (Math.abs(days) <= 6) {
+ return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(days, 'day');
+ }
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(date);
+}
+
+export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesModel {
+ const clerk = useClerk();
+ const { isLoaded: isUserLoaded, user } = useUser();
+ const { isLoaded: isSessionLoaded, session } = useSession();
+ const locale = useLocale();
+ const m = useMessages('userProfileActiveDevices');
+ const userId = user?.id;
+ const sessionId = session?.id;
+
+ const revokeSession = useReverification(async (originIdentity: string, id: string): Promise => {
+ if (
+ !userId ||
+ !sessionId ||
+ originIdentity !== `${userId}:${sessionId}` ||
+ clerk.user?.id !== userId ||
+ clerk.session?.id !== sessionId ||
+ id === sessionId
+ ) {
+ throw new Error(m.signOutError);
+ }
+ const target = (await user?.getSessions())?.find(
+ item => item.id === id && (item.status === 'active' || item.status === 'pending'),
+ );
+ if (!target || clerk.user?.id !== userId || clerk.session?.id !== sessionId) {
+ throw new Error(m.signOutError);
+ }
+ await target.revoke();
+ });
+
+ const toDevice = useCallback(
+ (item: SessionWithActivitiesResource): UserProfileDevice => {
+ const activity = item.latestActivity;
+ const isCurrent = item.id === sessionId;
+ const isMobile = Boolean(activity.isMobile);
+ const model = activity.deviceType || (isMobile ? m.mobileDevice : m.desktopDevice);
+ const browser = [activity.browserName, activity.browserVersion].filter(Boolean).join(' ') || m.webBrowser;
+ const location = [activity.city, activity.country].filter(Boolean).join(', ');
+ const lastActive = lastActiveLabel(item.lastActiveAt, locale);
+ const description = [fill(m.lastSeen, { date: lastActive }), location].filter(Boolean).join(' · ');
+
+ return {
+ id: item.id,
+ name: fill(m.deviceName, { browser: activity.browserName || m.webBrowser, device: model }),
+ description,
+ type: isMobile ? 'mobile' : 'desktop',
+ isCurrent,
+ isUserDevice: Boolean(session?.actor && !item.actor && !isCurrent),
+ isImpersonationDevice: Boolean(item.actor && !isCurrent),
+ lastActive,
+ model: activity.deviceType || undefined,
+ browser,
+ ipAddress: activity.ipAddress,
+ location: location || undefined,
+ };
+ },
+ [sessionId, session?.actor, locale, m],
+ );
+
+ const loadSessions = useCallback(async (): Promise => {
+ if (!user || !sessionId) {
+ return [];
+ }
+ const items = await user.getSessions();
+ return items
+ .filter(item => item.status === 'active' || item.status === 'pending')
+ .sort((a, b) => Number(b.id === sessionId) - Number(a.id === sessionId))
+ .map(toDevice);
+ }, [user, sessionId, toDevice]);
+
+ if (!isUserLoaded || !isSessionLoaded) {
+ return { status: 'loading' };
+ }
+ if (!user || !session) {
+ return { status: 'hidden' };
+ }
+
+ return {
+ status: 'ready',
+ identity: `${user.id}:${session.id}`,
+ loadSessions,
+ // TODO: Add bulk revocation when a dedicated API is available, preserving the current session and reverification.
+ revoke: async id => {
+ try {
+ await revokeSession(`${user.id}:${session.id}`, id);
+ return true;
+ } catch (error) {
+ if (isClerkRuntimeError(error) && error.code === 'reverification_cancelled') {
+ return false;
+ }
+ if (isClerkAPIResponseError(error)) {
+ const first = error.errors[0];
+ throw new Error(first?.longMessage || first?.message || m.signOutError);
+ }
+ throw error;
+ }
+ },
+ };
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx
new file mode 100644
index 00000000000..7a6704c821a
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx
@@ -0,0 +1,49 @@
+import type { ReactNode } from 'react';
+
+import { Button } from '../../components/button';
+import { useMessages } from '../../localization';
+import { useUserProfileActiveDevicesController } from './user-profile-active-devices-section.controller';
+import type { UserProfileActiveDevicesModel } from './user-profile-active-devices-section.model';
+import { useUserProfileActiveDevicesModel } from './user-profile-active-devices-section.model';
+import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view';
+
+export function UserProfileActiveDevicesSection({ fallback = null }: { fallback?: ReactNode }) {
+ const model = useUserProfileActiveDevicesModel();
+ if (model.status === 'hidden') {
+ return null;
+ }
+ if (model.status === 'loading') {
+ return fallback;
+ }
+ return (
+
+ );
+}
+
+function ActiveDevices({
+ model,
+ fallback,
+}: {
+ model: Extract;
+ fallback: ReactNode;
+}) {
+ const controller = useUserProfileActiveDevicesController(model);
+ const m = useMessages('userProfileActiveDevices');
+
+ if (controller.status === 'loading') {
+ return fallback;
+ }
+ if (controller.status === 'error') {
+ return (
+
+ {m.loadError}
+
+
+ );
+ }
+ return ;
+}
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 ced3c9815ec..bef375b0dbe 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
@@ -21,7 +21,8 @@ export type { UserProfileDevice };
export interface UserProfileActiveDevicesSectionViewProps {
devices: UserProfileDevice[];
- onSignOutDevice?: (id: string) => void | Promise;
+ onSignOutDevice?: (id: string) => void | boolean | Promise;
+ // TODO: Connect bulk sign-out when the model and controller support it; keep its confirmation open while pending.
onSignOutAllOtherDevices?: () => void | Promise;
}
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
index ed29beef3b4..914cf6ee349 100644
--- 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
@@ -1,6 +1,16 @@
export const userProfileActiveDevicesMessages = {
title: 'Active devices',
emptyCurrent: 'No current device available',
+ deviceName: '{browser} on {device}',
+ desktopDevice: 'Desktop device',
+ mobileDevice: 'Mobile device',
+ webBrowser: 'Web browser',
+ lastSeen: 'Last seen {date}',
+ loadError: 'Could not load active devices.',
+ retry: 'Try again',
+ signOutError: 'This device is no longer available. Please try again.',
+ 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.',
signOutAllDialog: {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts
new file mode 100644
index 00000000000..a944452e1c7
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts
@@ -0,0 +1,64 @@
+import { setup } from '../../machine/setup';
+import { useMachine } from '../../machine/useMachine';
+import type { UserProfileDevice } from './user-profile-active-devices.types';
+
+interface Context {
+ run: () => Promise;
+ errorMessage: string | undefined;
+ fallbackError: string;
+}
+
+type Event = { type: 'OPEN' } | { type: 'CLOSE' } | { type: 'SIGN_OUT'; run: () => Promise };
+
+const { createMachine, assign, fromPromise } = setup();
+
+const machine = createMachine({
+ id: 'deviceDetails',
+ initial: 'closed',
+ context: { run: () => Promise.resolve(), errorMessage: undefined, fallbackError: '' },
+ states: {
+ closed: {
+ on: { OPEN: { target: 'open', actions: assign(() => ({ errorMessage: undefined })) } },
+ },
+ open: {
+ on: {
+ CLOSE: 'closed',
+ SIGN_OUT: {
+ target: 'signingOut',
+ actions: assign((_, event) => ({ run: event.run, errorMessage: undefined })),
+ },
+ },
+ },
+ signingOut: {
+ invoke: fromPromise(context => context.run(), {
+ onDone: 'closed',
+ onError: {
+ target: 'open',
+ actions: assign((context, event) => ({
+ errorMessage: event.error instanceof Error ? event.error.message : context.fallbackError,
+ })),
+ },
+ }),
+ },
+ },
+});
+
+export function useUserProfileDeviceDetailsController({
+ onSignOut,
+ fallbackError,
+}: {
+ onSignOut?: (device: UserProfileDevice) => void | Promise;
+ fallbackError: string;
+}) {
+ const [snapshot, send] = useMachine(machine, { context: { fallbackError } });
+
+ return {
+ open: snapshot.value !== 'closed',
+ onOpenChange: (open: boolean) => send({ type: open ? 'OPEN' : 'CLOSE' }),
+ isSigningOut: snapshot.value === 'signingOut',
+ errorMessage: snapshot.context.errorMessage,
+ onSignOut: onSignOut
+ ? (device: UserProfileDevice) => send({ type: 'SIGN_OUT', run: async () => onSignOut(device) })
+ : undefined,
+ };
+}
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
index 8d050f0939b..030ae9bfecf 100644
--- 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
@@ -1,5 +1,3 @@
-import { useRef, useState } from 'react';
-
import { Button, SubmitButton } from '../../components/button';
import { Card } from '../../components/card';
import { DataList } from '../../components/data-list';
@@ -7,6 +5,7 @@ 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';
+import { useUserProfileDeviceDetailsController } from './user-profile-device-details.controller';
export interface UserProfileDeviceDetailsDialogProps {
handle: DialogHandle;
@@ -15,8 +14,15 @@ export interface UserProfileDeviceDetailsDialogProps {
}
export function UserProfileDeviceDetailsDialog({ handle, finalFocus, onSignOut }: UserProfileDeviceDetailsDialogProps) {
+ const m = useMessages('userProfileActiveDevices');
+ const controller = useUserProfileDeviceDetailsController({ onSignOut, fallbackError: m.detailsDialog.signOutError });
+
return (
-
+
{({ payload: device }) =>
device === undefined ? null : (
)
@@ -37,36 +44,16 @@ export function UserProfileDeviceDetailsDialog({ handle, finalFocus, onSignOut }
function DeviceDetailsCard({
device,
- handle,
onSignOut,
+ isSigningOut,
+ errorMessage,
}: {
device: UserProfileDevice;
- handle: DialogHandle;
- onSignOut: UserProfileDeviceDetailsDialogProps['onSignOut'];
+ onSignOut?: (device: UserProfileDevice) => void;
+ isSigningOut: boolean;
+ errorMessage: string | undefined;
}) {
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 },
@@ -111,7 +98,7 @@ function DeviceDetailsCard({
type='button'
fullWidth
isPending={isSigningOut}
- onClick={() => void signOut()}
+ onClick={() => onSignOut(device)}
>
{m.detailsDialog.signOut}
diff --git a/packages/mosaic/src/hooks/__tests__/useListRemovalFocus.test.ts b/packages/mosaic/src/hooks/__tests__/useListRemovalFocus.test.ts
new file mode 100644
index 00000000000..e4bc36621c4
--- /dev/null
+++ b/packages/mosaic/src/hooks/__tests__/useListRemovalFocus.test.ts
@@ -0,0 +1,21 @@
+import { renderHook } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { useListRemovalFocus } from '../useListRemovalFocus';
+
+describe('list removal focus', () => {
+ it('leaves focus restoration to the dialog when removal is cancelled', async () => {
+ const fallback = document.createElement('button');
+ const { result } = renderHook(() =>
+ useListRemovalFocus({
+ ids: ['device'],
+ onRemove: () => Promise.resolve(false),
+ fallback: () => fallback,
+ }),
+ );
+
+ await result.current.remove('device');
+
+ expect(result.current.finalFocus()).toBeNull();
+ });
+});
diff --git a/packages/mosaic/src/hooks/use-list-removal-focus.ts b/packages/mosaic/src/hooks/use-list-removal-focus.ts
index 1c38c6ae607..890d4c4b8b9 100644
--- a/packages/mosaic/src/hooks/use-list-removal-focus.ts
+++ b/packages/mosaic/src/hooks/use-list-removal-focus.ts
@@ -6,7 +6,7 @@ export function useListRemovalFocus({
fallback,
}: {
ids: string[];
- onRemove?: (id: string) => void | Promise;
+ onRemove?: (id: string) => void | boolean | Promise;
fallback: () => HTMLElement | null;
}) {
const triggers = useRef(new Map());
@@ -25,8 +25,9 @@ export function useListRemovalFocus({
return;
}
const index = ids.indexOf(id);
- await onRemove(id);
- removed.current = { id, index };
+ if ((await onRemove(id)) !== false) {
+ removed.current = { id, index };
+ }
};
const finalFocus = () => {
From 0a0897b04d33c035993735ba0095cfb1c6cc8724 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 30 Sep 2026 01:18:08 -0600
Subject: [PATCH 02/10] refactor(mosaic): compose active devices through a
panel slot
---
...er-profile-active-devices.feature.test.tsx | 7 +-
.../user-profile-security-panel.view.test.tsx | 72 ++++++++++++-------
.../user-profile-security-panel.view.tsx | 22 ++----
.../src/stories/fixtures/user-profile.tsx | 5 +-
4 files changed, 59 insertions(+), 47 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
index 401af9058a3..a828c15e819 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
@@ -10,6 +10,7 @@ import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fa
import { renderWithClerk } from '../../../__tests__/feature/render';
import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section';
+import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
const alice = fapiUser({ id: 'user_1' });
@@ -93,14 +94,16 @@ describe('Active devices', () => {
expect(await screen.findByText('This device')).toBeVisible();
});
- it('shows signed-in sessions with metadata and signs out another device', async () => {
+ it('shows signed-in sessions and signs out another device inside the security panel', async () => {
const devices = serveDevices([
device('sess_other', 'active', { device_type: 'iPhone', is_mobile: true }),
device('sess_current', 'active'),
device('sess_pending', 'pending', { browser_name: undefined, device_type: undefined }),
device('sess_expired', 'expired'),
]);
- const { clerk } = await renderWithClerk();
+ const { clerk } = await renderWithClerk(
+ } />,
+ );
expect(await clerk.user?.getSessions()).toHaveLength(4);
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 8d208349fd6..62168bd638a 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
@@ -5,6 +5,8 @@ import { describe, expect, it, vi } from 'vitest';
import { useDestructiveController } from '../../../blocks/destructive/destructive.controller';
import { MosaicProvider } from '../../../mosaic-provider';
+import type { UserProfileDevice } from '../user-profile-active-devices-section.view';
+import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section.view';
import { UserProfileDangerSectionView } from '../user-profile-danger-section/user-profile-danger-section.view';
import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view';
import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
@@ -13,6 +15,27 @@ function DeleteAccount({ onDelete = () => Promise.resolve() }: { onDelete?: () =
const controller = useDestructiveController({ onDelete });
return ;
}
+const devices: UserProfileDevice[] = [
+ {
+ 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 props: UserProfileSecurityPanelViewProps = {
passkeys: [
@@ -28,27 +51,7 @@ const props: UserProfileSecurityPanelViewProps = {
{ id: 'totp_1', type: 'authenticator' },
{ id: 'backup_1', type: 'backup-codes' },
],
- devices: [
- {
- 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',
- },
- ],
+ activeDevicesSlot: ,
};
function renderView(overrides: Partial = {}) {
@@ -84,6 +87,13 @@ describe('UserProfileSecurityPanelView', () => {
).toBeInTheDocument();
});
+ it('omits active devices when no slot is supplied', () => {
+ renderView({ activeDevicesSlot: undefined });
+
+ expect(screen.queryByRole('heading', { name: 'Active devices' })).not.toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Security' })).toBeInTheDocument();
+ });
+
it('adds an available MFA method through the picker', async () => {
const onAddMfaMethod = vi.fn();
const user = userEvent.setup();
@@ -117,9 +127,14 @@ describe('UserProfileSecurityPanelView', () => {
onAddPasskey,
onRenamePasskey,
onRemovePasskey,
- onSignOutDevice,
- onSignOutAllOtherDevices,
dangerSlot: ,
+ activeDevicesSlot: (
+
+ ),
});
await user.click(screen.getByRole('button', { name: 'Add passkey' }));
@@ -168,7 +183,7 @@ describe('UserProfileSecurityPanelView', () => {
renderView({
passkeys: [],
mfaMethods: [],
- devices: [],
+ activeDevicesSlot: ,
onAddPasskey: vi.fn(),
onAddMfaMethod: vi.fn(),
addableMfaMethods: ['sms', 'authenticator'],
@@ -183,7 +198,14 @@ describe('UserProfileSecurityPanelView', () => {
it('withholds sign out from the current device', async () => {
const user = userEvent.setup();
- renderView({ onSignOutDevice: vi.fn() });
+ renderView({
+ activeDevicesSlot: (
+
+ ),
+ });
await user.click(screen.getByRole('button', { name: 'Manage Safari on macOS' }));
expect(screen.getByRole('menuitem', { name: 'View details' })).toBeInTheDocument();
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 af6c5c8e34c..f5274b8d798 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
@@ -3,11 +3,7 @@ import type { ReactElement, ReactNode } from 'react';
import { Panel } from '../../components/panel';
import { Section } from '../../components/section';
import { themeProps } from '../../props';
-import type {
- UserProfileActiveDevicesSectionViewProps,
- UserProfileDevice,
-} from './user-profile-active-devices-section.view';
-import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view';
+import type { UserProfileDevice } from './user-profile-active-devices-section.view';
import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view';
import { UserProfileMfaSectionView } from './user-profile-mfa-section.view';
import type { UserProfilePasskey } from './user-profile-passkeys-section.view';
@@ -16,14 +12,14 @@ import type { UserProfilePasswordSlot } from './user-profile-password-section/us
export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey };
-export interface UserProfileSecurityPanelViewProps extends Omit {
+export interface UserProfileSecurityPanelViewProps {
passwordSlot?: UserProfilePasswordSlot | null;
passkeys?: UserProfilePasskey[];
passkeysVisible?: boolean;
mfaMethods?: UserProfileMfaMethod[];
addableMfaMethods?: readonly UserProfileMfaAddableMethod[];
mfaAddControl?: ReactNode;
- devices?: UserProfileDevice[];
+ activeDevicesSlot?: ReactNode;
onAddPasskey?: () => void;
addPasskeyError?: string;
onRenamePasskey?: (id: string, name: string) => void | Promise;
@@ -43,7 +39,7 @@ export function UserProfileSecurityPanelView({
mfaMethods,
addableMfaMethods,
mfaAddControl,
- devices,
+ activeDevicesSlot,
onAddPasskey,
addPasskeyError,
onRenamePasskey,
@@ -52,8 +48,6 @@ export function UserProfileSecurityPanelView({
onRegenerateBackupCodes,
onRemoveMfaMethod,
onSetDefaultMfaMethod,
- onSignOutDevice,
- onSignOutAllOtherDevices,
dangerSlot,
}: UserProfileSecurityPanelViewProps): ReactElement {
const showPassword = Boolean(passwordSlot);
@@ -89,13 +83,7 @@ export function UserProfileSecurityPanelView({
) : null}
) : null}
- {devices ? (
-
- ) : null}
+ {activeDevicesSlot}
{dangerSlot}
diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx
index b3b38a5bb60..7af21de568f 100644
--- a/packages/swingset/src/stories/fixtures/user-profile.tsx
+++ b/packages/swingset/src/stories/fixtures/user-profile.tsx
@@ -1,5 +1,6 @@
import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller';
import type { UserProfileViewProps } from '@clerk/mosaic/features/user-profile/user-profile.view';
+import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section.view';
import type {
UserProfilePaymentMethod,
UserProfileSubscription,
@@ -160,12 +161,10 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions
addPasskeyError: passkeys.addError,
onRenamePasskey: passkeys.onRename,
...mfa.security,
- devices: activeDevices.devices,
+ activeDevicesSlot: ,
onAddPasskey: passkeys.onAdd,
dangerSlot: ,
onRemovePasskey: passkeys.onRemove,
- onSignOutAllOtherDevices: activeDevices.onSignOutAllOtherDevices,
- onSignOutDevice: activeDevices.onSignOutDevice,
},
billing: {
subscription,
From f5f9454b3a98f7922d3a0a0622d04ab979459a80 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 30 Sep 2026 01:30:05 -0600
Subject: [PATCH 03/10] fix(mosaic): defer active-device reverification
---
...er-profile-active-devices.feature.test.tsx | 175 ++++++++----------
...ofile-active-devices-section.controller.ts | 7 +-
...er-profile-active-devices-section.model.ts | 42 ++---
3 files changed, 94 insertions(+), 130 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
index a828c15e819..c11070264f9 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
@@ -165,65 +165,64 @@ describe('Active devices', () => {
expect(devices.find(item => item.id === 'sess_current')?.status).toBe('active');
});
- it.each(['user', 'session'])(
- 'does not retry an old revoke after switching %s during reverification',
- async switchKind => {
- const nextUser = switchKind === 'user' ? fapiUser({ id: 'user_2' }) : alice;
- const fapi = serveFapi({
- client: fapiClient([
- fapiSession({ id: 'sess_current', user: alice }),
- fapiSession({ id: 'sess_next', user: nextUser }),
- ]),
- });
- const devices = [
- device('sess_current', 'active'),
- device('sess_next', 'active', { device_type: 'Next laptop' }),
- device('sess_other', 'active', { device_type: 'iPhone' }),
- ];
- const nextDevices = switchKind === 'user' ? devices.filter(item => item.id === 'sess_next') : devices;
- const attempts: string[] = [];
- worker.use(
- http.get(fapiUrl('/v1/me/sessions/active'), () =>
- HttpResponse.json(fapi.client.last_active_session_id === 'sess_next' ? nextDevices : devices),
- ),
- http.post(fapiUrl('/v1/me/sessions/:id/revoke'), ({ params }) => {
- attempts.push(String(params.id));
- return HttpResponse.json(
- { errors: [{ code: 'session_reverification_required', message: 'Verification required' }] },
- { status: 400 },
- );
- }),
- );
- const view = await renderWithClerk();
- const verification = createDeferredPromise<() => void>();
- vi.spyOn(view.clerk, '__internal_openReverification').mockImplementation(props => {
- if (props?.afterVerification) {
- verification.resolve(props.afterVerification);
+ it.each(['user', 'session'])('keeps the new %s view intact when an old revoke completes', async switchKind => {
+ const nextUser = switchKind === 'user' ? fapiUser({ id: 'user_2' }) : alice;
+ const fapi = serveFapi({
+ client: fapiClient([
+ fapiSession({ id: 'sess_current', user: alice }),
+ fapiSession({ id: 'sess_next', user: nextUser }),
+ ]),
+ });
+ const devices = [
+ device('sess_current', 'active'),
+ device('sess_next', 'active', { device_type: 'Next laptop' }),
+ device('sess_other', 'active', { device_type: 'iPhone' }),
+ ];
+ const nextDevices = switchKind === 'user' ? devices.filter(item => item.id === 'sess_next') : devices;
+ const attempts: string[] = [];
+ const revokeStarted = createDeferredPromise();
+ const releaseRevoke = createDeferredPromise();
+ worker.use(
+ http.get(fapiUrl('/v1/me/sessions/active'), () =>
+ HttpResponse.json(fapi.client.last_active_session_id === 'sess_next' ? nextDevices : devices),
+ ),
+ http.post(fapiUrl('/v1/me/sessions/:id/revoke'), async ({ params }) => {
+ attempts.push(String(params.id));
+ revokeStarted.resolve();
+ await releaseRevoke.promise;
+ const target = devices.find(item => item.id === params.id);
+ if (!target) {
+ return new HttpResponse(null, { status: 404 });
}
- });
- view.rerender();
- const user = userEvent.setup();
- await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
- await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
- const complete = await verification.promise;
+ target.status = 'revoked';
+ return HttpResponse.json({ response: target, client: null });
+ }),
+ );
+ const view = await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+ await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
+ await revokeStarted.promise;
+ try {
await act(() => view.clerk.setActive({ session: 'sess_next' }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
- expect(view.clerk.session?.id).toBe('sess_next');
- expect(fapi.client.last_active_session_id).toBe('sess_next');
- await act(() => complete());
+ } finally {
+ releaseRevoke.resolve();
+ }
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(view.clerk.session?.id).toBe('sess_next');
+ expect(fapi.client.last_active_session_id).toBe('sess_next');
- await user.click(await screen.findByRole('button', { name: 'Manage Safari on Next laptop' }));
- expect(screen.queryByRole('menuitem', { name: 'Sign out' })).toBeNull();
- expect(attempts).toEqual(['sess_other']);
- expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active');
- if (switchKind === 'user') {
- expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull();
- }
- expect(screen.queryByRole('alert')).toBeNull();
- },
- );
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on Next laptop' }));
+ expect(screen.queryByRole('menuitem', { name: 'Sign out' })).toBeNull();
+ expect(attempts).toEqual(['sess_other']);
+ await waitFor(() => expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked'));
+ if (switchKind === 'user') {
+ expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull();
+ }
+ expect(screen.queryByRole('alert')).toBeNull();
+ });
it('localizes relative and formatted activity dates with surrounding text', async () => {
const now = new Date();
@@ -258,49 +257,31 @@ describe('Active devices', () => {
expect(screen.getAllByRole('button', { name: 'Manage Safari sur MacBook Pro' })).toHaveLength(3);
});
- it('keeps a device and closes quietly when reverification is cancelled', async () => {
- const devices = serveDevices([device('sess_current', 'active'), device('sess_other', 'active')], {
- reverifyOnceId: 'sess_other',
- });
- const view = await renderWithClerk();
- const openReverification = vi.spyOn(view.clerk, '__internal_openReverification').mockImplementation(props => {
- props?.afterVerificationCancelled?.();
- });
- view.rerender();
- const user = userEvent.setup();
- const other = await screen.findAllByRole('button', { name: 'Manage Safari on MacBook Pro' });
- await user.click(other[1]);
- 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')).toBeNull());
- expect(openReverification).toHaveBeenCalledOnce();
- expect(screen.queryByRole('alert')).toBeNull();
- expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active');
- expect(screen.getAllByRole('button', { name: 'Manage Safari on MacBook Pro' })).toHaveLength(2);
- });
-
- it('retries the revoke after reverification succeeds', async () => {
- const devices = serveDevices([device('sess_current', 'active'), device('sess_other', 'active')], {
- reverifyOnceId: 'sess_other',
- });
- const view = await renderWithClerk();
- const openReverification = vi.spyOn(view.clerk, '__internal_openReverification').mockImplementation(props => {
- props?.afterVerification?.();
- });
- view.rerender();
- const user = userEvent.setup();
- const other = await screen.findAllByRole('button', { name: 'Manage Safari on MacBook Pro' });
- await user.click(other[1]);
- await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
+ it.each(['confirmation', 'details'] as const)(
+ 'surfaces verification-required errors in the %s without opening reverification',
+ async surface => {
+ const devices = serveDevices(
+ [device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })],
+ {
+ reverifyOnceId: 'sess_other',
+ },
+ );
+ const view = await renderWithClerk();
+ const openReverification = vi.spyOn(view.clerk, '__internal_openReverification').mockImplementation(() => {});
+ view.rerender();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
+ await user.click(screen.getByRole('menuitem', { name: surface === 'details' ? 'View details' : 'Sign out' }));
+ const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog');
+ await user.click(within(dialog).getByRole('button', { name: 'Sign out' }));
- await waitFor(() =>
- expect(screen.getAllByRole('button', { name: 'Manage Safari on MacBook Pro' })).toHaveLength(1),
- );
- expect(openReverification).toHaveBeenCalledOnce();
- expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked');
- });
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('Verification required');
+ expect(openReverification).not.toHaveBeenCalled();
+ expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active');
+ expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeInTheDocument();
+ expect(within(dialog).getByRole('button', { name: 'Sign out' })).not.toHaveAttribute('aria-busy', 'true');
+ },
+ );
it('keeps device details open until its pending sign out completes', async () => {
const otherDevice = device('sess_other', 'active', { device_type: 'iPhone' });
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
index 2e98f46a5e2..ab81508f6d2 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
@@ -56,11 +56,8 @@ export function useUserProfileActiveDevicesController(
}, [loadSessions, send]);
const revoke = async (id: string) => {
- const removed = await model.revoke(id);
- if (removed) {
- send({ type: 'REMOVED', id });
- }
- return removed;
+ await model.revoke(id);
+ send({ type: 'REMOVED', id });
};
return {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
index 085c4361e13..a7215888e9b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
@@ -1,5 +1,5 @@
-import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error';
-import { useClerk, useReverification, useSession, useUser } from '@clerk/shared/react';
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+import { useClerk, useSession, useUser } from '@clerk/shared/react';
import type { SessionWithActivitiesResource } from '@clerk/shared/types';
import { useCallback } from 'react';
@@ -13,7 +13,7 @@ export type UserProfileActiveDevicesModel =
status: 'ready';
identity: string;
loadSessions: () => Promise;
- revoke: (id: string) => Promise;
+ revoke: (id: string) => Promise;
};
function lastActiveLabel(date: Date, locale: string): string {
@@ -38,26 +38,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
const userId = user?.id;
const sessionId = session?.id;
- const revokeSession = useReverification(async (originIdentity: string, id: string): Promise => {
- if (
- !userId ||
- !sessionId ||
- originIdentity !== `${userId}:${sessionId}` ||
- clerk.user?.id !== userId ||
- clerk.session?.id !== sessionId ||
- id === sessionId
- ) {
- throw new Error(m.signOutError);
- }
- const target = (await user?.getSessions())?.find(
- item => item.id === id && (item.status === 'active' || item.status === 'pending'),
- );
- if (!target || clerk.user?.id !== userId || clerk.session?.id !== sessionId) {
- throw new Error(m.signOutError);
- }
- await target.revoke();
- });
-
const toDevice = useCallback(
(item: SessionWithActivitiesResource): UserProfileDevice => {
const activity = item.latestActivity;
@@ -110,14 +90,20 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
identity: `${user.id}:${session.id}`,
loadSessions,
// TODO: Add bulk revocation when a dedicated API is available, preserving the current session and reverification.
+ // TODO: Add session reverification for device revocation; surface API errors until then.
revoke: async id => {
try {
- await revokeSession(`${user.id}:${session.id}`, id);
- return true;
- } catch (error) {
- if (isClerkRuntimeError(error) && error.code === 'reverification_cancelled') {
- return false;
+ if (clerk.user?.id !== userId || clerk.session?.id !== sessionId || id === sessionId) {
+ throw new Error(m.signOutError);
+ }
+ const target = (await user.getSessions()).find(
+ item => item.id === id && (item.status === 'active' || item.status === 'pending'),
+ );
+ if (!target || clerk.user?.id !== userId || clerk.session?.id !== sessionId) {
+ throw new Error(m.signOutError);
}
+ await target.revoke();
+ } catch (error) {
if (isClerkAPIResponseError(error)) {
const first = error.errors[0];
throw new Error(first?.longMessage || first?.message || m.signOutError);
From eef18116241a51bd68dcfe95822883c26ecfc2ce Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 15:47:56 -0600
Subject: [PATCH 04/10] fix(mosaic): preserve active device state and restore
retries
---
.../core/resources/SessionWithActivities.ts | 9 +-
packages/clerk-js/src/core/resources/User.ts | 7 +-
.../resources/__tests__/User.sessions.test.ts | 78 +++
.../mosaic/src/__tests__/feature/fake-fapi.ts | 89 ++++
...er-profile-active-devices.feature.test.tsx | 483 +++++++++++++++---
.../user-profile-device-actions.test.tsx | 46 --
...ofile-active-devices-section.controller.ts | 69 +--
...er-profile-active-devices-section.model.ts | 164 +++---
.../user-profile-active-devices-section.tsx | 31 +-
.../user-profile-device-details.controller.ts | 2 +-
.../__tests__/useListRemovalFocus.test.ts | 21 -
packages/shared/src/types/user.ts | 10 +-
12 files changed, 733 insertions(+), 276 deletions(-)
create mode 100644 packages/clerk-js/src/core/resources/__tests__/User.sessions.test.ts
delete mode 100644 packages/mosaic/src/hooks/__tests__/useListRemovalFocus.test.ts
diff --git a/packages/clerk-js/src/core/resources/SessionWithActivities.ts b/packages/clerk-js/src/core/resources/SessionWithActivities.ts
index 23c8ababed2..0210f6c4ba0 100644
--- a/packages/clerk-js/src/core/resources/SessionWithActivities.ts
+++ b/packages/clerk-js/src/core/resources/SessionWithActivities.ts
@@ -1,5 +1,6 @@
import type {
ActClaim,
+ GetSessionsParams,
SessionActivity,
SessionActivityJSON,
SessionWithActivitiesJSON,
@@ -36,10 +37,10 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc
this.fromJSON(data);
}
- static retrieve(): Promise {
+ static retrieve(params?: Pick): Promise {
const sessionId = BaseResource.clerk.session?.id;
- return this.clerk
+ const request = this.clerk
.getFapiClient()
.request({
method: 'GET',
@@ -50,8 +51,8 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc
// https://www.notion.so/clerkdev/Align-SessionWithActivities-retrieval-with-the-rest-of-Client-API-a043f72f6b9d4344bd2f21dc1d3f79de
const sessionWithActivitiesJSON = res.payload as unknown as SessionWithActivitiesJSON[];
return sessionWithActivitiesJSON.map(sa => new SessionWithActivities(sa, '/me/sessions'));
- })
- .catch(() => []);
+ });
+ return params?.throwOnError ? request : request.catch(() => []);
}
revoke(): Promise {
diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts
index a57e8a6249e..7d6a8c08bc7 100644
--- a/packages/clerk-js/src/core/resources/User.ts
+++ b/packages/clerk-js/src/core/resources/User.ts
@@ -19,6 +19,7 @@ import type {
ExternalAccountResource,
GetEnterpriseConnectionsParams,
GetOrganizationMemberships,
+ GetSessionsParams,
GetUserOrganizationInvitationsParams,
GetUserOrganizationSuggestionsParams,
ImageResource,
@@ -330,11 +331,11 @@ export class User extends BaseResource implements UserResource {
});
};
- getSessions = async (): Promise => {
- if (this.cachedSessionsWithActivities) {
+ getSessions = async (params?: GetSessionsParams): Promise => {
+ if (!params?.forceRefresh && this.cachedSessionsWithActivities) {
return this.cachedSessionsWithActivities;
}
- const res = await SessionWithActivities.retrieve();
+ const res = await SessionWithActivities.retrieve(params);
this.cachedSessionsWithActivities = res;
return res;
};
diff --git a/packages/clerk-js/src/core/resources/__tests__/User.sessions.test.ts b/packages/clerk-js/src/core/resources/__tests__/User.sessions.test.ts
new file mode 100644
index 00000000000..2f3c8c2a50f
--- /dev/null
+++ b/packages/clerk-js/src/core/resources/__tests__/User.sessions.test.ts
@@ -0,0 +1,78 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { createSession, createUser } from '@/test/core-fixtures';
+
+import { Clerk } from '../../clerk';
+import { BaseResource } from '../internal';
+import { User } from '../User';
+
+function response(id: string) {
+ const { user: _user, ...session } = createSession({ id, status: 'active' });
+ return Object.assign(new Response(), {
+ payload: [{ ...session, user: null, latest_activity: { object: 'session_activity', id: `activity_${id}` } }],
+ });
+}
+
+describe('User session retrieval', () => {
+ const clerk = new Clerk('pk_test_Y2xlcmsuZXhhbXBsZS5jb20k');
+ const previousClerk = BaseResource.clerk;
+
+ beforeEach(() => {
+ BaseResource.clerk = clerk;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ BaseResource.clerk = previousClerk;
+ });
+
+ it('preserves cached successes for callers without options', async () => {
+ const request = vi.spyOn(clerk.getFapiClient(), 'request').mockResolvedValue(response('sess_first'));
+ const user = new User(createUser());
+ expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_first']);
+ request.mockResolvedValue(response('sess_next'));
+ expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_first']);
+ expect(request).toHaveBeenCalledOnce();
+ });
+
+ it('preserves the cached empty result after failure for callers without options', async () => {
+ const request = vi.spyOn(clerk.getFapiClient(), 'request').mockRejectedValue(new Error('Network failure'));
+ const user = new User(createUser());
+ await expect(user.getSessions()).resolves.toEqual([]);
+ request.mockResolvedValue(response('sess_next'));
+ await expect(user.getSessions()).resolves.toEqual([]);
+ expect(request).toHaveBeenCalledOnce();
+ });
+
+ it('makes a fresh read after a legacy caller cached a failed request', async () => {
+ const request = vi.spyOn(clerk.getFapiClient(), 'request').mockRejectedValue(new Error('Network failure'));
+ const user = new User(createUser());
+ await expect(user.getSessions()).resolves.toEqual([]);
+ request.mockResolvedValue(response('sess_next'));
+ const sessions = await user.getSessions({ forceRefresh: true, throwOnError: true });
+ expect(sessions.map(session => session.id)).toEqual(['sess_next']);
+ expect(request).toHaveBeenCalledTimes(2);
+ });
+
+ it('propagates a strict failure without overwriting a successful cache', async () => {
+ const request = vi.spyOn(clerk.getFapiClient(), 'request').mockResolvedValue(response('sess_first'));
+ const user = new User(createUser());
+ await user.getSessions();
+ const failure = new Error('Network failure');
+ request.mockRejectedValue(failure);
+ await expect(user.getSessions({ forceRefresh: true, throwOnError: true })).rejects.toBe(failure);
+ expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_first']);
+ });
+
+ it('retries a strict failed request and refreshes the cached sessions on success', async () => {
+ const request = vi.spyOn(clerk.getFapiClient(), 'request').mockRejectedValue(new Error('Network failure'));
+ const user = new User(createUser());
+ await expect(user.getSessions({ forceRefresh: true, throwOnError: true })).rejects.toThrow('Network failure');
+ request.mockResolvedValue(response('sess_next'));
+ expect((await user.getSessions({ forceRefresh: true, throwOnError: true })).map(session => session.id)).toEqual([
+ 'sess_next',
+ ]);
+ expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_next']);
+ expect(request).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
index bea691e3880..23d35f21264 100644
--- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
@@ -7,6 +7,7 @@ import type {
OrganizationMembershipJSON,
OrganizationSuggestionJSON,
SessionJSON,
+ SessionWithActivitiesJSON,
UserJSON,
UserOrganizationInvitationJSON,
} from '@clerk/shared/types';
@@ -49,6 +50,16 @@ export interface FakeFapiState {
passwordUpdates: URLSearchParams[];
enterpriseConnections: EnterpriseConnectionJSON[];
enterpriseLinking: FakeEnterpriseLinking;
+ activeDevices?: ActiveDeviceRecord[];
+ deviceTrackingEnabled: boolean;
+}
+
+export interface ActiveDeviceRecord extends SessionWithActivitiesJSON {
+ ownerUserId: string;
+ replacementSessionId?: string;
+ inactivityTimeoutSeconds?: number;
+ touchedAt?: number;
+ tokenIssuedAt?: number;
}
export type FakeFapiSeed = Partial> & {
@@ -137,10 +148,40 @@ function deleteOrganization(state: FakeFapiState, organizationId: string): void
};
}
+function requestingSession(state: FakeFapiState, request: Request): SessionJSON | undefined {
+ const sessionId = new URL(request.url).searchParams.get('_clerk_session_id') ?? state.client.last_active_session_id;
+ return findSession(state, sessionId);
+}
+
function missing() {
return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
}
+function canRevokeDevice(record: ActiveDeviceRecord): boolean {
+ const now = Date.now();
+ const lastActivity = Math.max(record.touchedAt ?? record.last_active_at, record.tokenIssuedAt ?? 0);
+ return (
+ record.status === 'active' &&
+ !record.replacementSessionId &&
+ record.expire_at > now &&
+ record.abandon_at > now &&
+ (!record.inactivityTimeoutSeconds || now - lastActivity <= record.inactivityTimeoutSeconds * 1000)
+ );
+}
+
+function deviceSessionPayload(record: ActiveDeviceRecord) {
+ const {
+ ownerUserId: _ownerUserId,
+ replacementSessionId: _replacementSessionId,
+ inactivityTimeoutSeconds: _inactivityTimeoutSeconds,
+ touchedAt: _touchedAt,
+ tokenIssuedAt: _tokenIssuedAt,
+ latest_activity: _latestActivity,
+ ...session
+ } = record;
+ return session;
+}
+
export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
const { verification, enterpriseLinking, ...rest } = seed;
const state: FakeFapiState = {
@@ -152,6 +193,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
apiKeys: [],
passwordUpdates: [],
enterpriseConnections: [],
+ deviceTrackingEnabled: true,
...rest,
verification: createVerificationState(verification),
enterpriseLinking: {
@@ -276,6 +318,53 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
state.client = { ...state.client, sessions, last_active_session_id: sessions[0]?.id ?? null };
return envelope({ ...session, status: 'removed' }, state.client);
}),
+ http.get(fapiUrl('/v1/me/sessions/active'), ({ request }) => {
+ if (!state.activeDevices) {
+ return undefined;
+ }
+ const requester = requestingSession(state, request);
+ if (!requester) {
+ return missing();
+ }
+ if (!state.deviceTrackingEnabled) {
+ const { user: _user, ...session } = requester;
+ return HttpResponse.json([{ ...session, user: null }]);
+ }
+ const records = state.activeDevices.filter(
+ item => item.ownerUserId === requester.user.id && canRevokeDevice(item) && (!item.actor || requester.actor),
+ );
+ return HttpResponse.json(
+ records.map(item => ({
+ ...deviceSessionPayload(item),
+ status: item.tasks?.length ? 'pending' : item.status,
+ latest_activity: item.latest_activity,
+ })),
+ );
+ }),
+ http.post(fapiUrl('/v1/me/sessions/:id/revoke'), ({ params, request }) => {
+ if (!state.activeDevices) {
+ return undefined;
+ }
+ const requester = requestingSession(state, request);
+ if (!requester) {
+ return missing();
+ }
+ const target = state.activeDevices.find(item => item.id === params.id);
+ if (target?.id === requester.id) {
+ const code = 'invalid_action_for_session';
+ return HttpResponse.json({ errors: [{ code, message: code }] }, { status: 400 });
+ }
+ if (!target || target.ownerUserId !== requester.user.id) {
+ const code = 'action_for_session_not_authorized';
+ return HttpResponse.json({ errors: [{ code, message: code }] }, { status: 401 });
+ }
+ if (!canRevokeDevice(target)) {
+ const code = 'invalid_action_for_session';
+ return HttpResponse.json({ errors: [{ code, message: code }] }, { status: 400 });
+ }
+ target.status = 'revoked';
+ return envelope({ ...deviceSessionPayload(target), user: requester.user, status: 'revoked' }, state.client);
+ }),
http.post(fapiUrl('/v1/me/change_password'), async ({ request }) => {
const session = findSession(state, state.client.last_active_session_id);
if (!session) {
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
index c11070264f9..b8214ee720b 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';
-import { fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
+import { type ActiveDeviceRecord, fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
@@ -14,11 +14,16 @@ import { UserProfileSecurityPanelView } from '../user-profile-security-panel.vie
const alice = fapiUser({ id: 'user_1' });
-function device(id: string, status: string, activity: Partial = {}) {
+function device(
+ id: string,
+ status: SessionWithActivitiesJSON['status'],
+ activity: Partial = {},
+): ActiveDeviceRecord {
const { user: _user, ...session } = fapiSession({ id, user: alice });
return {
...session,
user: null,
+ ownerUserId: alice.id,
status,
latest_activity: {
object: 'session_activity',
@@ -31,18 +36,26 @@ function device(id: string, status: string, activity: Partial HttpResponse.json(devices)),
http.post(fapiUrl('/v1/me/sessions/:id/revoke'), ({ params }) => {
if (params.id === options.failOnceId && !failed) {
failed = true;
@@ -58,14 +71,7 @@ function serveDevices(
{ status: 400 },
);
}
- const index = devices.findIndex(item => item.id === params.id);
- const selected = devices[index];
- if (!selected) {
- return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
- }
- const revoked = { ...selected, status: 'revoked' };
- devices[index] = revoked;
- return HttpResponse.json({ response: revoked, client: null });
+ return undefined;
}),
);
return devices;
@@ -98,21 +104,24 @@ describe('Active devices', () => {
const devices = serveDevices([
device('sess_other', 'active', { device_type: 'iPhone', is_mobile: true }),
device('sess_current', 'active'),
- device('sess_pending', 'pending', { browser_name: undefined, device_type: undefined }),
- device('sess_expired', 'expired'),
+ {
+ ...device('sess_pending', 'active', { browser_name: undefined, device_type: undefined }),
+ tasks: [{ key: 'choose-organization' }],
+ },
+ { ...device('sess_expired', 'active', { device_type: 'Expired laptop' }), expire_at: 1 },
]);
const { clerk } = await renderWithClerk(
} />,
);
- expect(await clerk.user?.getSessions()).toHaveLength(4);
+ expect(await clerk.user?.getSessions()).toHaveLength(3);
expect(await screen.findByRole('button', { name: 'Manage Safari on MacBook Pro' })).toBeInTheDocument();
expect(screen.getByText('This device')).toBeVisible();
expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).toBeNull();
expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Manage Web browser on Desktop device' })).toBeInTheDocument();
- expect(screen.queryByText('sess_expired')).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Manage Safari on Expired laptop' })).toBeNull();
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'Manage Safari on iPhone' }));
@@ -127,6 +136,38 @@ describe('Active devices', () => {
expect(screen.getByRole('button', { name: 'Manage Safari on MacBook Pro' })).toBeInTheDocument();
});
+ it('filters an expired row even if a stale list response includes it', async () => {
+ const expired = device('sess_expired', 'expired', { device_type: 'Expired laptop' });
+ serveDevices([device('sess_current', 'active')]);
+ worker.use(
+ http.get(fapiUrl('/v1/me/sessions/active'), () => HttpResponse.json([device('sess_current', 'active'), expired])),
+ );
+
+ await renderWithClerk();
+
+ expect(await screen.findByText('This device')).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Manage Safari on Expired laptop' })).toBeNull();
+ });
+
+ it('shows a load error and retries after the device request recovers', async () => {
+ serveDevices([device('sess_current', 'active')]);
+ let failing = true;
+ worker.use(
+ http.get(fapiUrl('/v1/me/sessions/active'), () =>
+ failing
+ ? HttpResponse.json({ errors: [{ code: 'internal_clerk_error', message: 'Unavailable' }] }, { status: 500 })
+ : HttpResponse.json([device('sess_current', 'active')]),
+ ),
+ );
+
+ await renderWithClerk();
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Could not load active devices.');
+ failing = false;
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' }));
+ expect(await screen.findByText('This device')).toBeVisible();
+ });
+
it('maps impersonation sessions to the distinct badges', async () => {
serveDevices(
[
@@ -143,6 +184,72 @@ describe('Active devices', () => {
expect(screen.getByText('Impersonation device')).toBeInTheDocument();
});
+ it('lists only eligible sessions owned by the requester', async () => {
+ const records = [
+ device('sess_current', 'active'),
+ {
+ ...device('sess_pending', 'active', { device_type: 'Pending phone' }),
+ tasks: [{ key: 'choose-organization' }],
+ },
+ { ...device('sess_foreign', 'active', { device_type: 'Foreign phone' }), ownerUserId: 'user_2' },
+ { ...device('sess_actor', 'active', { device_type: 'Actor phone' }), actor: { sub: 'admin_1' } },
+ device('sess_activation', 'pending_activation', { device_type: 'Activation phone' }),
+ { ...device('sess_replaced', 'active', { device_type: 'Replaced phone' }), replacementSessionId: 'sess_new' },
+ {
+ ...device('sess_idle', 'active', { device_type: 'Idle phone' }),
+ inactivityTimeoutSeconds: 300,
+ touchedAt: Date.now() - 301_000,
+ },
+ ] satisfies ActiveDeviceRecord[];
+ serveDevices(records);
+
+ await renderWithClerk();
+
+ expect(await screen.findByRole('button', { name: 'Manage Safari on Pending phone' })).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Manage Safari on MacBook Pro' })).toBeVisible();
+ for (const name of ['Foreign', 'Actor', 'Activation', 'Replaced', 'Idle']) {
+ expect(screen.queryByRole('button', { name: `Manage Safari on ${name} phone` })).toBeNull();
+ }
+ });
+
+ it('shows only the current session without activity when device tracking is disabled', async () => {
+ serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })], {
+ deviceTrackingEnabled: false,
+ });
+
+ await renderWithClerk();
+
+ expect(await screen.findByRole('button', { name: 'Manage Web browser on Desktop device' })).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull();
+ expect(screen.queryByText('Paris, France')).toBeNull();
+ });
+
+ it('rejects backend-forbidden revokes through session resources', async () => {
+ const current = device('sess_current', 'active');
+ const foreign = { ...device('sess_foreign', 'active'), ownerUserId: 'user_2' };
+ const revoked = device('sess_revoked', 'revoked');
+ const unknown = device('sess_unknown', 'active');
+ serveDevices([current, foreign, revoked]);
+ worker.use(
+ http.get(fapiUrl('/v1/me/sessions/active'), () => HttpResponse.json([current, foreign, revoked, unknown])),
+ );
+ const { clerk } = await renderWithClerk();
+ const sessions = await clerk.user?.getSessions();
+
+ for (const [id, code] of [
+ ['sess_current', 'invalid_action_for_session'],
+ ['sess_foreign', 'action_for_session_not_authorized'],
+ ['sess_revoked', 'invalid_action_for_session'],
+ ['sess_unknown', 'action_for_session_not_authorized'],
+ ]) {
+ const session = sessions?.find(item => item.id === id);
+ if (!session) {
+ throw new Error(`Missing session resource ${id}`);
+ }
+ await expect(session.revoke()).rejects.toMatchObject({ errors: [{ code }] });
+ }
+ });
+
it('keeps a device after a failed revoke and allows retrying', async () => {
const devices = serveDevices(
[device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })],
@@ -165,63 +272,206 @@ describe('Active devices', () => {
expect(devices.find(item => item.id === 'sess_current')?.status).toBe('active');
});
- it.each(['user', 'session'])('keeps the new %s view intact when an old revoke completes', async switchKind => {
- const nextUser = switchKind === 'user' ? fapiUser({ id: 'user_2' }) : alice;
- const fapi = serveFapi({
+ it('translates a backend revoke code from the error catalog', async () => {
+ serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })]);
+ worker.use(
+ http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), () =>
+ HttpResponse.json(
+ {
+ errors: [
+ {
+ code: 'invalid_action_for_session',
+ message: 'Invalid action',
+ long_message: 'Unable to revoke session',
+ },
+ ],
+ },
+ { status: 400 },
+ ),
+ ),
+ );
+ const messages = {
+ userProfileActiveDevices: { signOutError: 'Erreur de déconnexion.' },
+ errors: { invalid_action_for_session: 'Cet appareil est indisponible.' },
+ };
+ await renderWithClerk(
+
+
+ ,
+ );
+
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
+ 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('Cet appareil est indisponible.');
+ expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeVisible();
+ });
+
+ it('ignores an old list response after switching users', async () => {
+ const nextUser = fapiUser({ id: 'user_2' });
+ serveFapi({
client: fapiClient([
fapiSession({ id: 'sess_current', user: alice }),
fapiSession({ id: 'sess_next', user: nextUser }),
]),
+ activeDevices: [
+ device('sess_current', 'active'),
+ device('sess_other', 'active', { device_type: 'Old phone' }),
+ { ...device('sess_next', 'active', { device_type: 'Next laptop' }), ownerUserId: nextUser.id },
+ ],
});
+ const pending = createDeferredPromise();
+ let oldRequests = 0;
+ worker.use(
+ http.get(fapiUrl('/v1/me/sessions/active'), async ({ request }) => {
+ if (new URL(request.url).searchParams.get('_clerk_session_id') === 'sess_next') {
+ return undefined;
+ }
+ oldRequests += 1;
+ await pending.promise;
+ return HttpResponse.json([
+ device('sess_current', 'active'),
+ device('sess_other', 'active', { device_type: 'Old phone' }),
+ ]);
+ }),
+ );
+ const view = await renderWithClerk();
+ const currentUser = view.clerk.user;
+ if (!currentUser) {
+ throw new Error('Expected a signed-in user');
+ }
+ const reads = vi.spyOn(currentUser, 'getSessions');
+ view.rerender();
+ try {
+ await waitFor(() => expect(oldRequests).toBe(1));
+ await act(() => view.clerk.setActive({ session: 'sess_next' }));
+ expect(await screen.findByRole('button', { name: 'Manage Safari on Next laptop' })).toBeVisible();
+ await act(async () => {
+ const oldRead = reads.mock.results[0];
+ if (!oldRead || oldRead.type !== 'return') {
+ throw new Error('Expected an in-flight session read');
+ }
+ pending.resolve();
+ await oldRead.value;
+ });
+ expect(screen.queryByRole('button', { name: 'Manage Safari on Old phone' })).toBeNull();
+ expect(screen.getByRole('button', { name: 'Manage Safari on Next laptop' })).toBeVisible();
+ } finally {
+ pending.resolve();
+ }
+ });
+
+ it('stays hidden after sign out while a device list is pending', async () => {
+ serveDevices([device('sess_current', 'active')]);
+ const pending = createDeferredPromise();
+ let requests = 0;
+ worker.use(
+ http.get(fapiUrl('/v1/me/sessions/active'), async () => {
+ requests += 1;
+ await pending.promise;
+ return HttpResponse.json([device('sess_current', 'active')]);
+ }),
+ );
+ const view = await renderWithClerk();
+ const currentUser = view.clerk.user;
+ if (!currentUser) {
+ throw new Error('Expected a signed-in user');
+ }
+ const reads = vi.spyOn(currentUser, 'getSessions');
+ view.rerender();
+ try {
+ await waitFor(() => expect(requests).toBe(1));
+ await act(() => view.clerk.signOut());
+ expect(view.clerk.user).toBeNull();
+ expect(view.container).toBeEmptyDOMElement();
+ await act(async () => {
+ const oldRead = reads.mock.results[0];
+ if (!oldRead || oldRead.type !== 'return') {
+ throw new Error('Expected an in-flight session read');
+ }
+ pending.resolve();
+ await oldRead.value;
+ });
+ expect(view.container).toBeEmptyDOMElement();
+ } finally {
+ pending.resolve();
+ }
+ });
+
+ it.each(['user', 'session'])('ignores an old revoke completion after switching %s', async switchKind => {
+ const nextUser = switchKind === 'user' ? fapiUser({ id: 'user_2' }) : alice;
const devices = [
device('sess_current', 'active'),
- device('sess_next', 'active', { device_type: 'Next laptop' }),
- device('sess_other', 'active', { device_type: 'iPhone' }),
+ device('sess_other', 'active', { device_type: 'Old phone' }),
+ { ...device('sess_next', 'active', { device_type: 'Next laptop' }), ownerUserId: nextUser.id },
];
- const nextDevices = switchKind === 'user' ? devices.filter(item => item.id === 'sess_next') : devices;
- const attempts: string[] = [];
- const revokeStarted = createDeferredPromise();
- const releaseRevoke = createDeferredPromise();
+ const fapi = serveFapi({
+ client: fapiClient([
+ fapiSession({ id: 'sess_current', user: alice }),
+ fapiSession({ id: 'sess_next', user: nextUser }),
+ ]),
+ activeDevices: devices,
+ });
+ const pending = createDeferredPromise();
+ let requests = 0;
worker.use(
http.get(fapiUrl('/v1/me/sessions/active'), () =>
- HttpResponse.json(fapi.client.last_active_session_id === 'sess_next' ? nextDevices : devices),
+ HttpResponse.json(
+ fapi.client.last_active_session_id === 'sess_next'
+ ? [device('sess_next', 'active', { device_type: 'Next laptop' })]
+ : [device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'Old phone' })],
+ ),
),
- http.post(fapiUrl('/v1/me/sessions/:id/revoke'), async ({ params }) => {
- attempts.push(String(params.id));
- revokeStarted.resolve();
- await releaseRevoke.promise;
- const target = devices.find(item => item.id === params.id);
- if (!target) {
- return new HttpResponse(null, { status: 404 });
+ http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), async () => {
+ requests += 1;
+ await pending.promise;
+ const target = devices.find(item => item.id === 'sess_other');
+ if (target) {
+ target.status = 'revoked';
}
- target.status = 'revoked';
- return HttpResponse.json({ response: target, client: null });
+ return HttpResponse.json({
+ response: fapiSession({ id: 'sess_other', user: alice, status: 'revoked' }),
+ client: null,
+ });
}),
);
- const view = await renderWithClerk();
+ const view = await renderWithClerk();
+ const sdkRequests = vi.spyOn(view.clerk.getFapiClient(), 'request');
+ view.rerender();
const user = userEvent.setup();
- await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
- await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
- await revokeStarted.promise;
-
try {
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on Old phone' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+ await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(requests).toBe(1));
await act(() => view.clerk.setActive({ session: 'sess_next' }));
+ expect(await screen.findByRole('button', { name: 'Manage Safari on Next laptop' })).toBeVisible();
+ await act(async () => {
+ const index = sdkRequests.mock.calls.findIndex(
+ ([request]) => request.method === 'POST' && request.path === '/me/sessions/sess_other/revoke',
+ );
+ const response = sdkRequests.mock.results[index];
+ if (!response || response.type !== 'return') {
+ throw new Error('Expected an in-flight revoke request');
+ }
+ pending.resolve();
+ await response.value;
+ });
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(screen.queryByRole('button', { name: 'Manage Safari on Old phone' })).toBeNull();
+ expect(screen.getByRole('button', { name: 'Manage Safari on Next laptop' })).toBeVisible();
+ expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked');
+ expect(requests).toBe(1);
} finally {
- releaseRevoke.resolve();
- }
- await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
- expect(view.clerk.session?.id).toBe('sess_next');
- expect(fapi.client.last_active_session_id).toBe('sess_next');
-
- await user.click(await screen.findByRole('button', { name: 'Manage Safari on Next laptop' }));
- expect(screen.queryByRole('menuitem', { name: 'Sign out' })).toBeNull();
- expect(attempts).toEqual(['sess_other']);
- await waitFor(() => expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked'));
- if (switchKind === 'user') {
- expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull();
+ pending.resolve();
}
- expect(screen.queryByRole('alert')).toBeNull();
});
it('localizes relative and formatted activity dates with surrounding text', async () => {
@@ -257,6 +507,44 @@ describe('Active devices', () => {
expect(screen.getAllByRole('button', { name: 'Manage Safari sur MacBook Pro' })).toHaveLength(3);
});
+ it('keeps device details open while the locale changes during a pending revoke', async () => {
+ serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })]);
+ const pending = createDeferredPromise();
+ worker.use(
+ http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), async () => {
+ await pending.promise;
+ return HttpResponse.json({
+ response: fapiSession({ id: 'sess_other', user: alice, status: 'revoked' }),
+ client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]),
+ });
+ }),
+ );
+ const section = (locale: string) => (
+
+
+
+ );
+ const view = await renderWithClerk(section('en-US'));
+ const user = userEvent.setup();
+ try {
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
+ 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'),
+ );
+
+ view.rerender(section('fr-FR'));
+
+ expect(screen.getByRole('dialog')).toBe(dialog);
+ expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy');
+ } finally {
+ pending.resolve();
+ }
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ });
+
it.each(['confirmation', 'details'] as const)(
'surfaces verification-required errors in the %s without opening reverification',
async surface => {
@@ -301,12 +589,14 @@ describe('Active devices', () => {
await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
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 user.dblClick(within(dialog).getByRole('button', { name: 'Sign out' }));
await waitFor(() => expect(requests).toBe(1));
await user.keyboard('{Escape}');
expect(screen.getByRole('dialog')).toBe(dialog);
await user.click(within(dialog).getByRole('button', { name: 'Close' }));
expect(screen.getByRole('dialog')).toBe(dialog);
+ await user.click(document.body);
+ expect(screen.getByRole('dialog')).toBe(dialog);
expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy');
} finally {
pending.resolve();
@@ -315,4 +605,81 @@ describe('Active devices', () => {
expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull();
expect(requests).toBe(1);
});
+
+ it('allows details to close after a revoke fails', async () => {
+ serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })]);
+ worker.use(
+ http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), () =>
+ HttpResponse.json({ errors: [{ code: 'revoke_failed', message: 'Could not revoke device' }] }, { status: 400 }),
+ ),
+ );
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
+ 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('Could not revoke device');
+ await user.keyboard('{Escape}');
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeVisible();
+ });
+
+ it('stays hidden after sign out while a revoke is pending', async () => {
+ const devices = serveDevices([
+ device('sess_current', 'active'),
+ device('sess_other', 'active', { device_type: 'iPhone' }),
+ ]);
+ const pending = createDeferredPromise();
+ let requests = 0;
+ worker.use(
+ http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), async () => {
+ requests += 1;
+ await pending.promise;
+ const target = devices.find(item => item.id === 'sess_other');
+ if (target) {
+ target.status = 'revoked';
+ }
+ return HttpResponse.json({
+ response: fapiSession({ id: 'sess_other', user: alice, status: 'revoked' }),
+ client: fapiClient(),
+ });
+ }),
+ );
+ const view = await renderWithClerk();
+ const user = userEvent.setup();
+ const settled = createDeferredPromise();
+ let removeListener: (() => void) | undefined;
+ try {
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+ await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(requests).toBe(1));
+ await act(() => view.clerk.signOut());
+ expect(view.clerk.user).toBeNull();
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(view.container).toBeEmptyDOMElement();
+ removeListener = view.clerk.addListener(
+ ({ session }) => {
+ if (!session) {
+ settled.resolve();
+ }
+ },
+ { skipInitialEmit: true },
+ );
+ await act(async () => {
+ pending.resolve();
+ await pending.promise;
+ });
+ await settled.promise;
+ await waitFor(() => expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked'));
+ expect(view.container).toBeEmptyDOMElement();
+ } finally {
+ pending.resolve();
+ removeListener?.();
+ }
+ });
+
+ it.todo('signs out every other eligible device after confirmation while preserving the current session');
+ it.todo('reverifies device revocation before retrying verification-required API errors');
});
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 55920ca900d..4b9fb1a540b 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
@@ -366,49 +366,3 @@ describe('focus after signing a device out', () => {
await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toHaveFocus());
});
});
-
-describe('pending device sign out dismissal', () => {
- it.each(['Escape', 'close button', 'outside press'])(
- 'keeps details open on %s until sign out completes',
- async dismiss => {
- const pending = createDeferredPromise();
- const onSignOut = vi.fn(() => pending.promise.then(() => undefined));
- const user = userEvent.setup();
- renderDevices(onSignOut);
- 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' }));
- if (dismiss === 'Escape') {
- await user.keyboard('{Escape}');
- } else if (dismiss === 'close button') {
- await user.click(within(dialog).getByRole('button', { name: 'Close' }));
- } else {
- await user.click(document.body);
- }
- expect(screen.getByRole('dialog')).toBe(dialog);
- expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy');
- expect(onSignOut).toHaveBeenCalledOnce();
- await act(async () => {
- pending.resolve();
- await pending.promise;
- });
- await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
- },
- );
-
- it('allows details dismissal after sign out fails', async () => {
- const pending = createDeferredPromise();
- const user = userEvent.setup();
- renderDevices(() => pending.promise.then(() => undefined));
- 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' }));
- act(() => {
- pending.reject(new Error('Sign out failed'));
- });
- expect(await screen.findByRole('alert')).toHaveTextContent('Sign out failed');
- await user.keyboard('{Escape}');
- await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
- });
-});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
index ab81508f6d2..3879f27c2fa 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
@@ -1,70 +1,21 @@
-import { useEffect } from 'react';
+import { useState } from 'react';
-import { setup } from '../../machine/setup';
-import { useMachine } from '../../machine/useMachine';
-import type { UserProfileDevice } from './user-profile-active-devices.types';
import type { UserProfileActiveDevicesModel } from './user-profile-active-devices-section.model';
-interface Context {
- devices: UserProfileDevice[];
- loadSessions: () => Promise;
-}
-
-type Event =
- | { type: 'LOAD'; loadSessions: Context['loadSessions'] }
- | { type: 'RETRY' }
- | { type: 'REMOVED'; id: string };
-
-const { createMachine, assign, fromPromise } = setup();
-const load = {
- target: 'loading',
- actions: assign((_, event: Extract) => ({ loadSessions: event.loadSessions })),
-};
-
-const machine = createMachine({
- id: 'activeDevices',
- initial: 'idle',
- context: { devices: [], loadSessions: () => Promise.resolve([]) },
- states: {
- idle: { on: { LOAD: load } },
- loading: {
- on: { LOAD: load },
- invoke: fromPromise(context => context.loadSessions(), {
- onDone: { target: 'ready', actions: assign((_, event) => ({ devices: event.output })) },
- onError: 'error',
- }),
- },
- error: { on: { LOAD: load, RETRY: 'loading' } },
- ready: {
- on: {
- LOAD: load,
- REMOVED: {
- actions: assign((context, event) => ({ devices: context.devices.filter(device => device.id !== event.id) })),
- },
- },
- },
- },
-});
-
export function useUserProfileActiveDevicesController(
model: Extract,
) {
- const [snapshot, send] = useMachine(machine);
- const { loadSessions } = model;
- useEffect(() => {
- send({ type: 'LOAD', loadSessions });
- }, [loadSessions, send]);
-
- const revoke = async (id: string) => {
- await model.revoke(id);
- send({ type: 'REMOVED', id });
- };
+ const [removedIds, setRemovedIds] = useState([]);
return {
- status: snapshot.value === 'ready' ? 'ready' : snapshot.value === 'error' ? 'error' : 'loading',
- devices: snapshot.context.devices,
- retry: () => send({ type: 'RETRY' }),
- onSignOutDevice: revoke,
+ devices: model.devices.filter(device => !removedIds.includes(device.id)),
+ onSignOutDevice: async (id: string) => {
+ const removed = await model.revoke(id);
+ if (removed) {
+ setRemovedIds(ids => [...ids, id]);
+ }
+ return removed;
+ },
// TODO: Wire the model's future bulk action to onSignOutAllOtherDevices and refresh the device list after it settles.
};
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
index a7215888e9b..bfdfe26ce58 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
@@ -1,28 +1,34 @@
+import { differenceInCalendarDays } from '@clerk/shared/date';
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { useClerk, useSession, useUser } from '@clerk/shared/react';
import type { SessionWithActivitiesResource } from '@clerk/shared/types';
-import { useCallback } from 'react';
+import { useEffect, useRef, useState } from 'react';
-import { fill, useLocale, useMessages } from '../../localization';
+import { fill, toLocalizableApiError, useErrorText, useLocale, useMessages } from '../../localization';
import type { UserProfileDevice } from './user-profile-active-devices.types';
export type UserProfileActiveDevicesModel =
| { status: 'loading' }
| { status: 'hidden' }
+ | { status: 'error'; message: string; retry: () => void }
| {
status: 'ready';
identity: string;
- loadSessions: () => Promise;
- revoke: (id: string) => Promise;
+ devices: UserProfileDevice[];
+ revoke: (id: string) => Promise;
};
+type SessionsQuery =
+ | { status: 'loading'; identity: string | undefined }
+ | { status: 'error'; identity: string }
+ | { status: 'ready'; identity: string; sessions: SessionWithActivitiesResource[] };
+
+function isActiveDevice(session: SessionWithActivitiesResource): boolean {
+ return session.status === 'active' || session.status === 'pending';
+}
+
function lastActiveLabel(date: Date, locale: string): string {
- const today = new Date();
- const days = Math.round(
- (Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) -
- Date.UTC(today.getFullYear(), today.getMonth(), today.getDate())) /
- 86_400_000,
- );
+ const days = differenceInCalendarDays(new Date(), date, { absolute: false });
if (Math.abs(days) <= 6) {
return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(days, 'day');
}
@@ -38,77 +44,113 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
const userId = user?.id;
const sessionId = session?.id;
- const toDevice = useCallback(
- (item: SessionWithActivitiesResource): UserProfileDevice => {
- const activity = item.latestActivity;
- const isCurrent = item.id === sessionId;
- const isMobile = Boolean(activity.isMobile);
- const model = activity.deviceType || (isMobile ? m.mobileDevice : m.desktopDevice);
- const browser = [activity.browserName, activity.browserVersion].filter(Boolean).join(' ') || m.webBrowser;
- const location = [activity.city, activity.country].filter(Boolean).join(', ');
- const lastActive = lastActiveLabel(item.lastActiveAt, locale);
- const description = [fill(m.lastSeen, { date: lastActive }), location].filter(Boolean).join(' · ');
-
- return {
- id: item.id,
- name: fill(m.deviceName, { browser: activity.browserName || m.webBrowser, device: model }),
- description,
- type: isMobile ? 'mobile' : 'desktop',
- isCurrent,
- isUserDevice: Boolean(session?.actor && !item.actor && !isCurrent),
- isImpersonationDevice: Boolean(item.actor && !isCurrent),
- lastActive,
- model: activity.deviceType || undefined,
- browser,
- ipAddress: activity.ipAddress,
- location: location || undefined,
- };
- },
- [sessionId, session?.actor, locale, m],
- );
+ const identity = userId && sessionId ? `${userId}:${sessionId}` : undefined;
+ const errorText = useErrorText();
+ const translation = useRef({ m, errorText });
+ translation.current = { m, errorText };
+ const [query, setQuery] = useState({ status: 'loading', identity });
+ const [attempt, setAttempt] = useState(0);
- const loadSessions = useCallback(async (): Promise => {
- if (!user || !sessionId) {
- return [];
+ useEffect(() => {
+ const currentUser = clerk.user;
+ if (!identity || !currentUser || currentUser.id !== userId || clerk.session?.id !== sessionId) {
+ return;
}
- const items = await user.getSessions();
- return items
- .filter(item => item.status === 'active' || item.status === 'pending')
- .sort((a, b) => Number(b.id === sessionId) - Number(a.id === sessionId))
- .map(toDevice);
- }, [user, sessionId, toDevice]);
+ let active = true;
+ setQuery({ status: 'loading', identity });
+ void currentUser.getSessions({ forceRefresh: true, throwOnError: true }).then(
+ sessions => {
+ if (active && clerk.user?.id === userId && clerk.session?.id === sessionId) {
+ setQuery({ status: 'ready', identity, sessions });
+ }
+ },
+ () => {
+ if (active && clerk.user?.id === userId && clerk.session?.id === sessionId) {
+ setQuery({ status: 'error', identity });
+ }
+ },
+ );
+ return () => {
+ active = false;
+ };
+ }, [clerk, userId, sessionId, identity, attempt]);
+
+ const toDevice = (item: SessionWithActivitiesResource): UserProfileDevice => {
+ const activity = item.latestActivity;
+ const isCurrent = item.id === sessionId;
+ const isMobile = Boolean(activity.isMobile);
+ const model = activity.deviceType || (isMobile ? m.mobileDevice : m.desktopDevice);
+ const browser = [activity.browserName, activity.browserVersion].filter(Boolean).join(' ') || m.webBrowser;
+ const location = [activity.city, activity.country].filter(Boolean).join(', ');
+ const lastActive = lastActiveLabel(item.lastActiveAt, locale);
+ const description = [fill(m.lastSeen, { date: lastActive }), location].filter(Boolean).join(' · ');
+
+ return {
+ id: item.id,
+ name: fill(m.deviceName, { browser: activity.browserName || m.webBrowser, device: model }),
+ description,
+ type: isMobile ? 'mobile' : 'desktop',
+ isCurrent,
+ isUserDevice: Boolean(session?.actor && !item.actor && !isCurrent),
+ isImpersonationDevice: Boolean(item.actor && !isCurrent),
+ lastActive,
+ model: activity.deviceType || undefined,
+ browser,
+ ipAddress: activity.ipAddress,
+ location: location || undefined,
+ };
+ };
if (!isUserLoaded || !isSessionLoaded) {
return { status: 'loading' };
}
- if (!user || !session) {
+ if (!user || !session || !identity) {
return { status: 'hidden' };
}
+ if (query.identity !== identity || query.status === 'loading') {
+ return { status: 'loading' };
+ }
+ if (query.status === 'error') {
+ return {
+ status: 'error',
+ message: m.loadError,
+ retry: () => setAttempt(value => value + 1),
+ };
+ }
+
return {
status: 'ready',
- identity: `${user.id}:${session.id}`,
- loadSessions,
+ identity,
+ devices: query.sessions
+ .filter(isActiveDevice)
+ .sort((a, b) => Number(b.id === sessionId) - Number(a.id === sessionId))
+ .map(toDevice),
// TODO: Add bulk revocation when a dedicated API is available, preserving the current session and reverification.
// TODO: Add session reverification for device revocation; surface API errors until then.
revoke: async id => {
try {
- if (clerk.user?.id !== userId || clerk.session?.id !== sessionId || id === sessionId) {
- throw new Error(m.signOutError);
+ const currentUser = clerk.user;
+ if (
+ !currentUser ||
+ !sessionId ||
+ currentUser.id !== userId ||
+ clerk.session?.id !== sessionId ||
+ id === sessionId
+ ) {
+ throw new Error(translation.current.m.signOutError);
}
- const target = (await user.getSessions()).find(
- item => item.id === id && (item.status === 'active' || item.status === 'pending'),
- );
+ const sessions = await currentUser.getSessions({ forceRefresh: true, throwOnError: true });
+ const target = sessions.find(item => item.id === id && isActiveDevice(item));
if (!target || clerk.user?.id !== userId || clerk.session?.id !== sessionId) {
- throw new Error(m.signOutError);
+ throw new Error(translation.current.m.signOutError);
}
await target.revoke();
+ return clerk.user?.id === userId && clerk.session?.id === sessionId;
} catch (error) {
- if (isClerkAPIResponseError(error)) {
- const first = error.errors[0];
- throw new Error(first?.longMessage || first?.message || m.signOutError);
- }
- throw error;
+ const { m: messages, errorText: translate } = translation.current;
+ const first = isClerkAPIResponseError(error) ? error.errors[0] : undefined;
+ throw new Error(first ? translate(toLocalizableApiError(first, messages.signOutError)) : messages.signOutError);
}
},
};
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx
index 7a6704c821a..1c62edf4364 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx
@@ -9,41 +9,30 @@ import { UserProfileActiveDevicesSectionView } from './user-profile-active-devic
export function UserProfileActiveDevicesSection({ fallback = null }: { fallback?: ReactNode }) {
const model = useUserProfileActiveDevicesModel();
+ const m = useMessages('userProfileActiveDevices');
if (model.status === 'hidden') {
return null;
}
if (model.status === 'loading') {
return fallback;
}
+ if (model.status === 'error') {
+ return (
+
+ {model.message}
+
+
+ );
+ }
return (
);
}
-function ActiveDevices({
- model,
- fallback,
-}: {
- model: Extract;
- fallback: ReactNode;
-}) {
+function ActiveDevices({ model }: { model: Extract }) {
const controller = useUserProfileActiveDevicesController(model);
- const m = useMessages('userProfileActiveDevices');
-
- if (controller.status === 'loading') {
- return fallback;
- }
- if (controller.status === 'error') {
- return (
-
- {m.loadError}
-
-
- );
- }
return ;
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts
index a944452e1c7..a8e4cb1c074 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts
@@ -1,5 +1,5 @@
import { setup } from '../../machine/setup';
-import { useMachine } from '../../machine/useMachine';
+import { useMachine } from '../../machine/use-machine';
import type { UserProfileDevice } from './user-profile-active-devices.types';
interface Context {
diff --git a/packages/mosaic/src/hooks/__tests__/useListRemovalFocus.test.ts b/packages/mosaic/src/hooks/__tests__/useListRemovalFocus.test.ts
deleted file mode 100644
index e4bc36621c4..00000000000
--- a/packages/mosaic/src/hooks/__tests__/useListRemovalFocus.test.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { renderHook } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
-
-import { useListRemovalFocus } from '../useListRemovalFocus';
-
-describe('list removal focus', () => {
- it('leaves focus restoration to the dialog when removal is cancelled', async () => {
- const fallback = document.createElement('button');
- const { result } = renderHook(() =>
- useListRemovalFocus({
- ids: ['device'],
- onRemove: () => Promise.resolve(false),
- fallback: () => fallback,
- }),
- );
-
- await result.current.remove('device');
-
- expect(result.current.finalFocus()).toBeNull();
- });
-});
diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts
index 1d935a5ab98..d147a518a47 100644
--- a/packages/shared/src/types/user.ts
+++ b/packages/shared/src/types/user.ts
@@ -30,6 +30,11 @@ import type { UserOrganizationInvitationResource } from './userOrganizationInvit
import type { SnakeToCamel } from './utils';
import type { Web3WalletResource } from './web3Wallet';
+export interface GetSessionsParams {
+ forceRefresh?: boolean;
+ throwOnError?: boolean;
+}
+
declare global {
/**
* If you want to provide custom types for the user.publicMetadata object,
@@ -264,10 +269,11 @@ export interface UserResource extends ClerkResource, BillingPayerMethods {
*/
isPrimaryIdentification: (ident: EmailAddressResource | PhoneNumberResource | Web3WalletResource) => boolean;
/**
- * Gets all **active** sessions for this user. This method uses a cache so a network request will only be triggered only once.
+ * Gets all **active** sessions for this user. Results are cached unless `forceRefresh` is enabled.
+ * Failed requests resolve to an empty array unless `throwOnError` is enabled.
* @returns An array of [`SessionWithActivities`](https://clerk.com/docs/reference/types/session-with-activities) objects.
*/
- getSessions: () => Promise;
+ getSessions: (params?: GetSessionsParams) => Promise;
/**
* Adds the user's profile image or replaces it if one already exists. This method will upload an image and associate it with the user.
*/
From ec7c277f7a45663fe7b9cdccec7dca48e8fd0236 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 16:33:42 -0600
Subject: [PATCH 05/10] fix(swingset): add active devices live flow
---
.../app/(clerk)/live/active-devices/page.tsx | 34 +++++++++++++++++++
packages/swingset/src/lib/live-navigation.ts | 1 +
2 files changed, 35 insertions(+)
create mode 100644 packages/swingset/src/app/(clerk)/live/active-devices/page.tsx
diff --git a/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx b/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx
new file mode 100644
index 00000000000..2fde0ecfbc6
--- /dev/null
+++ b/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { UserProfileActiveDevicesSection } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section';
+import { MosaicProvider } from '@clerk/mosaic/mosaic-provider';
+import { useUser } from '@clerk/nextjs';
+import Link from 'next/link';
+
+export default function ActiveDevicesLivePage() {
+ const { isLoaded, isSignedIn } = useUser();
+
+ return (
+
+
+
+
Active devices
+
Review the devices signed in to your account.
+
+ {!isLoaded ?
Loading…
: null}
+ {isLoaded && !isSignedIn ? (
+
+
+ Sign in
+ {' '}
+ to review your active devices.
+
+ ) : null}
+ {isLoaded && isSignedIn ?
: null}
+
+
+ );
+}
diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts
index 9b97aa31ea3..60376345061 100644
--- a/packages/swingset/src/lib/live-navigation.ts
+++ b/packages/swingset/src/lib/live-navigation.ts
@@ -13,6 +13,7 @@ const liveRoutes = new Map([
['/user-profile/user-profile-api-keys-panel', '/live/api-keys'],
['/user-profile/user-profile-account-section', '/live/account-section'],
['/user-profile/user-profile-password-section', '/live/password'],
+ ['/user-profile/user-profile-active-devices-section', '/live/active-devices'],
['/user-profile/user-profile-connected-accounts-section', '/live/connected-accounts'],
['/user-profile/user-profile-enterprise-accounts-section', '/live/enterprise-accounts'],
['/user-profile/user-profile-danger-section', '/live/user-danger'],
From 468934493eb9f4e7fba18dfd3e4ed57fec8a0e8b Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Sat, 3 Oct 2026 14:24:44 -0600
Subject: [PATCH 06/10] chore(mosaic): keep active-device deferrals in feature
tests
---
.../user-profile-active-devices-section.controller.ts | 1 -
.../user-profile/user-profile-active-devices-section.model.ts | 2 --
.../user-profile/user-profile-active-devices-section.view.tsx | 1 -
3 files changed, 4 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
index 3879f27c2fa..93091f42ffe 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
@@ -16,6 +16,5 @@ export function useUserProfileActiveDevicesController(
}
return removed;
},
- // TODO: Wire the model's future bulk action to onSignOutAllOtherDevices and refresh the device list after it settles.
};
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
index bfdfe26ce58..02e579baac2 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
@@ -126,8 +126,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
.filter(isActiveDevice)
.sort((a, b) => Number(b.id === sessionId) - Number(a.id === sessionId))
.map(toDevice),
- // TODO: Add bulk revocation when a dedicated API is available, preserving the current session and reverification.
- // TODO: Add session reverification for device revocation; surface API errors until then.
revoke: async id => {
try {
const currentUser = clerk.user;
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 bef375b0dbe..afc3b0e5856 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
@@ -22,7 +22,6 @@ export type { UserProfileDevice };
export interface UserProfileActiveDevicesSectionViewProps {
devices: UserProfileDevice[];
onSignOutDevice?: (id: string) => void | boolean | Promise;
- // TODO: Connect bulk sign-out when the model and controller support it; keep its confirmation open while pending.
onSignOutAllOtherDevices?: () => void | Promise;
}
From 047bd0472222c1449631c331e5970e4ddb0bd24e Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Sat, 3 Oct 2026 15:49:03 -0600
Subject: [PATCH 07/10] test(mosaic): await rendered device revoke errors
---
.../user-profile-active-devices.feature.test.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
index b8214ee720b..86659246bb3 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
@@ -261,7 +261,7 @@ describe('Active devices', () => {
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('Could not revoke device');
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not revoke device'));
expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active');
expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeInTheDocument();
@@ -310,7 +310,7 @@ describe('Active devices', () => {
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('Cet appareil est indisponible.');
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Cet appareil est indisponible.'));
expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeVisible();
});
@@ -563,7 +563,7 @@ describe('Active devices', () => {
const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog');
await user.click(within(dialog).getByRole('button', { name: 'Sign out' }));
- expect(await within(dialog).findByRole('alert')).toHaveTextContent('Verification required');
+ await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent('Verification required'));
expect(openReverification).not.toHaveBeenCalled();
expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active');
expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeInTheDocument();
@@ -619,7 +619,7 @@ describe('Active devices', () => {
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('Could not revoke device');
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not revoke device'));
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeVisible();
From da682834ea82bbfb46777b1f150e87feeea7b60d Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 5 Oct 2026 23:03:10 -0600
Subject: [PATCH 08/10] refactor(mosaic): group active device section files
---
...er-profile-active-devices.feature.test.tsx | 2 +-
.../user-profile-device-actions.test.tsx | 4 ++--
.../user-profile-security-panel.view.test.tsx | 4 ++--
...ofile-active-devices-section.controller.ts | 0
...er-profile-active-devices-section.model.ts | 2 +-
.../user-profile-active-devices-section.tsx | 4 ++--
...er-profile-active-devices-section.view.tsx | 24 +++++++++----------
.../user-profile-active-devices.messages.ts | 0
.../user-profile-active-devices.types.ts | 0
.../user-profile-device-details.controller.ts | 4 ++--
.../user-profile-device-details.dialog.tsx | 12 +++++-----
.../user-profile-security-panel.view.tsx | 2 +-
packages/mosaic/src/localization/registry.ts | 2 +-
.../app/(clerk)/live/active-devices/page.tsx | 2 +-
.../fixtures/user-profile-active-devices.ts | 2 +-
.../src/stories/fixtures/user-profile.tsx | 2 +-
...profile-active-devices-section.stories.tsx | 5 ++--
17 files changed, 36 insertions(+), 35 deletions(-)
rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices-section.controller.ts (100%)
rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices-section.model.ts (99%)
rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices-section.tsx (92%)
rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices-section.view.tsx (91%)
rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices.messages.ts (100%)
rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices.types.ts (100%)
rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-device-details.controller.ts (94%)
rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-device-details.dialog.tsx (90%)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
index 86659246bb3..dd6f2fd6185 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
@@ -9,7 +9,7 @@ import { type ActiveDeviceRecord, fapiUrl, serveFapi, worker } from '../../../__
import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
-import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section';
+import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section';
import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
const alice = fapiUser({ id: 'user_1' });
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 4b9fb1a540b..a1ed2531c49 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
@@ -5,8 +5,8 @@ import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { MosaicProvider } from '../../../mosaic-provider';
-import type { UserProfileDevice } from '../user-profile-active-devices.types';
-import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section.view';
+import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices.types';
+import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view';
const current: UserProfileDevice = {
id: 'current',
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 62168bd638a..62c12fbf320 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
@@ -5,8 +5,8 @@ import { describe, expect, it, vi } from 'vitest';
import { useDestructiveController } from '../../../blocks/destructive/destructive.controller';
import { MosaicProvider } from '../../../mosaic-provider';
-import type { UserProfileDevice } from '../user-profile-active-devices-section.view';
-import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section.view';
+import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices-section.view';
+import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view';
import { UserProfileDangerSectionView } from '../user-profile-danger-section/user-profile-danger-section.view';
import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view';
import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.controller.ts
similarity index 100%
rename from packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts
rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.controller.ts
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts
similarity index 99%
rename from packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts
index 02e579baac2..bcae34b824d 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts
@@ -4,7 +4,7 @@ import { useClerk, useSession, useUser } from '@clerk/shared/react';
import type { SessionWithActivitiesResource } from '@clerk/shared/types';
import { useEffect, useRef, useState } from 'react';
-import { fill, toLocalizableApiError, useErrorText, useLocale, useMessages } from '../../localization';
+import { fill, toLocalizableApiError, useErrorText, useLocale, useMessages } from '../../../localization';
import type { UserProfileDevice } from './user-profile-active-devices.types';
export type UserProfileActiveDevicesModel =
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx
similarity index 92%
rename from packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx
rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx
index 1c62edf4364..cbeb878f79d 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
-import { Button } from '../../components/button';
-import { useMessages } from '../../localization';
+import { Button } from '../../../components/button';
+import { useMessages } from '../../../localization';
import { useUserProfileActiveDevicesController } from './user-profile-active-devices-section.controller';
import type { UserProfileActiveDevicesModel } from './user-profile-active-devices-section.model';
import { useUserProfileActiveDevicesModel } from './user-profile-active-devices-section.model';
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/user-profile-active-devices-section.view.tsx
similarity index 91%
rename from packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx
rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx
index afc3b0e5856..3dc43cbb03c 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/user-profile-active-devices-section.view.tsx
@@ -2,20 +2,20 @@ import * as stylex from '@stylexjs/stylex';
import type { Ref } from 'react';
import { useMemo, useRef, useState } from 'react';
-import { Confirmation } from '../../blocks/confirmation';
-import type { ActionMenuAction } from '../../components/action-menu';
-import { ActionMenu } from '../../components/action-menu';
-import { Badge } from '../../components/badge';
-import { Button } from '../../components/button';
-import { Dialog } from '../../components/dialog';
-import { Section } from '../../components/section';
-import { useListRemovalFocus } from '../../hooks/use-list-removal-focus';
-import type { MosaicMessages } from '../../localization';
-import { fill, plural, useLocale, useMessages } from '../../localization';
+import { Confirmation } from '../../../blocks/confirmation';
+import type { ActionMenuAction } from '../../../components/action-menu';
+import { ActionMenu } from '../../../components/action-menu';
+import { Badge } from '../../../components/badge';
+import { Button } from '../../../components/button';
+import { Dialog } from '../../../components/dialog';
+import { Section } from '../../../components/section';
+import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus';
+import type { MosaicMessages } from '../../../localization';
+import { fill, plural, useLocale, useMessages } from '../../../localization';
+import { UserProfileSecurityIcon } from '../user-profile-security-icon';
+import { styles } from '../user-profile-security-panel.styles';
import type { UserProfileDevice } from './user-profile-active-devices.types';
import { UserProfileDeviceDetailsDialog } from './user-profile-device-details.dialog';
-import { UserProfileSecurityIcon } from './user-profile-security-icon';
-import { styles } from './user-profile-security-panel.styles';
export type { UserProfileDevice };
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-section/user-profile-active-devices.messages.ts
similarity index 100%
rename from packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts
rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts
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-section/user-profile-active-devices.types.ts
similarity index 100%
rename from packages/mosaic/src/features/user-profile/user-profile-active-devices.types.ts
rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.types.ts
diff --git a/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts
similarity index 94%
rename from packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts
rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts
index a8e4cb1c074..16d9ad1e58e 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts
@@ -1,5 +1,5 @@
-import { setup } from '../../machine/setup';
-import { useMachine } from '../../machine/use-machine';
+import { setup } from '../../../machine/setup';
+import { useMachine } from '../../../machine/use-machine';
import type { UserProfileDevice } from './user-profile-active-devices.types';
interface Context {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx
similarity index 90%
rename from packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx
rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx
index 030ae9bfecf..5f3a44b65f7 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx
@@ -1,9 +1,9 @@
-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 { 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';
import { useUserProfileDeviceDetailsController } from './user-profile-device-details.controller';
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 f5274b8d798..d3f11cedea5 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
@@ -3,7 +3,7 @@ import type { ReactElement, ReactNode } from 'react';
import { Panel } from '../../components/panel';
import { Section } from '../../components/section';
import { themeProps } from '../../props';
-import type { UserProfileDevice } from './user-profile-active-devices-section.view';
+import type { UserProfileDevice } from './user-profile-active-devices-section/user-profile-active-devices-section.view';
import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view';
import { UserProfileMfaSectionView } from './user-profile-mfa-section.view';
import type { UserProfilePasskey } from './user-profile-passkeys-section.view';
diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts
index 9064579842f..9d154d38cfd 100644
--- a/packages/mosaic/src/localization/registry.ts
+++ b/packages/mosaic/src/localization/registry.ts
@@ -15,7 +15,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 { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages';
import { userProfileAddAuthenticatorMessages } from '../features/user-profile/user-profile-add-authenticator.messages';
import { userProfileAddSmsMessages } from '../features/user-profile/user-profile-add-sms.messages';
import { userProfileAuthenticatorSetupMessages } from '../features/user-profile/user-profile-authenticator-setup.messages';
diff --git a/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx b/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx
index 2fde0ecfbc6..dabf7c75e51 100644
--- a/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx
+++ b/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { UserProfileActiveDevicesSection } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section';
+import { UserProfileActiveDevicesSection } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section';
import { MosaicProvider } from '@clerk/mosaic/mosaic-provider';
import { useUser } from '@clerk/nextjs';
import Link from 'next/link';
diff --git a/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts b/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts
index 050fb5510f0..b00ab3a2abd 100644
--- a/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts
+++ b/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts
@@ -1,4 +1,4 @@
-import type { UserProfileDevice } from '@clerk/mosaic/features/user-profile/user-profile-active-devices.types';
+import type { UserProfileDevice } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.types';
import { useRef, useState } from 'react';
import { useChaosFixture } from '@/components/ChaosProvider';
diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx
index 7af21de568f..a05abbf793e 100644
--- a/packages/swingset/src/stories/fixtures/user-profile.tsx
+++ b/packages/swingset/src/stories/fixtures/user-profile.tsx
@@ -1,6 +1,6 @@
import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller';
import type { UserProfileViewProps } from '@clerk/mosaic/features/user-profile/user-profile.view';
-import { UserProfileActiveDevicesSectionView } 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/user-profile-active-devices-section.view';
import type {
UserProfilePaymentMethod,
UserProfileSubscription,
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 5127d7e25a1..984ea0ca1fe 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,4 +1,4 @@
-import { UserProfileActiveDevicesSectionView } 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/user-profile-active-devices-section.view';
import type { StoryMeta } from '@/lib/types';
@@ -15,7 +15,8 @@ export const meta: StoryMeta = {
title: 'UserProfileActiveDevicesSection',
label: 'Active devices',
navigation: { category: 'Sections' },
- source: 'packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx',
+ source:
+ 'packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx',
};
export function Default() {
From 757bdad416d74cb992ebe0a815b126260ce0e53a Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 5 Oct 2026 23:03:11 -0600
Subject: [PATCH 09/10] test(mosaic): consolidate active device behavior
coverage
---
.changeset/quiet-devices-feedback.md | 2 +
...er-profile-active-devices.feature.test.tsx | 293 ++++++++++++++
.../user-profile-device-actions.test.tsx | 368 ------------------
3 files changed, 295 insertions(+), 368 deletions(-)
create mode 100644 .changeset/quiet-devices-feedback.md
delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx
diff --git a/.changeset/quiet-devices-feedback.md b/.changeset/quiet-devices-feedback.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/quiet-devices-feedback.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
index dd6f2fd6185..e03a04506c6 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
@@ -3,13 +3,16 @@ import { createDeferredPromise } from '@clerk/shared/utils';
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
+import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { type ActiveDeviceRecord, fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
+import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices.types';
import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section';
+import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view';
import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
const alice = fapiUser({ id: 'user_1' });
@@ -124,12 +127,18 @@ describe('Active devices', () => {
expect(screen.queryByRole('button', { name: 'Manage Safari on Expired laptop' })).toBeNull();
const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Safari on MacBook Pro' }));
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+ expect(within(screen.getByRole('dialog')).queryByRole('button', { name: 'Sign out' })).toBeNull();
+ await user.click(within(screen.getByRole('dialog')).getByText('Close'));
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
await user.click(screen.getByRole('button', { name: 'Manage Safari on iPhone' }));
await user.click(screen.getByRole('menuitem', { name: 'View details' }));
expect(within(screen.getByRole('dialog')).getByText('192.0.2.1')).toBeInTheDocument();
expect(within(screen.getByRole('dialog')).getByText('Paris, France')).toBeInTheDocument();
expect(within(screen.getByRole('dialog')).queryByText('Original sign in')).toBeNull();
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' }));
+ expect(screen.queryByRole('alertdialog')).toBeNull();
await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull());
expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked');
@@ -259,6 +268,10 @@ describe('Active devices', () => {
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+ expect(
+ within(screen.getByRole('alertdialog')).getByText(/Safari on iPhone will be signed out/),
+ ).toBeInTheDocument();
+ expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active');
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not revoke device'));
@@ -606,6 +619,36 @@ describe('Active devices', () => {
expect(requests).toBe(1);
});
+ it('keeps device details open after a failed revoke and allows retrying', async () => {
+ const devices = serveDevices(
+ [device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })],
+ { failOnceId: 'sess_other' },
+ );
+ let requests = 0;
+ worker.use(
+ http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), () => {
+ requests += 1;
+ }),
+ );
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
+ 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('alert')).toHaveTextContent('Could not revoke device'));
+ expect(dialog).toBeInTheDocument();
+ expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active');
+ expect(requests).toBe(1);
+
+ await user.click(within(dialog).getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked');
+ expect(requests).toBe(2);
+ expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull();
+ });
+
it('allows details to close after a revoke fails', async () => {
serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })]);
worker.use(
@@ -683,3 +726,253 @@ describe('Active devices', () => {
it.todo('signs out every other eligible device after confirmation while preserving the current session');
it.todo('reverifies device revocation before retrying verification-required API errors');
});
+
+const currentViewDevice: UserProfileDevice = {
+ id: 'current',
+ name: 'Safari on macOS',
+ description: 'Salt Lake City, UT, United States',
+ type: 'desktop',
+ isCurrent: true,
+};
+
+const mobileViewDevice: 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',
+};
+
+async function renderDevices(onSignOutDevice?: (id: string) => void | Promise) {
+ serveDevices([device('sess_current', 'active')]);
+ return renderWithClerk(
+ ,
+ );
+}
+
+async function openMenu(user: ReturnType, item: UserProfileDevice) {
+ await user.click(screen.getByRole('button', { name: `Manage ${item.name}` }));
+}
+
+describe('active devices view contract', () => {
+ it('renders every provided device detail field', async () => {
+ const user = userEvent.setup();
+ await renderDevices();
+ await openMenu(user, mobileViewDevice);
+ 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();
+ await renderDevices();
+ await openMenu(user, currentViewDevice);
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+
+ expect(within(screen.getByRole('dialog')).queryByText('Browser')).not.toBeInTheDocument();
+ });
+
+ describe('signing out of all other devices', () => {
+ async function renderAll(
+ onSignOutAllOtherDevices: () => void | Promise,
+ devices = [currentViewDevice, mobileViewDevice],
+ ) {
+ serveDevices([device('sess_current', 'active')]);
+ return renderWithClerk(
+ ,
+ );
+ }
+
+ const confirmation = () => screen.getByRole('alertdialog');
+
+ it('confirms first, naming how many devices it covers', async () => {
+ const user = userEvent.setup();
+ const onSignOutAllOtherDevices = vi.fn();
+ await renderAll(onSignOutAllOtherDevices, [
+ currentViewDevice,
+ mobileViewDevice,
+ { id: 'desktop', name: 'Clerk App', type: 'desktop' },
+ ]);
+ await user.click(screen.getByRole('button', { name: 'Sign out of all devices' }));
+
+ expect(within(confirmation()).getByText(/2 other devices will be signed out/)).toBeInTheDocument();
+ expect(onSignOutAllOtherDevices).not.toHaveBeenCalled();
+
+ await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' }));
+ expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce();
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ });
+
+ it('leaves the devices alone when the confirmation is cancelled', async () => {
+ const user = userEvent.setup();
+ const onSignOutAllOtherDevices = vi.fn();
+ await renderAll(onSignOutAllOtherDevices);
+ await user.click(screen.getByRole('button', { name: 'Sign out of all devices' }));
+ await user.click(within(confirmation()).getByRole('button', { name: 'Cancel' }));
+
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ expect(onSignOutAllOtherDevices).not.toHaveBeenCalled();
+ expect(screen.getByRole('button', { name: 'Sign out of all devices' })).toHaveFocus();
+ });
+
+ it('holds the confirmation open and explains a failure', async () => {
+ const user = userEvent.setup();
+ const onSignOutAllOtherDevices = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('Unable to sign out of all devices'))
+ .mockResolvedValue(undefined);
+ await renderAll(onSignOutAllOtherDevices);
+ await user.click(screen.getByRole('button', { name: 'Sign out of all devices' }));
+ await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' }));
+
+ expect(await screen.findByText('Unable to sign out of all devices')).toBeInTheDocument();
+ expect(confirmation()).toBeInTheDocument();
+
+ await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).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);
+ await renderAll(onSignOutAllOtherDevices);
+ await user.click(screen.getByRole('button', { name: 'Sign out of all devices' }));
+ const confirm = within(confirmation()).getByRole('button', { name: 'Sign out' });
+ await user.click(confirm);
+ await waitFor(() => expect(confirm).toHaveAttribute('aria-busy'));
+ expect(confirm).toHaveAttribute('aria-disabled', 'true');
+ act(() => confirm.click());
+ expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ signOutAll.resolve();
+ await signOutAll.promise;
+ });
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ });
+
+ it('hands focus to the current device once the others are gone', async () => {
+ const user = userEvent.setup();
+ function Example() {
+ const [devices, setDevices] = useState([currentViewDevice, mobileViewDevice]);
+ return (
+ setDevices(list => list.filter(device => device.isCurrent))}
+ />
+ );
+ }
+ serveDevices([device('sess_current', 'active')]);
+ await renderWithClerk();
+ await user.click(screen.getByRole('button', { name: 'Sign out of all devices' }));
+ await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' }));
+
+ await waitFor(() =>
+ expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).not.toBeInTheDocument(),
+ );
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus());
+ });
+ });
+
+ describe('focus after a delayed row update', () => {
+ const desktop: UserProfileDevice = { id: 'desktop', name: 'Clerk App on macOS', type: 'desktop' };
+ it('skips the signed-out row when the list only catches up later', async () => {
+ const user = userEvent.setup();
+ const catchUp = createDeferredPromise();
+ function LateExample() {
+ const [devices, setDevices] = useState([currentViewDevice, mobileViewDevice, desktop]);
+ return (
+ {
+ void catchUp.promise.then(() => setDevices(list => list.filter(device => device.id !== id)));
+ return Promise.resolve();
+ }}
+ />
+ );
+ }
+ serveDevices([device('sess_current', 'active')]);
+ await renderWithClerk();
+ await openMenu(user, mobileViewDevice);
+ 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 act(async () => {
+ catchUp.resolve();
+ await catchUp.promise;
+ });
+ expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus();
+ });
+ });
+});
+
+describe('active devices focus after connected revocation', () => {
+ it.each(['confirmation', 'details'])('hands focus to the next row after signing out from %s', async surface => {
+ serveDevices([
+ device('sess_current', 'active'),
+ device('sess_other', 'active', { device_type: 'iPhone' }),
+ device('sess_next', 'active', { device_type: 'Next laptop' }),
+ ]);
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
+ await user.click(screen.getByRole('menuitem', { name: surface === 'details' ? 'View details' : 'Sign out' }));
+ const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog');
+ await user.click(within(dialog).getByRole('button', { name: 'Sign out' }));
+
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on Next laptop' })).toHaveFocus());
+ expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull();
+ });
+
+ it('falls back to the previous row, then the current device', async () => {
+ serveDevices([
+ device('sess_current', 'active'),
+ device('sess_other', 'active', { device_type: 'iPhone' }),
+ device('sess_last', 'active', { device_type: 'Last laptop' }),
+ ]);
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on Last laptop' }));
+ 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 iPhone' })).toHaveFocus());
+
+ await user.click(screen.getByRole('button', { name: 'Manage Safari on iPhone' }));
+ 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 MacBook Pro' })).toHaveFocus());
+ });
+
+ it('returns focus to the same row when sign out is cancelled', async () => {
+ serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })]);
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' }));
+ 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 iPhone' })).toHaveFocus());
+ });
+});
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
deleted file mode 100644
index a1ed2531c49..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx
+++ /dev/null
@@ -1,368 +0,0 @@
-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 '../../../mosaic-provider';
-import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices.types';
-import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/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' }));
-
- await waitFor(() => expect(screen.getByRole('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' }));
-
- await waitFor(() => expect(screen.getByRole('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', () => {
- function renderAll(onSignOutAllOtherDevices: () => void | Promise, devices = [current, mobile]) {
- return render(
-
-
- ,
- );
- }
-
- const confirmation = () => screen.getByRole('alertdialog');
-
- it('confirms first, naming how many devices it covers', async () => {
- const user = userEvent.setup();
- const onSignOutAllOtherDevices = vi.fn();
- renderAll(onSignOutAllOtherDevices, [current, mobile, { id: 'desktop', name: 'Clerk App', type: 'desktop' }]);
- await user.click(screen.getByRole('button', { name: 'Sign out of all devices' }));
-
- expect(within(confirmation()).getByText(/2 other devices will be signed out/)).toBeInTheDocument();
- expect(onSignOutAllOtherDevices).not.toHaveBeenCalled();
-
- await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' }));
- expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce();
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- });
-
- it('leaves the devices alone when the confirmation is cancelled', async () => {
- const user = userEvent.setup();
- const onSignOutAllOtherDevices = vi.fn();
- renderAll(onSignOutAllOtherDevices);
- await user.click(screen.getByRole('button', { name: 'Sign out of all devices' }));
- await user.click(within(confirmation()).getByRole('button', { name: 'Cancel' }));
-
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- expect(onSignOutAllOtherDevices).not.toHaveBeenCalled();
- expect(screen.getByRole('button', { name: 'Sign out of all devices' })).toHaveFocus();
- });
-
- it('holds the confirmation open and explains a failure', async () => {
- const user = userEvent.setup();
- const onSignOutAllOtherDevices = vi
- .fn()
- .mockRejectedValueOnce(new Error('Unable to sign out of all devices'))
- .mockResolvedValue(undefined);
- renderAll(onSignOutAllOtherDevices);
- await user.click(screen.getByRole('button', { name: 'Sign out of all devices' }));
- await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' }));
-
- expect(await screen.findByText('Unable to sign out of all devices')).toBeInTheDocument();
- expect(confirmation()).toBeInTheDocument();
-
- await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).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);
- renderAll(onSignOutAllOtherDevices);
- await user.click(screen.getByRole('button', { name: 'Sign out of all devices' }));
- const confirm = within(confirmation()).getByRole('button', { name: 'Sign out' });
- await user.click(confirm);
- await waitFor(() => expect(confirm).toHaveAttribute('aria-busy'));
- await user.click(confirm);
-
- expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- signOutAll.resolve();
- await signOutAll.promise;
- });
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- });
-
- it('hands focus to the current device once the others are gone', async () => {
- const user = userEvent.setup();
- function Example() {
- const [devices, setDevices] = useState([current, mobile]);
- return (
-
- setDevices(list => list.filter(device => device.isCurrent))}
- />
-
- );
- }
- render();
- await user.click(screen.getByRole('button', { name: 'Sign out of all devices' }));
- await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' }));
-
- await waitFor(() =>
- expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).not.toBeInTheDocument(),
- );
- await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus());
- });
-});
-
-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();
- const catchUp = createDeferredPromise();
- function LateExample() {
- const [devices, setDevices] = useState([current, mobile, desktop]);
- return (
-
- {
- void catchUp.promise.then(() => setDevices(list => list.filter(device => device.id !== id)));
- 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 act(async () => {
- catchUp.resolve();
- await catchUp.promise;
- });
- 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());
- });
-});
From a431b3591fdb11ba2444c31ea1228139ff13fd28 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 5 Oct 2026 23:43:21 -0600
Subject: [PATCH 10/10] fix(mosaic): keep focus on the next row after removal
---
.changeset/mosaic-list-removal-focus.md | 5 ++
.../__tests__/use-list-removal-focus.test.tsx | 69 +++++++++++++++++++
.../src/hooks/use-list-removal-focus.ts | 23 +++++--
3 files changed, 90 insertions(+), 7 deletions(-)
create mode 100644 .changeset/mosaic-list-removal-focus.md
create mode 100644 packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx
diff --git a/.changeset/mosaic-list-removal-focus.md b/.changeset/mosaic-list-removal-focus.md
new file mode 100644
index 00000000000..faaf23e19d7
--- /dev/null
+++ b/.changeset/mosaic-list-removal-focus.md
@@ -0,0 +1,5 @@
+---
+'@clerk/mosaic': patch
+---
+
+Keep keyboard focus on the next available row after removing a list item.
diff --git a/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx b/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx
new file mode 100644
index 00000000000..2e84fcc867c
--- /dev/null
+++ b/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx
@@ -0,0 +1,69 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useLayoutEffect, useRef, useState } from 'react';
+import { expect, it } from 'vitest';
+
+import { useListRemovalFocus } from '../use-list-removal-focus';
+
+function RestoreFocus({ target }: { target: () => HTMLElement | null }) {
+ const targetRef = useRef(target);
+ useLayoutEffect(() => {
+ targetRef.current = target;
+ });
+ useLayoutEffect(
+ () => () => {
+ const element = targetRef.current();
+ queueMicrotask(() => element?.focus());
+ },
+ [],
+ );
+ return null;
+}
+
+function Example() {
+ const [ids, setIds] = useState(['removed', 'next']);
+ const [open, setOpen] = useState(true);
+ const fallback = useRef(null);
+ const removalFocus = useListRemovalFocus({
+ ids,
+ onRemove: id => setIds(current => current.filter(item => item !== id)),
+ fallback: () => fallback.current,
+ });
+
+ return (
+ <>
+
+ {ids.map(id => (
+
+ ))}
+ {open ? : null}
+
+ >
+ );
+}
+
+it('focuses the next row when removal and dialog cleanup share a commit', async () => {
+ render();
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' }));
+
+ await waitFor(() => expect(screen.getByRole('button', { name: 'next' })).toHaveFocus(), { timeout: 1000 });
+ expect(screen.queryByRole('button', { name: 'removed' })).not.toBeInTheDocument();
+});
diff --git a/packages/mosaic/src/hooks/use-list-removal-focus.ts b/packages/mosaic/src/hooks/use-list-removal-focus.ts
index 890d4c4b8b9..82cca1944d0 100644
--- a/packages/mosaic/src/hooks/use-list-removal-focus.ts
+++ b/packages/mosaic/src/hooks/use-list-removal-focus.ts
@@ -9,15 +9,24 @@ export function useListRemovalFocus({
onRemove?: (id: string) => void | boolean | Promise;
fallback: () => HTMLElement | null;
}) {
- const triggers = useRef(new Map());
+ const triggers = useRef(
+ new Map void }>(),
+ );
const removed = useRef<{ id: string; index: number } | undefined>(undefined);
- const registerTrigger = (id: string) => (element: HTMLButtonElement | null) => {
- if (element) {
- triggers.current.set(id, element);
- } else {
- triggers.current.delete(id);
+ const registerTrigger = (id: string) => {
+ const trigger = triggers.current.get(id);
+ if (trigger) {
+ return trigger.ref;
}
+ const ref = (element: HTMLButtonElement | null) => {
+ if (element) {
+ triggers.current.set(id, { element, ref });
+ } else {
+ triggers.current.delete(id);
+ }
+ };
+ return ref;
};
const remove = async (id: string) => {
@@ -38,7 +47,7 @@ export function useListRemovalFocus({
}
const remaining = ids.filter(id => id !== item.id);
const next = remaining[Math.min(item.index, remaining.length - 1)];
- return (next ? triggers.current.get(next) : undefined) ?? fallback();
+ return (next ? triggers.current.get(next)?.element : undefined) ?? fallback();
};
return { registerTrigger, remove, finalFocus };