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/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
index f730da6e6be..b2245be69f9 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,
Web3WalletJSON,
@@ -54,6 +55,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> & {
@@ -156,6 +167,36 @@ function updateWeb3Wallet(state: FakeFapiState, wallet: Web3WalletJSON): void {
}
}
+function requestingSession(state: FakeFapiState, request: Request): SessionJSON | undefined {
+ const sessionId = new URL(request.url).searchParams.get('_clerk_session_id') ?? state.client.last_active_session_id;
+ return findSession(state, sessionId);
+}
+
+function 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 = {
@@ -167,6 +208,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
apiKeys: [],
passwordUpdates: [],
enterpriseConnections: [],
+ deviceTrackingEnabled: true,
...rest,
verification: createVerificationState(verification),
enterpriseLinking: {
@@ -393,6 +435,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/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-device-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx
index 5f5ea410364..35a94b3a722 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
@@ -4,10 +4,9 @@ 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.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',
@@ -22,25 +21,8 @@ const mobile: UserProfileDevice = {
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}` }));
}
@@ -49,145 +31,6 @@ 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(
@@ -295,55 +138,9 @@ describe('signing out of all other devices', () => {
});
});
-describe('focus after signing a device out', () => {
+describe('focus after a delayed row update', () => {
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();
@@ -353,7 +150,6 @@ describe('focus after signing a device out', () => {
{
void catchUp.promise.then(() => setDevices(list => list.filter(device => device.id !== id)));
return Promise.resolve();
@@ -376,14 +172,4 @@ describe('focus after signing a device out', () => {
expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus();
});
-
- it('still returns focus to the row itself when the sign out is cancelled', async () => {
- const user = userEvent.setup();
- render();
- await openMenu(user, mobile);
- await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' }));
-
- await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toHaveFocus());
- });
});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-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: ,
+ },
+ }}
/>
);
}
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..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,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/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';
@@ -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-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
new file mode 100644
index 00000000000..f9e10a6f93d
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.controller.ts
@@ -0,0 +1,17 @@
+import { useState } from 'react';
+
+import type { UserProfileActiveDevicesModel } from './user-profile-active-devices-section.model';
+
+export function useUserProfileActiveDevicesController(
+ model: Extract,
+) {
+ const [removedIds, setRemovedIds] = useState([]);
+
+ return {
+ devices: model.devices.filter(device => !removedIds.includes(device.id)),
+ onSignOutDevice: async (id: string) => {
+ 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.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
new file mode 100644
index 00000000000..bdd5e4e77d3
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx
@@ -0,0 +1,830 @@
+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 {
+ 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 { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
+import { UserProfileActiveDevicesSection } from './user-profile-active-devices-section';
+
+const alice = fapiUser({ id: 'user_1' });
+
+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',
+ 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 ActiveDeviceRecord;
+}
+
+function serveDevices(
+ initialDevices: ActiveDeviceRecord[],
+ options: {
+ currentActor?: ActClaim;
+ deviceTrackingEnabled?: boolean;
+ failOnceId?: string;
+ reverifyOnceId?: string;
+ } = {},
+) {
+ const devices = [...initialDevices];
+ let failed = false;
+ serveFapi({
+ client: fapiClient([fapiSession({ id: 'sess_current', user: alice, actor: options.currentActor })]),
+ activeDevices: devices,
+ deviceTrackingEnabled: options.deviceTrackingEnabled ?? true,
+ });
+ worker.use(
+ 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 },
+ );
+ }
+ return undefined;
+ }),
+ );
+ 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 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', 'active', { browser_name: undefined, device_type: undefined }),
+ tasks: [{ key: 'choose-organization' }],
+ },
+ { ...device('sess_expired', 'active', { device_type: 'Expired laptop' }), expire_at: 1 },
+ ]);
+ await renderWithClerk(} />);
+
+ 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.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');
+ 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 an empty state after a failed device request', async () => {
+ serveDevices([device('sess_current', 'active')]);
+ worker.use(
+ http.get(fapiUrl('/v1/me/sessions/active'), () =>
+ HttpResponse.json({ errors: [{ code: 'internal_clerk_error', message: 'Unavailable' }] }, { status: 500 }),
+ ),
+ );
+
+ await renderWithClerk();
+
+ expect(await screen.findByText('No current device available')).toBeVisible();
+ expect(screen.queryByRole('alert')).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull();
+ expect(screen.queryByText('This device')).toBeNull();
+ });
+
+ 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('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('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' }));
+ 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'));
+ 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('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: { detailsDialog: { 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' }));
+
+ await waitFor(() => expect(screen.getByRole('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_other', 'active', { device_type: 'Old phone' }),
+ { ...device('sess_next', 'active', { device_type: 'Next laptop' }), ownerUserId: nextUser.id },
+ ];
+ 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'
+ ? [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/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: null,
+ });
+ }),
+ );
+ const view = await renderWithClerk();
+ const sdkRequests = vi.spyOn(view.clerk.getFapiClient(), 'request');
+ view.rerender();
+ const user = userEvent.setup();
+ 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 {
+ pending.resolve();
+ }
+ });
+
+ 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');
+ 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.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(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();
+ 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' });
+ serveDevices([device('sess_current', 'active'), otherDevice]);
+ 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(revoke.requests).toHaveLength(1));
+ await user.keyboard('{Escape}');
+ 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')).toBeVisible();
+ expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy');
+ } finally {
+ revoke.release();
+ }
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull();
+ expect(revoke.requests).toHaveLength(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(
+ 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' }));
+
+ 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();
+ });
+
+ 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?.();
+ }
+ });
+});
+
+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');
+});
+
+describe('active devices focus after connected revocation', () => {
+ 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());
+ });
+});
+
+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.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts
new file mode 100644
index 00000000000..39cc1a0202d
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts
@@ -0,0 +1,126 @@
+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';
+
+import { useNow } from '../../../hooks/use-now';
+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;
+ devices: UserProfileDevice[];
+ revoke: (id: string) => Promise;
+ };
+
+type SessionsQuery =
+ | { status: 'loading'; identity: string | undefined }
+ | { status: 'ready'; identity: string; sessions: SessionWithActivitiesResource[] };
+
+function isActiveDevice(session: SessionWithActivitiesResource): boolean {
+ return session.status === 'active' || session.status === 'pending';
+}
+
+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');
+ }
+ 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 now = useNow({ updateInterval: 60_000 });
+ const locale = useLocale();
+ const m = useMessages('userProfileActiveDevices');
+ const userId = user?.id;
+ const sessionId = session?.id;
+
+ const identity = userId && sessionId ? `${userId}:${sessionId}` : undefined;
+ const [query, setQuery] = useState({ status: 'loading', identity });
+
+ useEffect(() => {
+ const currentUser = clerk.user;
+ if (!identity || !currentUser || currentUser.id !== userId || clerk.session?.id !== sessionId) {
+ return;
+ }
+ let active = true;
+ setQuery({ status: 'loading', 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]);
+
+ 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, now, 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 || !identity) {
+ return { status: 'hidden' };
+ }
+
+ if (query.identity !== identity || query.status === 'loading') {
+ return { status: 'loading' };
+ }
+ return {
+ status: 'ready',
+ identity,
+ devices: query.sessions
+ .filter(isActiveDevice)
+ .sort((a, b) => Number(b.id === sessionId) - Number(a.id === sessionId))
+ .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 ||
+ clerk.session?.id !== sessionId ||
+ id === sessionId
+ ) {
+ throw new ClerkRuntimeError('This device is no longer available.', { code: 'active_device_unavailable' });
+ }
+ await target.revoke();
+ },
+ };
+}
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
new file mode 100644
index 00000000000..73da4491512
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx
@@ -0,0 +1,27 @@
+import type { ReactNode } from 'react';
+
+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 }: { model: Extract }) {
+ const controller = useUserProfileActiveDevicesController(model);
+ 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/user-profile-active-devices-section.view.tsx
similarity index 84%
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 ec4e42d377b..7a48f1330ae 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 };
@@ -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)}
/>
))}
@@ -136,8 +139,9 @@ export function UserProfileActiveDevicesSectionView({
) : null}
{onSignOutDevice ? (
signOutDeviceAt?.(device)}
+ onConfirm={device => signOutActions?.remove(device)}
errorFallback={m.detailsDialog.signOutError}
/>
) : null}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts
similarity index 84%
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
index ed29beef3b4..cd67ebc65bf 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-section/user-profile-active-devices.messages.ts
@@ -1,6 +1,13 @@
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}',
+ 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-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.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx
similarity index 61%
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 81af87afe3a..787634639ff 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,51 +1,71 @@
-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 { usePendingAction } from '../../hooks/use-pending-action';
-import { fill, useMessages } from '../../localization';
+import { useConfirmationController } from '../../../blocks/confirmation/confirmation.controller';
+import { Button, SubmitButton } from '../../../components/button';
+import { Card } from '../../../components/card';
+import { DataList } from '../../../components/data-list';
+import type { DialogFocusTarget, DialogHandle } from '../../../components/dialog';
+import { Dialog } from '../../../components/dialog';
+import { fill, useMessages } from '../../../localization';
import type { UserProfileDevice } from './user-profile-active-devices.types';
export interface UserProfileDeviceDetailsDialogProps {
handle: DialogHandle;
+ 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 });
+
return (
-
- {({ payload: device }) =>
- device === undefined ? null : (
+
+ {({ payload }) => {
+ if (payload === undefined) {
+ return null;
+ }
+
+ const device = devices.find(candidate => candidate.id === payload.id) ?? payload;
+
+ return (
controller.onConfirm(async () => onSignOut(device)) : undefined}
+ isSigningOut={controller.isConfirming}
+ errorMessage={controller.errorMessage}
/>
- )
- }
+ );
+ }}
);
}
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 +90,7 @@ function DeviceDetailsCard({
role='alert'
color='negative'
>
- {signOut.error}
+ {errorMessage}
{details.length > 0 ? (
@@ -89,13 +109,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/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx
index f4174e4bb62..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,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/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/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts
index 4ab5ff2eed7..fe58f7de96b 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.',
diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts
index 62e5ef06cfd..236c54566fc 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
new file mode 100644
index 00000000000..dabf7c75e51
--- /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/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 f62a4cb9aae..593de3de7c2 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'],
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 81a830b082b..771df0d3713 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/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,
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() {