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() {