From 81b0d1ef891f3e5f8a330094874d1fa8ee0b5450 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Tue, 29 Sep 2026 19:26:09 -0600
Subject: [PATCH 01/26] 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 | 37 +-
.../__tests__/useListRemovalFocus.test.ts | 21 ++
.../src/hooks/use-list-removal-focus.ts | 7 +-
12 files changed, 754 insertions(+), 21 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 5f5ea410364..69c550c3a04 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
@@ -387,3 +387,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 ec4e42d377b..53ef3187fd7 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,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 7bf2d0a2c6c..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
@@ -3,9 +3,9 @@ import { Card } from '../../components/card';
import { DataList } from '../../components/data-list';
import type { DialogFocusTarget, DialogHandle } from '../../components/dialog';
import { Dialog } from '../../components/dialog';
-import { usePendingAction } from '../../hooks/use-pending-action';
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;
@@ -14,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 : (
)
@@ -36,16 +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 signOut = usePendingAction({ errorFallback: m.detailsDialog.signOutError });
-
const fields: { label: string; value: string | undefined }[] = [
{ label: m.detailsDialog.model, value: device.model },
{ label: m.detailsDialog.browser, value: device.browser },
@@ -70,7 +78,7 @@ function DeviceDetailsCard({
role='alert'
color='negative'
>
- {signOut.error}
+ {errorMessage}
{details.length > 0 ? (
@@ -89,13 +97,8 @@ function DeviceDetailsCard({
- void signOut.run('sign-out', async () => {
- await onSignOut?.(device);
- handle.close();
- })
- }
+ isPending={isSigningOut}
+ 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 75b5e46132de118af9d8d6cf3bcff43e72b3aab5 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 30 Sep 2026 01:18:08 -0600
Subject: [PATCH 02/26] 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 78e25fc2bdd..38d5bcd79a6 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 { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view';
@@ -14,6 +16,27 @@ function DeleteAccount() {
const controller = useDestructiveController({ onDelete: () => Promise.resolve() });
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 passkeys = [
{
@@ -31,27 +54,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 = {}) {
@@ -97,6 +100,13 @@ describe('UserProfileSecurityPanelView', () => {
expect(screen.queryByRole('button', { name: 'Delete account' })).not.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();
@@ -123,8 +133,13 @@ describe('UserProfileSecurityPanelView', () => {
const user = userEvent.setup();
renderView({
- onSignOutDevice,
- onSignOutAllOtherDevices,
+ activeDevicesSlot: (
+
+ ),
});
const signOutAll = screen.getByRole('button', { name: 'Sign out of all devices' });
@@ -155,7 +170,7 @@ describe('UserProfileSecurityPanelView', () => {
/>
),
mfaMethods: [],
- devices: [],
+ activeDevicesSlot: ,
onAddMfaMethod: vi.fn(),
addableMfaMethods: ['sms', 'authenticator'],
});
@@ -167,7 +182,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 f4174e4bb62..a419f33f912 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,24 +3,20 @@ 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';
export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey };
-export interface UserProfileSecurityPanelViewProps extends Omit {
+export interface UserProfileSecurityPanelViewProps {
passwordSlot?: ReactNode;
passkeysSlot?: ReactNode;
mfaMethods?: UserProfileMfaMethod[];
addableMfaMethods?: readonly UserProfileMfaAddableMethod[];
mfaAddControl?: ReactNode;
- devices?: UserProfileDevice[];
+ activeDevicesSlot?: ReactNode;
onAddMfaMethod?: (type: UserProfileMfaAddableMethod) => void;
onRegenerateBackupCodes?: () => void;
onRemoveMfaMethod?: (id: string) => void | Promise;
@@ -33,13 +29,11 @@ export function UserProfileSecurityPanelView({
mfaMethods,
addableMfaMethods,
mfaAddControl,
- devices,
+ activeDevicesSlot,
onAddMfaMethod,
onRegenerateBackupCodes,
onRemoveMfaMethod,
onSetDefaultMfaMethod,
- onSignOutDevice,
- onSignOutAllOtherDevices,
}: UserProfileSecurityPanelViewProps): ReactElement {
const hasAuthentication = passwordSlot != null || Boolean(passkeysSlot) || mfaMethods !== undefined;
@@ -64,13 +58,7 @@ export function UserProfileSecurityPanelView({
) : null}
) : null}
- {devices ? (
-
- ) : null}
+ {activeDevicesSlot}
);
diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx
index 19fefc34559..7c26a8717b2 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,
@@ -159,9 +160,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions
passwordSlot: ,
passkeysSlot: ,
...mfa.security,
- devices: activeDevices.devices,
- onSignOutAllOtherDevices: activeDevices.onSignOutAllOtherDevices,
- onSignOutDevice: activeDevices.onSignOutDevice,
+ activeDevicesSlot: ,
},
billing: {
subscription,
From 6c4511440bb8fd4a796b9cf094b5e4af906ac666 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 30 Sep 2026 01:30:05 -0600
Subject: [PATCH 03/26] 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 84941608095fe8a318adc966a1ad7d98c117b78b Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 15:47:56 -0600
Subject: [PATCH 04/26] 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, '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/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 edc1fa49b40..339a28e9651 100644
--- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
@@ -8,6 +8,7 @@ import type {
OrganizationMembershipJSON,
OrganizationSuggestionJSON,
SessionJSON,
+ SessionWithActivitiesJSON,
UserJSON,
UserOrganizationInvitationJSON,
} from '@clerk/shared/types';
@@ -52,6 +53,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> & {
@@ -140,6 +151,36 @@ function updateOrganization(state: FakeFapiState, organization: OrganizationJSON
};
}
+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 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, passkeys, ...rest } = seed;
const state: FakeFapiState = {
@@ -151,6 +192,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 69c550c3a04..5f5ea410364 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
@@ -387,49 +387,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 e4f2d9d09b6835361a6e5467f4253324c1ffaad6 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 16:33:42 -0600
Subject: [PATCH 05/26] 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 a330e15c410..6b2af89b51a 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([
['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/user-profile/user-profile-api-keys-panel', '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/live/api-keys'],
['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/user-profile/user-profile-account-section', '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/live/account-section'],
['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/user-profile/user-profile-password-section', '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/live/password'],
+ ['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/user-profile/user-profile-active-devices-section', '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/live/active-devices'],
['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/user-profile/user-profile-connected-accounts-section', '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/live/connected-accounts'],
['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/user-profile/user-profile-enterprise-accounts-section', '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/live/enterprise-accounts'],
['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/user-profile/user-profile-danger-section', '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/live/user-danger'],
From d78fc2d481f342b75223febb2918afea80d18be9 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Sat, 3 Oct 2026 14:24:44 -0600
Subject: [PATCH 06/26] 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 53ef3187fd7..c65a52cb4e0 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
@@ -23,7 +23,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 ee6b84df401b26e5fd5e03b08ec61306babc57e8 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Sat, 3 Oct 2026 15:49:03 -0600
Subject: [PATCH 07/26] 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 c8d73118988960e17d73329570b2949d8ade5721 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 5 Oct 2026 23:03:10 -0600
Subject: [PATCH 08/26] 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 | 26 +++++++++----------
.../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, 37 insertions(+), 36 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 (89%)
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 5f5ea410364..d6ed17d57ad 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
@@ -6,8 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { clerkApiError } from '../../../__tests__/clerk-errors';
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 38d5bcd79a6..f4649c0cf4d 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 { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
import type { UserProfileSecurityPanelViewProps } 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 89%
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 c65a52cb4e0..28877fcbf63 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,21 +2,21 @@ import * as stylex from '@stylexjs/stylex';
import type { Ref } from 'react';
import { useMemo, useRef } from 'react';
-import { Confirmation } from '../../blocks/confirmation';
-import { useConfirmationController } from '../../blocks/confirmation/confirmation.controller';
-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 { useConfirmationController } from '../../../blocks/confirmation/confirmation.controller';
+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 a419f33f912..4ed712b6bd0 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 2a40f6b2d6c..5752830a381 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 7c26a8717b2..6dbada86017 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 e3722ce630f0fb15492d078c74bd239edd73889f Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 5 Oct 2026 23:03:11 -0600
Subject: [PATCH 09/26] 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 | 389 ------------------
3 files changed, 295 insertions(+), 389 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 d6ed17d57ad..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx
+++ /dev/null
@@ -1,389 +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 { afterEach, describe, expect, it, vi } from 'vitest';
-
-import { clerkApiError } from '../../../__tests__/clerk-errors';
-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}` }));
-}
-
-afterEach(() => {
- vi.restoreAllMocks();
-});
-
-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 () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- const user = userEvent.setup();
- const onSignOutDevice = vi
- .fn()
- .mockRejectedValueOnce(new Error('Cannot read properties of undefined'))
- .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(
- 'Something went wrong signing this device out. Please try again.',
- ),
- );
-
- 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(clerkApiError('session_not_found', '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 () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- const user = userEvent.setup();
- const onSignOutAllOtherDevices = vi
- .fn()
- .mockRejectedValueOnce(new Error('Cannot read properties of undefined'))
- .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' }));
-
- await waitFor(() =>
- expect(screen.getByRole('alert')).toHaveTextContent(
- 'Something went wrong signing these devices out. Please try again.',
- ),
- );
- 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 8d1802c94e84ec1501a4c3db0a3dac6d28a3211b Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 5 Oct 2026 23:43:21 -0600
Subject: [PATCH 10/26] 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 };
From c121e2786cc3049b668bf2d35a98e06e07d6c186 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Tue, 6 Oct 2026 10:33:19 -0600
Subject: [PATCH 11/26] fix(mosaic): preserve active device error descriptions
---
...er-profile-active-devices.feature.test.tsx | 4 +-
...ser-profile-device-errors.feature.test.tsx | 43 +++++++++++++++++
...er-profile-active-devices-section.model.ts | 46 ++++++++-----------
.../user-profile-device-details.controller.ts | 22 +++++----
4 files changed, 76 insertions(+), 39 deletions(-)
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx
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 e03a04506c6..df05a4d4299 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
@@ -842,7 +842,9 @@ describe('active devices view contract', () => {
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(
+ await screen.findByText('Something went wrong signing these devices out. Please try again.'),
+ ).toBeInTheDocument();
expect(confirmation()).toBeInTheDocument();
await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' }));
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx
new file mode 100644
index 00000000000..161b026f154
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx
@@ -0,0 +1,43 @@
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it } from 'vitest';
+
+import { clerkApiError } from '../../../__tests__/clerk-errors';
+import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
+import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view';
+
+describe.each(['confirmation', 'details'] as const)('device %s errors', surface => {
+ it.each(['private', 'clerk'] as const)('renders a safe localized message for a %s error', async kind => {
+ const user = userEvent.setup();
+ const cause =
+ kind === 'private'
+ ? new Error('Private implementation details')
+ : clerkApiError('invalid_action_for_session', 'Backend device message');
+ const view = render(
+
+ Promise.reject(cause)}
+ />
+ ,
+ );
+ await user.click(screen.getByRole('button', { name: 'Manage Phone' }));
+ 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' }));
+ const expected = kind === 'clerk' ? 'Cet appareil est indisponible.' : 'Déconnexion impossible.';
+ await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent(expected));
+ expect(dialog).toBeVisible();
+ expect(screen.queryByText('Private implementation details')).toBeNull();
+ expect(within(dialog).getByRole('button', { name: 'Sign out' })).not.toHaveAttribute('aria-busy', 'true');
+ view.unmount();
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
index bcae34b824d..21b0a62467b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
@@ -1,10 +1,9 @@
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 { useEffect, useRef, useState } from 'react';
+import { useEffect, useState } from 'react';
-import { fill, toLocalizableApiError, useErrorText, useLocale, useMessages } from '../../../localization';
+import { fill, useLocale, useMessages } from '../../../localization';
import type { UserProfileDevice } from './user-profile-active-devices.types';
export type UserProfileActiveDevicesModel =
@@ -45,9 +44,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
const sessionId = session?.id;
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);
@@ -127,29 +123,23 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
.sort((a, b) => Number(b.id === sessionId) - Number(a.id === sessionId))
.map(toDevice),
revoke: async id => {
- try {
- const currentUser = clerk.user;
- if (
- !currentUser ||
- !sessionId ||
- currentUser.id !== userId ||
- clerk.session?.id !== sessionId ||
- id === sessionId
- ) {
- throw new Error(translation.current.m.signOutError);
- }
- 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(translation.current.m.signOutError);
- }
- await target.revoke();
- return clerk.user?.id === userId && clerk.session?.id === sessionId;
- } catch (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);
+ const currentUser = clerk.user;
+ if (
+ !currentUser ||
+ !sessionId ||
+ currentUser.id !== userId ||
+ clerk.session?.id !== sessionId ||
+ id === sessionId
+ ) {
+ throw new Error(m.signOutError);
+ }
+ 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);
}
+ await target.revoke();
+ return clerk.user?.id === userId && clerk.session?.id === sessionId;
},
};
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts
index 16d9ad1e58e..fab94a49f32 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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,11 +1,13 @@
+import type { ErrorDescription } from '../../../localization';
+import { useErrorText } from '../../../localization';
import { setup } from '../../../machine/setup';
import { useMachine } from '../../../machine/use-machine';
+import { toLocalizableError } from '../../../utils/errors';
import type { UserProfileDevice } from './user-profile-active-devices.types';
interface Context {
run: () => Promise;
- errorMessage: string | undefined;
- fallbackError: string;
+ error: ErrorDescription | undefined;
}
type Event = { type: 'OPEN' } | { type: 'CLOSE' } | { type: 'SIGN_OUT'; run: () => Promise };
@@ -15,17 +17,17 @@ const { createMachine, assign, fromPromise } = setup();
const machine = createMachine({
id: 'deviceDetails',
initial: 'closed',
- context: { run: () => Promise.resolve(), errorMessage: undefined, fallbackError: '' },
+ context: { run: () => Promise.resolve(), error: undefined },
states: {
closed: {
- on: { OPEN: { target: 'open', actions: assign(() => ({ errorMessage: undefined })) } },
+ on: { OPEN: { target: 'open', actions: assign(() => ({ error: undefined })) } },
},
open: {
on: {
CLOSE: 'closed',
SIGN_OUT: {
target: 'signingOut',
- actions: assign((_, event) => ({ run: event.run, errorMessage: undefined })),
+ actions: assign((_, event) => ({ run: event.run, error: undefined })),
},
},
},
@@ -34,9 +36,7 @@ const machine = createMachine({
onDone: 'closed',
onError: {
target: 'open',
- actions: assign((context, event) => ({
- errorMessage: event.error instanceof Error ? event.error.message : context.fallbackError,
- })),
+ actions: assign((_, event) => ({ error: toLocalizableError(event.error) })),
},
}),
},
@@ -50,13 +50,15 @@ export function useUserProfileDeviceDetailsController({
onSignOut?: (device: UserProfileDevice) => void | Promise;
fallbackError: string;
}) {
- const [snapshot, send] = useMachine(machine, { context: { fallbackError } });
+ const [snapshot, send] = useMachine(machine);
+ const errorText = useErrorText();
+ const { error } = snapshot.context;
return {
open: snapshot.value !== 'closed',
onOpenChange: (open: boolean) => send({ type: open ? 'OPEN' : 'CLOSE' }),
isSigningOut: snapshot.value === 'signingOut',
- errorMessage: snapshot.context.errorMessage,
+ errorMessage: error ? errorText(error, fallbackError) : undefined,
onSignOut: onSignOut
? (device: UserProfileDevice) => send({ type: 'SIGN_OUT', run: async () => onSignOut(device) })
: undefined,
From ca76053097bb3efde71d813636efb402d0da339c Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Tue, 6 Oct 2026 10:58:20 -0600
Subject: [PATCH 12/26] test(mosaic): wait for device dialog visibility
---
.../__tests__/user-profile-device-errors.feature.test.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx
index 161b026f154..6c3d9d718c0 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx
@@ -32,10 +32,11 @@ describe.each(['confirmation', 'details'] as const)('device %s errors', surface
await user.click(screen.getByRole('button', { name: 'Manage Phone' }));
await user.click(screen.getByRole('menuitem', { name: surface === 'details' ? 'View details' : 'Sign out' }));
const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog');
+ await waitFor(() => expect(dialog).toBeVisible());
await user.click(within(dialog).getByRole('button', { name: 'Sign out' }));
const expected = kind === 'clerk' ? 'Cet appareil est indisponible.' : 'Déconnexion impossible.';
await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent(expected));
- expect(dialog).toBeVisible();
+ await waitFor(() => expect(dialog).toBeVisible());
expect(screen.queryByText('Private implementation details')).toBeNull();
expect(within(dialog).getByRole('button', { name: 'Sign out' })).not.toHaveAttribute('aria-busy', 'true');
view.unmount();
From b58b850c753b608406323033d7750e2de10050da Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Tue, 6 Oct 2026 11:16:43 -0600
Subject: [PATCH 13/26] chore: keep one empty changeset
---
.changeset/mosaic-list-removal-focus.md | 5 -----
.changeset/quiet-devices-feedback.md | 2 --
2 files changed, 7 deletions(-)
delete mode 100644 .changeset/mosaic-list-removal-focus.md
delete mode 100644 .changeset/quiet-devices-feedback.md
diff --git a/.changeset/mosaic-list-removal-focus.md b/.changeset/mosaic-list-removal-focus.md
deleted file mode 100644
index faaf23e19d7..00000000000
--- a/.changeset/mosaic-list-removal-focus.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@clerk/mosaic': patch
----
-
-Keep keyboard focus on the next available row after removing a list item.
diff --git a/.changeset/quiet-devices-feedback.md b/.changeset/quiet-devices-feedback.md
deleted file mode 100644
index a845151cc84..00000000000
--- a/.changeset/quiet-devices-feedback.md
+++ /dev/null
@@ -1,2 +0,0 @@
----
----
From 2d52843b451960b1701abc90a02af38492de27d9 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 14:34:18 -0600
Subject: [PATCH 14/26] fix(mosaic): simplify active devices sign-out actions
---
.../user-profile-active-devices-section.view.tsx | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
index 28877fcbf63..75ffc33513f 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
@@ -38,15 +38,18 @@ export function UserProfileActiveDevicesSectionView({
const currentDevices = devices.filter(device => device.isCurrent);
const otherDevices = devices.filter(device => !device.isCurrent);
- const openSignOut = onSignOutDevice ? (device: UserProfileDevice) => signOutDevice.open(device) : undefined;
-
const currentDeviceTrigger = useRef(null);
const removalFocus = useListRemovalFocus({
ids: otherDevices.map(device => device.id),
onRemove: onSignOutDevice,
fallback: () => currentDeviceTrigger.current,
});
- const signOutDeviceAt = onSignOutDevice ? (device: UserProfileDevice) => removalFocus.remove(device.id) : undefined;
+ const signOutActions = onSignOutDevice
+ ? {
+ open: (device: UserProfileDevice) => signOutDevice.open(device),
+ remove: (device: UserProfileDevice) => removalFocus.remove(device.id),
+ }
+ : undefined;
const signOutAll = useConfirmationController({ errorFallback: m.signOutAllError });
const signedOutAll = useRef(false);
@@ -106,7 +109,7 @@ export function UserProfileActiveDevicesSectionView({
key={device.id}
device={device}
triggerRef={removalFocus.registerTrigger(device.id)}
- onSignOut={openSignOut}
+ onSignOut={signOutActions?.open}
onViewDetails={device => deviceDetails.open(device)}
/>
))}
@@ -137,7 +140,7 @@ export function UserProfileActiveDevicesSectionView({
{onSignOutDevice ? (
signOutDeviceAt?.(device)}
+ onConfirm={device => signOutActions?.remove(device)}
errorFallback={m.detailsDialog.signOutError}
/>
) : null}
From d7b4b2f83445a7adfad1e20f0651db4bf6f0919a Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 15:10:53 -0600
Subject: [PATCH 15/26] test(mosaic): compose passkeys with active devices slot
---
...er-profile-passkeys-composition.feature.test.tsx | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx
index f14a095dd5a..8c9b3ea65b6 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx
@@ -5,6 +5,7 @@ import { serveFapi } from '../../../__tests__/feature/fake-fapi';
import { fapiClient, fapiEnvironment, fapiPasskey, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { UserProfileView } from '../user-profile.view';
+import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view';
import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
import { passkeysSectionNode } from '../user-profile-passkeys-section/user-profile-passkeys-section';
import { useUserProfilePasskeysModel } from '../user-profile-passkeys-section/user-profile-passkeys-section.model';
@@ -41,7 +42,7 @@ function SecurityHost() {
passwordSlot={passwordSlot}
passkeysSlot={passkeysSlot}
mfaMethods={[]}
- devices={[]}
+ activeDevicesSlot={}
/>
);
}
@@ -53,7 +54,15 @@ function ProfileHost() {
{}}
- pages={{ account: {}, security: { passwordSlot, passkeysSlot, mfaMethods: [], devices: [] } }}
+ pages={{
+ account: {},
+ security: {
+ passwordSlot,
+ passkeysSlot,
+ mfaMethods: [],
+ activeDevicesSlot: ,
+ },
+ }}
/>
);
}
From a0684b30a3abb5a77f19a5d4e6dac212fc921a83 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 17:43:38 -0600
Subject: [PATCH 16/26] refactor(mosaic): reuse clock and confirmation hooks
for active devices
---
.../__tests__/confirmation.controller.test.ts | 20 ++++++
.../confirmation/confirmation.controller.ts | 2 +-
...er-profile-active-devices.feature.test.tsx | 9 ++-
...er-profile-active-devices-section.model.ts | 8 ++-
.../user-profile-device-details.controller.ts | 66 -------------------
.../user-profile-device-details.dialog.tsx | 10 +--
6 files changed, 37 insertions(+), 78 deletions(-)
delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts
diff --git a/packages/mosaic/src/blocks/confirmation/__tests__/confirmation.controller.test.ts b/packages/mosaic/src/blocks/confirmation/__tests__/confirmation.controller.test.ts
index fb5a3adfbd3..b8aec6ccf65 100644
--- a/packages/mosaic/src/blocks/confirmation/__tests__/confirmation.controller.test.ts
+++ b/packages/mosaic/src/blocks/confirmation/__tests__/confirmation.controller.test.ts
@@ -1,3 +1,4 @@
+import { createDeferredPromise } from '@clerk/shared/utils';
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -114,4 +115,23 @@ describe('useConfirmationController', () => {
await waitFor(() => expect(result.current.errorMessage).toBe('Unable to remove this member.'));
});
+
+ it('clears the previous error while a retry is pending', async () => {
+ const { result } = renderHook(() => useConfirmationController());
+ act(() => result.current.onOpenChange(true));
+ act(() => result.current.onConfirm(() => Promise.reject(blocked)));
+ await waitFor(() => expect(result.current.errorMessage).toMatch(/contact support/));
+
+ const pending = createDeferredPromise();
+ try {
+ act(() => result.current.onConfirm(() => pending.promise));
+
+ expect(result.current.isOpen).toBe(true);
+ expect(result.current.isConfirming).toBe(true);
+ expect(result.current.errorMessage).toBeUndefined();
+ } finally {
+ await act(() => pending.resolve());
+ }
+ await waitFor(() => expect(result.current.isOpen).toBe(false));
+ });
});
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
index 053bb1a3f7d..09aace70042 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
@@ -32,7 +32,7 @@ export const confirmationMachine = createMachine({
},
confirming: {
on: {
- CONFIRM: { target: 'pending', actions: assign((_, event) => ({ run: event.run })) },
+ CONFIRM: { target: 'pending', actions: assign((_, event) => ({ run: event.run, error: undefined })) },
CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) },
},
},
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 df05a4d4299..63da8ee4911 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,6 +9,7 @@ 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 { MosaicNowProvider } from '../../../hooks/use-now';
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';
@@ -487,8 +488,8 @@ describe('Active devices', () => {
}
});
- it('localizes relative and formatted activity dates with surrounding text', async () => {
- const now = new Date();
+ it('localizes activity dates using the provider clock', async () => {
+ const now = new Date(2025, 5, 15, 12);
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');
@@ -504,7 +505,9 @@ describe('Active devices', () => {
messages: { userProfileActiveDevices: { lastSeen: 'Vu {date}', deviceName: '{browser} sur {device}' } },
})}
>
-
+
+
+
,
);
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
index 21b0a62467b..636790de752 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
@@ -3,6 +3,7 @@ import { useClerk, useSession, useUser } from '@clerk/shared/react';
import type { SessionWithActivitiesResource } from '@clerk/shared/types';
import { useEffect, useState } from 'react';
+import { useNow } from '../../../hooks/use-now';
import { fill, useLocale, useMessages } from '../../../localization';
import type { UserProfileDevice } from './user-profile-active-devices.types';
@@ -26,8 +27,8 @@ function isActiveDevice(session: SessionWithActivitiesResource): boolean {
return session.status === 'active' || session.status === 'pending';
}
-function lastActiveLabel(date: Date, locale: string): string {
- const days = differenceInCalendarDays(new Date(), date, { absolute: false });
+function lastActiveLabel(date: Date, now: Date, locale: string): string {
+ const days = differenceInCalendarDays(now, date, { absolute: false });
if (Math.abs(days) <= 6) {
return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(days, 'day');
}
@@ -38,6 +39,7 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
const clerk = useClerk();
const { isLoaded: isUserLoaded, user } = useUser();
const { isLoaded: isSessionLoaded, session } = useSession();
+ const now = useNow({ updateInterval: 60_000 });
const locale = useLocale();
const m = useMessages('userProfileActiveDevices');
const userId = user?.id;
@@ -78,7 +80,7 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
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 lastActive = lastActiveLabel(item.lastActiveAt, now, locale);
const description = [fill(m.lastSeen, { date: lastActive }), location].filter(Boolean).join(' · ');
return {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts
deleted file mode 100644
index fab94a49f32..00000000000
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import type { ErrorDescription } from '../../../localization';
-import { useErrorText } from '../../../localization';
-import { setup } from '../../../machine/setup';
-import { useMachine } from '../../../machine/use-machine';
-import { toLocalizableError } from '../../../utils/errors';
-import type { UserProfileDevice } from './user-profile-active-devices.types';
-
-interface Context {
- run: () => Promise;
- error: ErrorDescription | undefined;
-}
-
-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(), error: undefined },
- states: {
- closed: {
- on: { OPEN: { target: 'open', actions: assign(() => ({ error: undefined })) } },
- },
- open: {
- on: {
- CLOSE: 'closed',
- SIGN_OUT: {
- target: 'signingOut',
- actions: assign((_, event) => ({ run: event.run, error: undefined })),
- },
- },
- },
- signingOut: {
- invoke: fromPromise(context => context.run(), {
- onDone: 'closed',
- onError: {
- target: 'open',
- actions: assign((_, event) => ({ error: toLocalizableError(event.error) })),
- },
- }),
- },
- },
-});
-
-export function useUserProfileDeviceDetailsController({
- onSignOut,
- fallbackError,
-}: {
- onSignOut?: (device: UserProfileDevice) => void | Promise;
- fallbackError: string;
-}) {
- const [snapshot, send] = useMachine(machine);
- const errorText = useErrorText();
- const { error } = snapshot.context;
-
- return {
- open: snapshot.value !== 'closed',
- onOpenChange: (open: boolean) => send({ type: open ? 'OPEN' : 'CLOSE' }),
- isSigningOut: snapshot.value === 'signingOut',
- errorMessage: error ? errorText(error, fallbackError) : undefined,
- onSignOut: onSignOut
- ? (device: UserProfileDevice) => send({ type: 'SIGN_OUT', run: async () => onSignOut(device) })
- : undefined,
- };
-}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx
index 5f3a44b65f7..f308e6928ca 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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,3 +1,4 @@
+import { useConfirmationController } from '../../../blocks/confirmation/confirmation.controller';
import { Button, SubmitButton } from '../../../components/button';
import { Card } from '../../../components/card';
import { DataList } from '../../../components/data-list';
@@ -5,7 +6,6 @@ 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,12 +15,12 @@ export interface UserProfileDeviceDetailsDialogProps {
export function UserProfileDeviceDetailsDialog({ handle, finalFocus, onSignOut }: UserProfileDeviceDetailsDialogProps) {
const m = useMessages('userProfileActiveDevices');
- const controller = useUserProfileDeviceDetailsController({ onSignOut, fallbackError: m.detailsDialog.signOutError });
+ const controller = useConfirmationController({ errorFallback: m.detailsDialog.signOutError });
return (
{({ payload: device }) =>
@@ -31,8 +31,8 @@ export function UserProfileDeviceDetailsDialog({ handle, finalFocus, onSignOut }
>
controller.onConfirm(async () => onSignOut(device)) : undefined}
+ isSigningOut={controller.isConfirming}
errorMessage={controller.errorMessage}
/>
From 296782dc6e5b6c216a24184a7eb4b48b60ac5474 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 18:45:02 -0600
Subject: [PATCH 17/26] refactor(mosaic): use the existing SDK API for active
devices
---
.../core/resources/SessionWithActivities.ts | 9 +--
packages/clerk-js/src/core/resources/User.ts | 7 +-
.../resources/__tests__/User.sessions.test.ts | 78 -------------------
...er-profile-active-devices.feature.test.tsx | 12 +--
...er-profile-active-devices-section.model.ts | 37 ++-------
.../user-profile-active-devices-section.tsx | 11 ---
.../user-profile-active-devices.messages.ts | 2 -
packages/shared/src/types/user.ts | 10 +--
8 files changed, 24 insertions(+), 142 deletions(-)
delete mode 100644 packages/clerk-js/src/core/resources/__tests__/User.sessions.test.ts
diff --git a/packages/clerk-js/src/core/resources/SessionWithActivities.ts b/packages/clerk-js/src/core/resources/SessionWithActivities.ts
index 0210f6c4ba0..23c8ababed2 100644
--- a/packages/clerk-js/src/core/resources/SessionWithActivities.ts
+++ b/packages/clerk-js/src/core/resources/SessionWithActivities.ts
@@ -1,6 +1,5 @@
import type {
ActClaim,
- GetSessionsParams,
SessionActivity,
SessionActivityJSON,
SessionWithActivitiesJSON,
@@ -37,10 +36,10 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc
this.fromJSON(data);
}
- static retrieve(params?: Pick): Promise {
+ static retrieve(): Promise {
const sessionId = BaseResource.clerk.session?.id;
- const request = this.clerk
+ return this.clerk
.getFapiClient()
.request({
method: 'GET',
@@ -51,8 +50,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, '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/me/sessions'));
- });
- return params?.throwOnError ? request : request.catch(() => []);
+ })
+ .catch(() => []);
}
revoke(): Promise {
diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts
index 7d6a8c08bc7..a57e8a6249e 100644
--- a/packages/clerk-js/src/core/resources/User.ts
+++ b/packages/clerk-js/src/core/resources/User.ts
@@ -19,7 +19,6 @@ import type {
ExternalAccountResource,
GetEnterpriseConnectionsParams,
GetOrganizationMemberships,
- GetSessionsParams,
GetUserOrganizationInvitationsParams,
GetUserOrganizationSuggestionsParams,
ImageResource,
@@ -331,11 +330,11 @@ export class User extends BaseResource implements UserResource {
});
};
- getSessions = async (params?: GetSessionsParams): Promise => {
- if (!params?.forceRefresh && this.cachedSessionsWithActivities) {
+ getSessions = async (): Promise => {
+ if (this.cachedSessionsWithActivities) {
return this.cachedSessionsWithActivities;
}
- const res = await SessionWithActivities.retrieve(params);
+ const res = await SessionWithActivities.retrieve();
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
deleted file mode 100644
index 2f3c8c2a50f..00000000000
--- a/packages/clerk-js/src/core/resources/__tests__/User.sessions.test.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-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/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 63da8ee4911..b71bbd72f62 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
@@ -159,7 +159,7 @@ describe('Active devices', () => {
expect(screen.queryByRole('button', { name: 'Manage Safari on Expired laptop' })).toBeNull();
});
- it('shows a load error and retries after the device request recovers', async () => {
+ it('preserves the SDK empty-list behavior after a failed device request', async () => {
serveDevices([device('sess_current', 'active')]);
let failing = true;
worker.use(
@@ -170,12 +170,14 @@ describe('Active devices', () => {
),
);
- await renderWithClerk();
+ const view = await renderWithClerk();
- expect(await screen.findByRole('alert')).toHaveTextContent('Could not load active devices.');
+ expect(await screen.findByText('No current device available')).toBeVisible();
+ expect(screen.queryByRole('alert')).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull();
failing = false;
- await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' }));
- expect(await screen.findByText('This device')).toBeVisible();
+ await expect(view.clerk.user?.getSessions()).resolves.toEqual([]);
+ expect(screen.queryByText('This device')).toBeNull();
});
it('maps impersonation sessions to the distinct badges', async () => {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
index 636790de752..fc50f63a685 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
@@ -10,7 +10,6 @@ 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;
@@ -20,7 +19,6 @@ export type UserProfileActiveDevicesModel =
type SessionsQuery =
| { status: 'loading'; identity: string | undefined }
- | { status: 'error'; identity: string }
| { status: 'ready'; identity: string; sessions: SessionWithActivitiesResource[] };
function isActiveDevice(session: SessionWithActivitiesResource): boolean {
@@ -47,7 +45,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
const identity = userId && sessionId ? `${userId}:${sessionId}` : undefined;
const [query, setQuery] = useState({ status: 'loading', identity });
- const [attempt, setAttempt] = useState(0);
useEffect(() => {
const currentUser = clerk.user;
@@ -56,22 +53,15 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
}
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 });
- }
- },
- );
+ void currentUser.getSessions().then(sessions => {
+ if (active && clerk.user?.id === userId && clerk.session?.id === sessionId) {
+ setQuery({ status: 'ready', identity, sessions });
+ }
+ });
return () => {
active = false;
};
- }, [clerk, userId, sessionId, identity, attempt]);
+ }, [clerk, userId, sessionId, identity]);
const toDevice = (item: SessionWithActivitiesResource): UserProfileDevice => {
const activity = item.latestActivity;
@@ -109,14 +99,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
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,
@@ -126,7 +108,9 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
.map(toDevice),
revoke: async id => {
const currentUser = clerk.user;
+ const target = query.sessions.find(item => item.id === id && isActiveDevice(item));
if (
+ !target ||
!currentUser ||
!sessionId ||
currentUser.id !== userId ||
@@ -135,11 +119,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
) {
throw new Error(m.signOutError);
}
- 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);
- }
await target.revoke();
return clerk.user?.id === userId && clerk.session?.id === sessionId;
},
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx
index cbeb878f79d..73da4491512 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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,5 @@
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';
@@ -9,21 +7,12 @@ 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 (
boolean;
/**
- * 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.
+ * Gets all **active** sessions for this user. This method uses a cache so a network request will only be triggered only once.
* @returns An array of [`SessionWithActivities`](https://clerk.com/docs/reference/types/session-with-activities) objects.
*/
- getSessions: (params?: GetSessionsParams) => Promise;
+ getSessions: () => 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 80892a99798686da9e9147bfad208e94a4b16d9a Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 18:55:37 -0600
Subject: [PATCH 18/26] test(mosaic): reproduce stale device details after
locale changes
---
...vice-details-localization.feature.test.tsx | 104 ++++++++++++++++++
1 file changed, 104 insertions(+)
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx
new file mode 100644
index 00000000000..9ec5078ce01
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx
@@ -0,0 +1,104 @@
+import { createDeferredPromise } from '@clerk/shared/utils';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { expect, it } 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 { MosaicNowProvider } from '../../../hooks/use-now';
+import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
+import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section';
+
+const alice = fapiUser({ id: 'user_1' });
+
+function device(id: string, deviceType: string): ActiveDeviceRecord {
+ const { user: _owner, ...session } = fapiSession({ id, user: alice });
+ return {
+ ...session,
+ user: null,
+ ownerUserId: alice.id,
+ last_active_at: new Date(2025, 5, 14, 12).getTime(),
+ latest_activity: {
+ object: 'session_activity',
+ id: `activity_${id}`,
+ browser_name: 'Safari',
+ device_type: deviceType,
+ },
+ };
+}
+
+function section(locale: string) {
+ return (
+
+
+
+
+
+ );
+}
+
+it.each(['idle', 'pending'] as const)('updates open device details after a locale change while %s', async state => {
+ serveFapi({
+ client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]),
+ activeDevices: [device('sess_current', 'Laptop'), device('sess_other', 'Phone')],
+ });
+ 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 view = await renderWithClerk(section('en-US'));
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' }));
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+ const dialog = screen.getByRole('dialog');
+ await waitFor(() => expect(dialog).toBeVisible());
+ expect(within(dialog).getByText('Last active yesterday')).toBeVisible();
+
+ try {
+ if (state === 'pending') {
+ 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.getAllByText('Vu hier')).toHaveLength(2);
+ expect(screen.getByRole('dialog')).toBe(dialog);
+ expect(dialog).toHaveTextContent('Dernière activité hier');
+ expect(within(dialog).getByRole('heading', { name: 'Safari sur Phone' })).toBeVisible();
+ if (state === 'pending') {
+ expect(within(dialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute('aria-busy');
+ await user.keyboard('{Escape}');
+ expect(dialog).toBeVisible();
+ }
+ } finally {
+ pending.resolve();
+ if (state === 'pending') {
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ }
+ }
+});
From a1a40a5e70f90dbef53ca786fa6023390ac0062a Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 19:00:59 -0600
Subject: [PATCH 19/26] fix(mosaic): refresh open device details when the
locale changes
---
...er-profile-active-devices-section.view.tsx | 1 +
.../user-profile-device-details.dialog.tsx | 22 ++++++++++++++-----
2 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
index 75ffc33513f..ace04e57e3c 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
@@ -139,6 +139,7 @@ export function UserProfileActiveDevicesSectionView({
) : null}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx
index f308e6928ca..787634639ff 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx
@@ -9,11 +9,17 @@ import type { UserProfileDevice } from './user-profile-active-devices.types';
export interface UserProfileDeviceDetailsDialogProps {
handle: DialogHandle;
+ devices: UserProfileDevice[];
finalFocus?: DialogFocusTarget;
onSignOut?: (device: UserProfileDevice) => void | Promise;
}
-export function UserProfileDeviceDetailsDialog({ handle, finalFocus, onSignOut }: UserProfileDeviceDetailsDialogProps) {
+export function UserProfileDeviceDetailsDialog({
+ handle,
+ devices,
+ finalFocus,
+ onSignOut,
+}: UserProfileDeviceDetailsDialogProps) {
const m = useMessages('userProfileActiveDevices');
const controller = useConfirmationController({ errorFallback: m.detailsDialog.signOutError });
@@ -23,8 +29,14 @@ export function UserProfileDeviceDetailsDialog({ handle, finalFocus, onSignOut }
open={controller.isOpen}
onOpenChange={controller.onOpenChange}
>
- {({ payload: device }) =>
- device === undefined ? null : (
+ {({ payload }) => {
+ if (payload === undefined) {
+ return null;
+ }
+
+ const device = devices.find(candidate => candidate.id === payload.id) ?? payload;
+
+ return (
- )
- }
+ );
+ }}
);
}
From c50b3c0bc6df7b20ed2a4e7dab1a1fcbc678b239 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 19:18:55 -0600
Subject: [PATCH 20/26] test(mosaic): align active device tests with feature
conventions
---
...vice-details-localization.feature.test.tsx | 104 -----------------
...e-active-devices-section.feature.test.tsx} | 78 +++----------
...ices-section.localization.feature.test.tsx | 108 ++++++++++++++++++
...ices-section.view.errors.feature.test.tsx} | 2 +-
4 files changed, 128 insertions(+), 164 deletions(-)
delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx
rename packages/mosaic/src/features/user-profile/{__tests__/user-profile-active-devices.feature.test.tsx => user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx} (93%)
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx
rename packages/mosaic/src/features/user-profile/{__tests__/user-profile-device-errors.feature.test.tsx => user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx} (94%)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx
deleted file mode 100644
index 9ec5078ce01..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { createDeferredPromise } from '@clerk/shared/utils';
-import { screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { http, HttpResponse } from 'msw';
-import { expect, it } 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 { MosaicNowProvider } from '../../../hooks/use-now';
-import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
-import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section';
-
-const alice = fapiUser({ id: 'user_1' });
-
-function device(id: string, deviceType: string): ActiveDeviceRecord {
- const { user: _owner, ...session } = fapiSession({ id, user: alice });
- return {
- ...session,
- user: null,
- ownerUserId: alice.id,
- last_active_at: new Date(2025, 5, 14, 12).getTime(),
- latest_activity: {
- object: 'session_activity',
- id: `activity_${id}`,
- browser_name: 'Safari',
- device_type: deviceType,
- },
- };
-}
-
-function section(locale: string) {
- return (
-
-
-
-
-
- );
-}
-
-it.each(['idle', 'pending'] as const)('updates open device details after a locale change while %s', async state => {
- serveFapi({
- client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]),
- activeDevices: [device('sess_current', 'Laptop'), device('sess_other', 'Phone')],
- });
- 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 view = await renderWithClerk(section('en-US'));
- const user = userEvent.setup();
- await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' }));
- await user.click(screen.getByRole('menuitem', { name: 'View details' }));
- const dialog = screen.getByRole('dialog');
- await waitFor(() => expect(dialog).toBeVisible());
- expect(within(dialog).getByText('Last active yesterday')).toBeVisible();
-
- try {
- if (state === 'pending') {
- 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.getAllByText('Vu hier')).toHaveLength(2);
- expect(screen.getByRole('dialog')).toBe(dialog);
- expect(dialog).toHaveTextContent('Dernière activité hier');
- expect(within(dialog).getByRole('heading', { name: 'Safari sur Phone' })).toBeVisible();
- if (state === 'pending') {
- expect(within(dialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute('aria-busy');
- await user.keyboard('{Escape}');
- expect(dialog).toBeVisible();
- }
- } finally {
- pending.resolve();
- if (state === 'pending') {
- await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
- }
- }
-});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
similarity index 93%
rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
index b71bbd72f62..21fd71bbb6e 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
@@ -6,15 +6,21 @@ 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 {
+ type ActiveDeviceRecord,
+ fapiUrl,
+ holdRequests,
+ serveFapi,
+ worker,
+} from '../../../__tests__/feature/fake-fapi';
import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { MosaicNowProvider } from '../../../hooks/use-now';
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';
+import type { UserProfileDevice } from './user-profile-active-devices.types';
+import { UserProfileActiveDevicesSection } from './user-profile-active-devices-section';
+import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view';
const alice = fapiUser({ id: 'user_1' });
@@ -525,44 +531,6 @@ 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 => {
@@ -592,36 +560,28 @@ describe('Active devices', () => {
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();
+ const revoke = holdRequests('post', '/v1/me/sessions/sess_other/revoke');
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.dblClick(within(dialog).getByRole('button', { name: 'Sign out' }));
- await waitFor(() => expect(requests).toBe(1));
+ await waitFor(() => expect(revoke.requests).toHaveLength(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(screen.getByRole('dialog')).toBeVisible();
+ await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' }));
+ expect(screen.getByRole('dialog')).toBeVisible();
await user.click(document.body);
- expect(screen.getByRole('dialog')).toBe(dialog);
- expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy');
+ expect(screen.getByRole('dialog')).toBeVisible();
+ expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy');
} finally {
- pending.resolve();
+ revoke.release();
}
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull();
- expect(requests).toBe(1);
+ expect(revoke.requests).toHaveLength(1);
});
it('keeps device details open after a failed revoke and allows retrying', async () => {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx
new file mode 100644
index 00000000000..212527dd8df
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx
@@ -0,0 +1,108 @@
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it } from 'vitest';
+
+import { type ActiveDeviceRecord, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
+import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { MosaicNowProvider } from '../../../hooks/use-now';
+import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
+import { UserProfileActiveDevicesSection } from './user-profile-active-devices-section';
+
+const alice = fapiUser({ id: 'user_1' });
+
+function device(id: string, deviceType: string): ActiveDeviceRecord {
+ const { user: _owner, ...session } = fapiSession({ id, user: alice });
+ return {
+ ...session,
+ user: null,
+ ownerUserId: alice.id,
+ last_active_at: new Date(2025, 5, 14, 12).getTime(),
+ latest_activity: {
+ object: 'session_activity',
+ id: `activity_${id}`,
+ browser_name: 'Safari',
+ device_type: deviceType,
+ },
+ };
+}
+
+function section(locale: string) {
+ return (
+
+
+
+
+
+ );
+}
+
+describe('Change language while viewing device details', () => {
+ it.each(['idle', 'pending'] as const)('updates open device details after a locale change while %s', async state => {
+ serveFapi({
+ client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]),
+ activeDevices: [device('sess_current', 'Laptop'), device('sess_other', 'Phone')],
+ });
+ const view = await renderWithClerk(section('en-US'));
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' }));
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+ const dialog = screen.getByRole('dialog');
+ await waitFor(() => expect(dialog).toBeVisible());
+ expect(within(dialog).getByText('Last active yesterday')).toBeVisible();
+ const revoke = state === 'pending' ? holdRequests('post', '/v1/me/sessions/sess_other/revoke') : undefined;
+
+ try {
+ if (revoke) {
+ await user.click(within(dialog).getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(revoke.requests).toHaveLength(1));
+ await waitFor(() =>
+ expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })).toHaveAttribute(
+ 'aria-busy',
+ 'true',
+ ),
+ );
+ }
+
+ view.rerender(section('fr-FR'));
+
+ expect(screen.getAllByText('Vu hier')).toHaveLength(2);
+ const translatedDialog = screen.getByRole('dialog');
+ expect(translatedDialog).toHaveTextContent('Dernière activité hier');
+ expect(within(translatedDialog).getByRole('heading', { name: 'Safari sur Phone' })).toBeVisible();
+ if (revoke) {
+ expect(within(translatedDialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute(
+ 'aria-busy',
+ 'true',
+ );
+ await user.keyboard('{Escape}');
+ const stillOpenDialog = screen.getByRole('dialog');
+ expect(stillOpenDialog).toBeVisible();
+ expect(within(stillOpenDialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute(
+ 'aria-busy',
+ 'true',
+ );
+ }
+ } finally {
+ revoke?.release();
+ if (revoke) {
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage Safari sur Phone' })).toBeNull());
+ }
+ }
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx
similarity index 94%
rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx
rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx
index 6c3d9d718c0..ef1e6f9aa31 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest';
import { clerkApiError } from '../../../__tests__/clerk-errors';
import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
-import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view';
+import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view';
describe.each(['confirmation', 'details'] as const)('device %s errors', surface => {
it.each(['private', 'clerk'] as const)('renders a safe localized message for a %s error', async kind => {
From 82f05705379b18b08316dd90903298551c3b5ed6 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 19:56:00 -0600
Subject: [PATCH 21/26] test(mosaic): reproduce unavailable device guard error
fallback
---
...ices-section.guard-errors.feature.test.tsx | 78 +++++++++++++++++++
1 file changed, 78 insertions(+)
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx
new file mode 100644
index 00000000000..74bc79e363d
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx
@@ -0,0 +1,78 @@
+import { act, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { type ActiveDeviceRecord, serveFapi } 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';
+
+describe('Sign out an unavailable device', () => {
+ it.each([
+ ['en-US', 'This device is no longer available. Please try again.'],
+ ['fr-FR', 'Cet appareil est indisponible.'],
+ ])('shows the catalog message in %s', async (locale, expectedMessage) => {
+ const alice = fapiUser({ id: 'user_1' });
+ serveFapi({
+ client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]),
+ activeDevices: ['sess_current', 'sess_other'].map(id => {
+ const { user: _owner, ...session } = fapiSession({ id, user: alice });
+ return {
+ ...session,
+ user: null,
+ ownerUserId: alice.id,
+ latest_activity: {
+ object: 'session_activity',
+ id: `activity_${id}`,
+ browser_name: 'Safari',
+ device_type: id === 'sess_other' ? 'Phone' : 'Laptop',
+ },
+ };
+ }),
+ });
+ const view = await renderWithClerk(
+
+
+ ,
+ );
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' }));
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible());
+
+ const currentUser = view.clerk.user;
+ if (!currentUser) {
+ throw new Error('Expected a signed-in user');
+ }
+ const target = (await currentUser.getSessions()).find(session => session.id === 'sess_other');
+ if (!target) {
+ throw new Error('Expected another device');
+ }
+ await act(async () => {
+ await target.revoke();
+ });
+
+ const logError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' }));
+
+ await waitFor(() =>
+ expect(within(screen.getByRole('dialog')).getByRole('alert')).toHaveTextContent(expectedMessage),
+ );
+ expect(screen.getByRole('dialog')).toBeVisible();
+ expect(screen.queryByText('Unexpected device failure.')).toBeNull();
+ expect(logError).not.toHaveBeenCalled();
+ } finally {
+ logError.mockRestore();
+ }
+ });
+});
From 5da06e8a1e0edb1fe83c87fdd919cd4be1fb928b Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 20:03:28 -0600
Subject: [PATCH 22/26] fix(mosaic): localize rejected device actions and
document removal focus
---
packages/mosaic/AGENTS.md | 4 +++
packages/mosaic/docs/views.md | 33 ++++++++++---------
...le-active-devices-section.feature.test.tsx | 2 +-
...er-profile-active-devices-section.model.ts | 3 +-
.../user-profile-active-devices.messages.ts | 1 -
.../src/localization/errors.messages.ts | 1 +
6 files changed, 25 insertions(+), 19 deletions(-)
diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md
index 16abbc46736..c7670131c37 100644
--- a/packages/mosaic/AGENTS.md
+++ b/packages/mosaic/AGENTS.md
@@ -84,6 +84,10 @@ Docs say what the code cannot: rules, reasons, and which tool fits which problem
A PR that adds, renames or changes the signature of a shared helper in `src/hooks/`, `src/utils/`, `src/blocks/`, `src/components/form/` or `src/machine/` updates the table above, and any doc that names it, in the same PR.
+## Reuse
+
+Use `useListRemovalFocus` (`src/hooks/use-list-removal-focus.ts`) when removing a list row also removes its dialog trigger. Pass the row ids, `onRemove`, and a surviving fallback control. Return `false` from `onRemove` to skip removal tracking and replacement focus for that action, such as when its result is stale. See [Views](../../.claude/skills/mosaic/references/views.md) for the wiring.
+
## Lint
Mosaic runs stricter lint rules than the rest of the repo (the `packages/mosaic` block in `eslint.config.mjs`).
diff --git a/packages/mosaic/docs/views.md b/packages/mosaic/docs/views.md
index 9603b61bf07..c8f5bfecb3d 100644
--- a/packages/mosaic/docs/views.md
+++ b/packages/mosaic/docs/views.md
@@ -106,32 +106,33 @@ opened the dialog unmounted with the menu, and a dialog mounted at the section
rather than inside the menu has no floating-tree ancestor to walk back to. A
keyboard user loses their place mid-list and a screen reader announces nothing.
-Hand focus to a surviving element with `useListRemovalFocus` (`hooks/`). It
-records which row was removed and, through `finalFocus`, picks the row that took
-its place, the last row when it was the last, or `fallback()` once the list is
-empty. `finalFocus` on `Dialog.Popup` and on the `Confirmation` block takes a
-function resolved when the dialog closes, after the row has gone:
+Use `useListRemovalFocus` (`packages/mosaic/src/hooks/use-list-removal-focus.ts`)
+to hand focus to a surviving element. Pass `finalFocus` to `Dialog.Popup` or
+`Confirmation`. The dialog calls it when it closes, after the row has gone:
```tsx
+const fallbackTrigger = useRef(null);
const removalFocus = useListRemovalFocus({
- ids: methods.map(method => method.id),
+ ids: rows.map(row => row.id),
onRemove,
- fallback: () => section.current?.querySelector('button:not([disabled])') ?? section.current,
+ fallback: () => fallbackTrigger.current,
});
-// each row's trigger
-triggerRef={removalFocus.registerTrigger(method.id)}
-
-// the dialog
+triggerRef={removalFocus.registerTrigger(row.id)}
finalFocus={removalFocus.finalFocus}
-onConfirm={method => removalFocus.remove(method.id)}
+onConfirm={row => removalFocus.remove(row.id)}
```
-A cancelled or failed removal keeps its own trigger, so `finalFocus` returns
-`null` and the dialog's default (the trigger) applies.
+When `onRemove` resolves to `void` or `true`, the hook records the row and its
+index. `finalFocus` then picks the next row's registered trigger, the previous
+row's trigger if the removed row was last, or `fallback()` if the chosen
+trigger is unavailable. Attach `fallbackTrigger` to a control that outlives the list.
-Prefer the row that took the removed one's place, the last row when it was the
-last, and a control that outlives the list once it is empty.
+When `onRemove` resolves to `false`, the hook does not record that attempt or
+choose replacement focus for it. A rejection also leaves that attempt
+unrecorded; let the existing error owner handle it. If no earlier removal is
+recorded, `finalFocus` returns `null` and the dialog's default focus behavior
+applies.
Test the removal, not just the cancel: `toHaveFocus()` on the row that should
have caught it. A suite that only asserts focus after cancelling passes while
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
index 21fd71bbb6e..fb04bb00a6b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
@@ -313,7 +313,7 @@ describe('Active devices', () => {
),
);
const messages = {
- userProfileActiveDevices: { signOutError: 'Erreur de déconnexion.' },
+ userProfileActiveDevices: { detailsDialog: { signOutError: 'Erreur de déconnexion.' } },
errors: { invalid_action_for_session: 'Cet appareil est indisponible.' },
};
await renderWithClerk(
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
index fc50f63a685..71391033e07 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
@@ -1,4 +1,5 @@
import { differenceInCalendarDays } from '@clerk/shared/date';
+import { ClerkRuntimeError } from '@clerk/shared/error';
import { useClerk, useSession, useUser } from '@clerk/shared/react';
import type { SessionWithActivitiesResource } from '@clerk/shared/types';
import { useEffect, useState } from 'react';
@@ -117,7 +118,7 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
clerk.session?.id !== sessionId ||
id === sessionId
) {
- throw new Error(m.signOutError);
+ throw new ClerkRuntimeError('This device is no longer available.', { code: 'active_device_unavailable' });
}
await target.revoke();
return clerk.user?.id === userId && clerk.session?.id === sessionId;
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts
index 61c5e3d0f72..cd67ebc65bf 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts
@@ -6,7 +6,6 @@ export const userProfileActiveDevicesMessages = {
mobileDevice: 'Mobile device',
webBrowser: 'Web browser',
lastSeen: 'Last seen {date}',
- signOutError: 'This device is no longer available. Please try again.',
otherDevice: '{count} other device',
otherDevices: '{count} other devices',
signOutAll: 'Sign out of all devices',
diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts
index 9efc9041a00..4219fed3d4e 100644
--- a/packages/mosaic/src/localization/errors.messages.ts
+++ b/packages/mosaic/src/localization/errors.messages.ts
@@ -1,6 +1,7 @@
export const errorMessages: { readonly generic: string } & Readonly> = {
generic: 'Something went wrong. Please try again.',
action_blocked: "This action couldn't be completed. Please try again later or contact support if this persists.",
+ active_device_unavailable: 'This device is no longer available. Please try again.',
avatar_file_count_exceeded: 'Only one file can be uploaded at a time.',
avatar_file_size_exceeded: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.',
avatar_file_type_invalid: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.',
From b2e0fdba828742ce9199605c0c3abd3bb575c845 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 20:32:31 -0600
Subject: [PATCH 23/26] refactor(mosaic): keep shared focus hook changes
outside active devices
---
packages/mosaic/AGENTS.md | 4 --
packages/mosaic/docs/views.md | 33 +++++----
...ofile-active-devices-section.controller.ts | 7 +-
...er-profile-active-devices-section.model.ts | 3 +-
...er-profile-active-devices-section.view.tsx | 2 +-
.../__tests__/use-list-removal-focus.test.tsx | 69 -------------------
.../src/hooks/use-list-removal-focus.ts | 30 +++-----
7 files changed, 30 insertions(+), 118 deletions(-)
delete mode 100644 packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx
diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md
index c7670131c37..16abbc46736 100644
--- a/packages/mosaic/AGENTS.md
+++ b/packages/mosaic/AGENTS.md
@@ -84,10 +84,6 @@ Docs say what the code cannot: rules, reasons, and which tool fits which problem
A PR that adds, renames or changes the signature of a shared helper in `src/hooks/`, `src/utils/`, `src/blocks/`, `src/components/form/` or `src/machine/` updates the table above, and any doc that names it, in the same PR.
-## Reuse
-
-Use `useListRemovalFocus` (`src/hooks/use-list-removal-focus.ts`) when removing a list row also removes its dialog trigger. Pass the row ids, `onRemove`, and a surviving fallback control. Return `false` from `onRemove` to skip removal tracking and replacement focus for that action, such as when its result is stale. See [Views](../../.claude/skills/mosaic/references/views.md) for the wiring.
-
## Lint
Mosaic runs stricter lint rules than the rest of the repo (the `packages/mosaic` block in `eslint.config.mjs`).
diff --git a/packages/mosaic/docs/views.md b/packages/mosaic/docs/views.md
index c8f5bfecb3d..9603b61bf07 100644
--- a/packages/mosaic/docs/views.md
+++ b/packages/mosaic/docs/views.md
@@ -106,33 +106,32 @@ opened the dialog unmounted with the menu, and a dialog mounted at the section
rather than inside the menu has no floating-tree ancestor to walk back to. A
keyboard user loses their place mid-list and a screen reader announces nothing.
-Use `useListRemovalFocus` (`packages/mosaic/src/hooks/use-list-removal-focus.ts`)
-to hand focus to a surviving element. Pass `finalFocus` to `Dialog.Popup` or
-`Confirmation`. The dialog calls it when it closes, after the row has gone:
+Hand focus to a surviving element with `useListRemovalFocus` (`hooks/`). It
+records which row was removed and, through `finalFocus`, picks the row that took
+its place, the last row when it was the last, or `fallback()` once the list is
+empty. `finalFocus` on `Dialog.Popup` and on the `Confirmation` block takes a
+function resolved when the dialog closes, after the row has gone:
```tsx
-const fallbackTrigger = useRef(null);
const removalFocus = useListRemovalFocus({
- ids: rows.map(row => row.id),
+ ids: methods.map(method => method.id),
onRemove,
- fallback: () => fallbackTrigger.current,
+ fallback: () => section.current?.querySelector('button:not([disabled])') ?? section.current,
});
-triggerRef={removalFocus.registerTrigger(row.id)}
+// each row's trigger
+triggerRef={removalFocus.registerTrigger(method.id)}
+
+// the dialog
finalFocus={removalFocus.finalFocus}
-onConfirm={row => removalFocus.remove(row.id)}
+onConfirm={method => removalFocus.remove(method.id)}
```
-When `onRemove` resolves to `void` or `true`, the hook records the row and its
-index. `finalFocus` then picks the next row's registered trigger, the previous
-row's trigger if the removed row was last, or `fallback()` if the chosen
-trigger is unavailable. Attach `fallbackTrigger` to a control that outlives the list.
+A cancelled or failed removal keeps its own trigger, so `finalFocus` returns
+`null` and the dialog's default (the trigger) applies.
-When `onRemove` resolves to `false`, the hook does not record that attempt or
-choose replacement focus for it. A rejection also leaves that attempt
-unrecorded; let the existing error owner handle it. If no earlier removal is
-recorded, `finalFocus` returns `null` and the dialog's default focus behavior
-applies.
+Prefer the row that took the removed one's place, the last row when it was the
+last, and a control that outlives the list once it is empty.
Test the removal, not just the cancel: `toHaveFocus()` on the row that should
have caught it. A suite that only asserts focus after cancelling passes while
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
index 93091f42ffe..f9e10a6f93d 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
@@ -10,11 +10,8 @@ export function useUserProfileActiveDevicesController(
return {
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;
+ await model.revoke(id);
+ setRemovedIds(ids => [...ids, id]);
},
};
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
index 71391033e07..39cc1a0202d 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
@@ -15,7 +15,7 @@ export type UserProfileActiveDevicesModel =
status: 'ready';
identity: string;
devices: UserProfileDevice[];
- revoke: (id: string) => Promise;
+ revoke: (id: string) => Promise;
};
type SessionsQuery =
@@ -121,7 +121,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode
throw new ClerkRuntimeError('This device is no longer available.', { code: 'active_device_unavailable' });
}
await target.revoke();
- return clerk.user?.id === userId && clerk.session?.id === sessionId;
},
};
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
index ace04e57e3c..7a48f1330ae 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/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
@@ -22,7 +22,7 @@ export type { UserProfileDevice };
export interface UserProfileActiveDevicesSectionViewProps {
devices: UserProfileDevice[];
- onSignOutDevice?: (id: string) => void | boolean | Promise;
+ onSignOutDevice?: (id: string) => void | Promise;
onSignOutAllOtherDevices?: () => void | Promise;
}
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
deleted file mode 100644
index 2e84fcc867c..00000000000
--- a/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-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 82cca1944d0..1c38c6ae607 100644
--- a/packages/mosaic/src/hooks/use-list-removal-focus.ts
+++ b/packages/mosaic/src/hooks/use-list-removal-focus.ts
@@ -6,27 +6,18 @@ export function useListRemovalFocus({
fallback,
}: {
ids: string[];
- onRemove?: (id: string) => void | boolean | Promise;
+ onRemove?: (id: string) => void | Promise;
fallback: () => HTMLElement | null;
}) {
- const triggers = useRef(
- new Map void }>(),
- );
+ const triggers = useRef(new Map());
const removed = useRef<{ id: string; index: number } | undefined>(undefined);
- const registerTrigger = (id: string) => {
- const trigger = triggers.current.get(id);
- if (trigger) {
- return trigger.ref;
+ const registerTrigger = (id: string) => (element: HTMLButtonElement | null) => {
+ if (element) {
+ triggers.current.set(id, element);
+ } else {
+ triggers.current.delete(id);
}
- 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) => {
@@ -34,9 +25,8 @@ export function useListRemovalFocus({
return;
}
const index = ids.indexOf(id);
- if ((await onRemove(id)) !== false) {
- removed.current = { id, index };
- }
+ await onRemove(id);
+ removed.current = { id, index };
};
const finalFocus = () => {
@@ -47,7 +37,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)?.element : undefined) ?? fallback();
+ return (next ? triggers.current.get(next) : undefined) ?? fallback();
};
return { registerTrigger, remove, finalFocus };
From a32ff9f4704f1b2f3308d1637fe842f378bd2194 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 21:05:25 -0600
Subject: [PATCH 24/26] test(mosaic): scope active device feature coverage
---
.../user-profile-device-actions.test.tsx | 175 ++++++++++
...le-active-devices-section.feature.test.tsx | 303 ++++--------------
...ices-section.guard-errors.feature.test.tsx | 78 -----
...vices-section.view.errors.feature.test.tsx | 44 ---
4 files changed, 233 insertions(+), 367 deletions(-)
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx
delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx
delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx
new file mode 100644
index 00000000000..35a94b3a722
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx
@@ -0,0 +1,175 @@
+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 { afterEach, 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',
+};
+
+async function openMenu(user: ReturnType, device: UserProfileDevice) {
+ await user.click(screen.getByRole('button', { name: `Manage ${device.name}` }));
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+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 () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ const user = userEvent.setup();
+ const onSignOutAllOtherDevices = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('Cannot read properties of undefined'))
+ .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' }));
+
+ await waitFor(() =>
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ 'Something went wrong signing these devices out. Please try again.',
+ ),
+ );
+ 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 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([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();
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
index fb04bb00a6b..4de9810a759 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
@@ -3,7 +3,6 @@ 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 {
@@ -18,9 +17,7 @@ import { renderWithClerk } from '../../../__tests__/feature/render';
import { MosaicNowProvider } from '../../../hooks/use-now';
import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
-import type { UserProfileDevice } from './user-profile-active-devices.types';
import { UserProfileActiveDevicesSection } from './user-profile-active-devices-section';
-import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view';
const alice = fapiUser({ id: 'user_1' });
@@ -120,11 +117,7 @@ describe('Active devices', () => {
},
{ ...device('sess_expired', 'active', { device_type: 'Expired laptop' }), expire_at: 1 },
]);
- const { clerk } = await renderWithClerk(
- } />,
- );
-
- expect(await clerk.user?.getSessions()).toHaveLength(3);
+ await renderWithClerk(} />);
expect(await screen.findByRole('button', { name: 'Manage Safari on MacBook Pro' })).toBeInTheDocument();
expect(screen.getByText('This device')).toBeVisible();
@@ -165,24 +158,19 @@ describe('Active devices', () => {
expect(screen.queryByRole('button', { name: 'Manage Safari on Expired laptop' })).toBeNull();
});
- it('preserves the SDK empty-list behavior after a failed device request', async () => {
+ it('shows an empty state after a failed device request', 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')]),
+ HttpResponse.json({ errors: [{ code: 'internal_clerk_error', message: 'Unavailable' }] }, { status: 500 }),
),
);
- const view = await renderWithClerk();
+ await renderWithClerk();
expect(await screen.findByText('No current device available')).toBeVisible();
expect(screen.queryByRole('alert')).toBeNull();
expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull();
- failing = false;
- await expect(view.clerk.user?.getSessions()).resolves.toEqual([]);
expect(screen.queryByText('This device')).toBeNull();
});
@@ -242,32 +230,6 @@ describe('Active devices', () => {
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' })],
@@ -687,214 +649,13 @@ describe('Active devices', () => {
removeListener?.();
}
});
+});
+describe('Deferred active-device actions', () => {
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('Something went wrong signing these devices out. Please try again.'),
- ).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([
@@ -943,3 +704,55 @@ describe('active devices focus after connected revocation', () => {
await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toHaveFocus());
});
});
+
+describe('Sign out an unavailable device', () => {
+ it.each([
+ ['en-US', 'This device is no longer available. Please try again.'],
+ ['fr-FR', 'Cet appareil est indisponible.'],
+ ])('shows the catalog message in %s', async (locale, expectedMessage) => {
+ serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'Phone' })]);
+ const view = await renderWithClerk(
+
+
+ ,
+ );
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' }));
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible());
+
+ const currentUser = view.clerk.user;
+ if (!currentUser) {
+ throw new Error('Expected a signed-in user');
+ }
+ const target = (await currentUser.getSessions()).find(session => session.id === 'sess_other');
+ if (!target) {
+ throw new Error('Expected another device');
+ }
+ await act(async () => {
+ await target.revoke();
+ });
+
+ const logError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' }));
+
+ await waitFor(() =>
+ expect(within(screen.getByRole('dialog')).getByRole('alert')).toHaveTextContent(expectedMessage),
+ );
+ expect(screen.getByRole('dialog')).toBeVisible();
+ expect(screen.queryByText('Unexpected device failure.')).toBeNull();
+ expect(logError).not.toHaveBeenCalled();
+ } finally {
+ logError.mockRestore();
+ }
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx
deleted file mode 100644
index 74bc79e363d..00000000000
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { act, screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { describe, expect, it, vi } from 'vitest';
-
-import { type ActiveDeviceRecord, serveFapi } 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';
-
-describe('Sign out an unavailable device', () => {
- it.each([
- ['en-US', 'This device is no longer available. Please try again.'],
- ['fr-FR', 'Cet appareil est indisponible.'],
- ])('shows the catalog message in %s', async (locale, expectedMessage) => {
- const alice = fapiUser({ id: 'user_1' });
- serveFapi({
- client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]),
- activeDevices: ['sess_current', 'sess_other'].map(id => {
- const { user: _owner, ...session } = fapiSession({ id, user: alice });
- return {
- ...session,
- user: null,
- ownerUserId: alice.id,
- latest_activity: {
- object: 'session_activity',
- id: `activity_${id}`,
- browser_name: 'Safari',
- device_type: id === 'sess_other' ? 'Phone' : 'Laptop',
- },
- };
- }),
- });
- const view = await renderWithClerk(
-
-
- ,
- );
- const user = userEvent.setup();
- await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' }));
- await user.click(screen.getByRole('menuitem', { name: 'View details' }));
- await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible());
-
- const currentUser = view.clerk.user;
- if (!currentUser) {
- throw new Error('Expected a signed-in user');
- }
- const target = (await currentUser.getSessions()).find(session => session.id === 'sess_other');
- if (!target) {
- throw new Error('Expected another device');
- }
- await act(async () => {
- await target.revoke();
- });
-
- const logError = vi.spyOn(console, 'error').mockImplementation(() => {});
- try {
- await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' }));
-
- await waitFor(() =>
- expect(within(screen.getByRole('dialog')).getByRole('alert')).toHaveTextContent(expectedMessage),
- );
- expect(screen.getByRole('dialog')).toBeVisible();
- expect(screen.queryByText('Unexpected device failure.')).toBeNull();
- expect(logError).not.toHaveBeenCalled();
- } finally {
- logError.mockRestore();
- }
- });
-});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx
deleted file mode 100644
index ef1e6f9aa31..00000000000
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { render, screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { describe, expect, it } from 'vitest';
-
-import { clerkApiError } from '../../../__tests__/clerk-errors';
-import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
-import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view';
-
-describe.each(['confirmation', 'details'] as const)('device %s errors', surface => {
- it.each(['private', 'clerk'] as const)('renders a safe localized message for a %s error', async kind => {
- const user = userEvent.setup();
- const cause =
- kind === 'private'
- ? new Error('Private implementation details')
- : clerkApiError('invalid_action_for_session', 'Backend device message');
- const view = render(
-
- Promise.reject(cause)}
- />
- ,
- );
- await user.click(screen.getByRole('button', { name: 'Manage Phone' }));
- await user.click(screen.getByRole('menuitem', { name: surface === 'details' ? 'View details' : 'Sign out' }));
- const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog');
- await waitFor(() => expect(dialog).toBeVisible());
- await user.click(within(dialog).getByRole('button', { name: 'Sign out' }));
- const expected = kind === 'clerk' ? 'Cet appareil est indisponible.' : 'Déconnexion impossible.';
- await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent(expected));
- await waitFor(() => expect(dialog).toBeVisible());
- expect(screen.queryByText('Private implementation details')).toBeNull();
- expect(within(dialog).getByRole('button', { name: 'Sign out' })).not.toHaveAttribute('aria-busy', 'true');
- view.unmount();
- });
-});
From d65f4b79ab292bbcaaff9e8e3b85dee97217d4ca Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 21:59:15 -0600
Subject: [PATCH 25/26] test(mosaic): keep active device scenarios together
---
...le-active-devices-section.feature.test.tsx | 89 +++++++++++++++
...ices-section.localization.feature.test.tsx | 108 ------------------
2 files changed, 89 insertions(+), 108 deletions(-)
delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
index 4de9810a759..62bbfbdc1f3 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
@@ -651,6 +651,95 @@ describe('Active devices', () => {
});
});
+describe('Change language while viewing device details', () => {
+ function section(locale: string) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ it.each(['idle', 'pending'] as const)('updates open device details after a locale change while %s', async state => {
+ serveFapi({
+ client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]),
+ activeDevices: [
+ {
+ ...device('sess_current', 'active', { device_type: 'Laptop', city: undefined, country: undefined }),
+ last_active_at: new Date(2025, 5, 14, 12).getTime(),
+ },
+ {
+ ...device('sess_other', 'active', { device_type: 'Phone', city: undefined, country: undefined }),
+ last_active_at: new Date(2025, 5, 14, 12).getTime(),
+ },
+ ],
+ });
+ const view = await renderWithClerk(section('en-US'));
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' }));
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+ const dialog = screen.getByRole('dialog');
+ await waitFor(() => expect(dialog).toBeVisible());
+ expect(within(dialog).getByText('Last active yesterday')).toBeVisible();
+ const revoke = state === 'pending' ? holdRequests('post', '/v1/me/sessions/sess_other/revoke') : undefined;
+
+ try {
+ if (revoke) {
+ await user.click(within(dialog).getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(revoke.requests).toHaveLength(1));
+ await waitFor(() =>
+ expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })).toHaveAttribute(
+ 'aria-busy',
+ 'true',
+ ),
+ );
+ }
+
+ view.rerender(section('fr-FR'));
+
+ expect(screen.getAllByText('Vu hier')).toHaveLength(2);
+ const translatedDialog = screen.getByRole('dialog');
+ expect(translatedDialog).toHaveTextContent('Dernière activité hier');
+ expect(within(translatedDialog).getByRole('heading', { name: 'Safari sur Phone' })).toBeVisible();
+ if (revoke) {
+ expect(within(translatedDialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute(
+ 'aria-busy',
+ 'true',
+ );
+ await user.keyboard('{Escape}');
+ const stillOpenDialog = screen.getByRole('dialog');
+ expect(stillOpenDialog).toBeVisible();
+ expect(within(stillOpenDialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute(
+ 'aria-busy',
+ 'true',
+ );
+ }
+ } finally {
+ revoke?.release();
+ if (revoke) {
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage Safari sur Phone' })).toBeNull());
+ }
+ }
+ });
+});
+
describe('Deferred active-device actions', () => {
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/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx
deleted file mode 100644
index 212527dd8df..00000000000
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { describe, expect, it } from 'vitest';
-
-import { type ActiveDeviceRecord, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
-import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
-import { renderWithClerk } from '../../../__tests__/feature/render';
-import { MosaicNowProvider } from '../../../hooks/use-now';
-import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization';
-import { UserProfileActiveDevicesSection } from './user-profile-active-devices-section';
-
-const alice = fapiUser({ id: 'user_1' });
-
-function device(id: string, deviceType: string): ActiveDeviceRecord {
- const { user: _owner, ...session } = fapiSession({ id, user: alice });
- return {
- ...session,
- user: null,
- ownerUserId: alice.id,
- last_active_at: new Date(2025, 5, 14, 12).getTime(),
- latest_activity: {
- object: 'session_activity',
- id: `activity_${id}`,
- browser_name: 'Safari',
- device_type: deviceType,
- },
- };
-}
-
-function section(locale: string) {
- return (
-
-
-
-
-
- );
-}
-
-describe('Change language while viewing device details', () => {
- it.each(['idle', 'pending'] as const)('updates open device details after a locale change while %s', async state => {
- serveFapi({
- client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]),
- activeDevices: [device('sess_current', 'Laptop'), device('sess_other', 'Phone')],
- });
- const view = await renderWithClerk(section('en-US'));
- const user = userEvent.setup();
- await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' }));
- await user.click(screen.getByRole('menuitem', { name: 'View details' }));
- const dialog = screen.getByRole('dialog');
- await waitFor(() => expect(dialog).toBeVisible());
- expect(within(dialog).getByText('Last active yesterday')).toBeVisible();
- const revoke = state === 'pending' ? holdRequests('post', '/v1/me/sessions/sess_other/revoke') : undefined;
-
- try {
- if (revoke) {
- await user.click(within(dialog).getByRole('button', { name: 'Sign out' }));
- await waitFor(() => expect(revoke.requests).toHaveLength(1));
- await waitFor(() =>
- expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })).toHaveAttribute(
- 'aria-busy',
- 'true',
- ),
- );
- }
-
- view.rerender(section('fr-FR'));
-
- expect(screen.getAllByText('Vu hier')).toHaveLength(2);
- const translatedDialog = screen.getByRole('dialog');
- expect(translatedDialog).toHaveTextContent('Dernière activité hier');
- expect(within(translatedDialog).getByRole('heading', { name: 'Safari sur Phone' })).toBeVisible();
- if (revoke) {
- expect(within(translatedDialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute(
- 'aria-busy',
- 'true',
- );
- await user.keyboard('{Escape}');
- const stillOpenDialog = screen.getByRole('dialog');
- expect(stillOpenDialog).toBeVisible();
- expect(within(stillOpenDialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute(
- 'aria-busy',
- 'true',
- );
- }
- } finally {
- revoke?.release();
- if (revoke) {
- await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
- await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage Safari sur Phone' })).toBeNull());
- }
- }
- });
-});
From cdfe5cf5f48e458ff54bb35ebaa3763d171204b1 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 8 Oct 2026 12:51:15 -0600
Subject: [PATCH 26/26] test(mosaic): remove duplicate active-device focus
coverage
---
...file-active-devices-section.feature.test.tsx | 17 -----------------
1 file changed, 17 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
index 62bbfbdc1f3..bdd5e4e77d3 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
@@ -746,23 +746,6 @@ describe('Deferred active-device actions', () => {
});
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'),