From f4c390227c24b92b2f44bf3fb4973678e4b55511 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 29 Sep 2026 19:26:09 -0600 Subject: [PATCH 01/10] feat(mosaic): wire up user profile active devices --- .changeset/clear-active-devices.md | 2 + ...er-profile-active-devices.feature.test.tsx | 334 ++++++++++++++++++ .../user-profile-device-actions.test.tsx | 46 +++ ...ofile-active-devices-section.controller.ts | 73 ++++ ...er-profile-active-devices-section.model.ts | 129 +++++++ .../user-profile-active-devices-section.tsx | 49 +++ ...er-profile-active-devices-section.view.tsx | 3 +- .../user-profile-active-devices.messages.ts | 10 + .../user-profile-device-details.controller.ts | 64 ++++ .../user-profile-device-details.dialog.tsx | 49 +-- .../__tests__/useListRemovalFocus.test.ts | 21 ++ .../src/hooks/use-list-removal-focus.ts | 7 +- 12 files changed, 752 insertions(+), 35 deletions(-) create mode 100644 .changeset/clear-active-devices.md create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts create mode 100644 packages/mosaic/src/hooks/__tests__/useListRemovalFocus.test.ts diff --git a/.changeset/clear-active-devices.md b/.changeset/clear-active-devices.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/clear-active-devices.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx new file mode 100644 index 00000000000..401af9058a3 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx @@ -0,0 +1,334 @@ +import type { ActClaim, SessionWithActivitiesJSON } from '@clerk/shared/types'; +import { createDeferredPromise } from '@clerk/shared/utils'; +import { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it, vi } from 'vitest'; + +import { fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; +import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section'; + +const alice = fapiUser({ id: 'user_1' }); + +function device(id: string, status: string, activity: Partial = {}) { + const { user: _user, ...session } = fapiSession({ id, user: alice }); + return { + ...session, + user: null, + status, + latest_activity: { + object: 'session_activity', + id: `activity_${id}`, + browser_name: 'Safari', + browser_version: '18', + device_type: 'MacBook Pro', + city: 'Paris', + country: 'France', + ip_address: '192.0.2.1', + ...activity, + }, + } satisfies SessionWithActivitiesJSON; +} + +function serveDevices( + initialDevices: SessionWithActivitiesJSON[], + options: { currentActor?: ActClaim; failOnceId?: string; reverifyOnceId?: string } = {}, +) { + const devices = [...initialDevices]; + let failed = false; + serveFapi({ client: fapiClient([fapiSession({ id: 'sess_current', user: alice, actor: options.currentActor })]) }); + worker.use( + http.get(fapiUrl('/v1/me/sessions/active'), () => HttpResponse.json(devices)), + http.post(fapiUrl('/v1/me/sessions/:id/revoke'), ({ params }) => { + if (params.id === options.failOnceId && !failed) { + failed = true; + return HttpResponse.json( + { errors: [{ code: 'revoke_failed', message: 'Could not revoke device' }] }, + { status: 400 }, + ); + } + if (params.id === options.reverifyOnceId && !failed) { + failed = true; + return HttpResponse.json( + { errors: [{ code: 'session_reverification_required', message: 'Verification required' }] }, + { status: 400 }, + ); + } + const index = devices.findIndex(item => item.id === params.id); + const selected = devices[index]; + if (!selected) { + return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); + } + const revoked = { ...selected, status: 'revoked' }; + devices[index] = revoked; + return HttpResponse.json({ response: revoked, client: null }); + }), + ); + return devices; +} + +describe('Active devices', () => { + it('honors the loading fallback, including rendering nothing by default', async () => { + serveDevices([device('sess_current', 'active')]); + const pending = createDeferredPromise(); + worker.use( + http.get(fapiUrl('/v1/me/sessions/active'), async () => { + await pending.promise; + return HttpResponse.json([device('sess_current', 'active')]); + }), + ); + try { + const view = await renderWithClerk(); + expect(view.container).toBeEmptyDOMElement(); + view.rerender(Loading devices

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

Active devices

+

Review the devices signed in to your account.

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to review your active devices. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index 9b97aa31ea3..60376345061 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -13,6 +13,7 @@ const liveRoutes = new Map([ ['/user-profile/user-profile-api-keys-panel', '/live/api-keys'], ['/user-profile/user-profile-account-section', '/live/account-section'], ['/user-profile/user-profile-password-section', '/live/password'], + ['/user-profile/user-profile-active-devices-section', '/live/active-devices'], ['/user-profile/user-profile-connected-accounts-section', '/live/connected-accounts'], ['/user-profile/user-profile-enterprise-accounts-section', '/live/enterprise-accounts'], ['/user-profile/user-profile-danger-section', '/live/user-danger'], From 468934493eb9f4e7fba18dfd3e4ed57fec8a0e8b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:24:44 -0600 Subject: [PATCH 06/10] chore(mosaic): keep active-device deferrals in feature tests --- .../user-profile-active-devices-section.controller.ts | 1 - .../user-profile/user-profile-active-devices-section.model.ts | 2 -- .../user-profile/user-profile-active-devices-section.view.tsx | 1 - 3 files changed, 4 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts index 3879f27c2fa..93091f42ffe 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts @@ -16,6 +16,5 @@ export function useUserProfileActiveDevicesController( } return removed; }, - // TODO: Wire the model's future bulk action to onSignOutAllOtherDevices and refresh the device list after it settles. }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts index bfdfe26ce58..02e579baac2 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts @@ -126,8 +126,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode .filter(isActiveDevice) .sort((a, b) => Number(b.id === sessionId) - Number(a.id === sessionId)) .map(toDevice), - // TODO: Add bulk revocation when a dedicated API is available, preserving the current session and reverification. - // TODO: Add session reverification for device revocation; surface API errors until then. revoke: async id => { try { const currentUser = clerk.user; diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx index bef375b0dbe..afc3b0e5856 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx @@ -22,7 +22,6 @@ export type { UserProfileDevice }; export interface UserProfileActiveDevicesSectionViewProps { devices: UserProfileDevice[]; onSignOutDevice?: (id: string) => void | boolean | Promise; - // TODO: Connect bulk sign-out when the model and controller support it; keep its confirmation open while pending. onSignOutAllOtherDevices?: () => void | Promise; } From 047bd0472222c1449631c331e5970e4ddb0bd24e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 15:49:03 -0600 Subject: [PATCH 07/10] test(mosaic): await rendered device revoke errors --- .../user-profile-active-devices.feature.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx index b8214ee720b..86659246bb3 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx @@ -261,7 +261,7 @@ describe('Active devices', () => { await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Could not revoke device'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not revoke device')); expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active'); expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeInTheDocument(); @@ -310,7 +310,7 @@ describe('Active devices', () => { await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Cet appareil est indisponible.'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Cet appareil est indisponible.')); expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeVisible(); }); @@ -563,7 +563,7 @@ describe('Active devices', () => { const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog'); await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); - expect(await within(dialog).findByRole('alert')).toHaveTextContent('Verification required'); + await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent('Verification required')); expect(openReverification).not.toHaveBeenCalled(); expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active'); expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeInTheDocument(); @@ -619,7 +619,7 @@ describe('Active devices', () => { await user.click(screen.getByRole('menuitem', { name: 'View details' })); await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Could not revoke device'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not revoke device')); await user.keyboard('{Escape}'); await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeVisible(); From da682834ea82bbfb46777b1f150e87feeea7b60d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 5 Oct 2026 23:03:10 -0600 Subject: [PATCH 08/10] refactor(mosaic): group active device section files --- ...er-profile-active-devices.feature.test.tsx | 2 +- .../user-profile-device-actions.test.tsx | 4 ++-- .../user-profile-security-panel.view.test.tsx | 4 ++-- ...ofile-active-devices-section.controller.ts | 0 ...er-profile-active-devices-section.model.ts | 2 +- .../user-profile-active-devices-section.tsx | 4 ++-- ...er-profile-active-devices-section.view.tsx | 24 +++++++++---------- .../user-profile-active-devices.messages.ts | 0 .../user-profile-active-devices.types.ts | 0 .../user-profile-device-details.controller.ts | 4 ++-- .../user-profile-device-details.dialog.tsx | 12 +++++----- .../user-profile-security-panel.view.tsx | 2 +- packages/mosaic/src/localization/registry.ts | 2 +- .../app/(clerk)/live/active-devices/page.tsx | 2 +- .../fixtures/user-profile-active-devices.ts | 2 +- .../src/stories/fixtures/user-profile.tsx | 2 +- ...profile-active-devices-section.stories.tsx | 5 ++-- 17 files changed, 36 insertions(+), 35 deletions(-) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices-section.controller.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices-section.model.ts (99%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices-section.tsx (92%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices-section.view.tsx (91%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices.messages.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices.types.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-device-details.controller.ts (94%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-device-details.dialog.tsx (90%) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx index 86659246bb3..dd6f2fd6185 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx @@ -9,7 +9,7 @@ import { type ActiveDeviceRecord, fapiUrl, serveFapi, worker } from '../../../__ import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; -import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section'; +import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; const alice = fapiUser({ id: 'user_1' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx index 4b9fb1a540b..a1ed2531c49 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx @@ -5,8 +5,8 @@ import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../mosaic-provider'; -import type { UserProfileDevice } from '../user-profile-active-devices.types'; -import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section.view'; +import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices.types'; +import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; const current: UserProfileDevice = { id: 'current', diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 62168bd638a..62c12fbf320 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -5,8 +5,8 @@ import { describe, expect, it, vi } from 'vitest'; import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; import { MosaicProvider } from '../../../mosaic-provider'; -import type { UserProfileDevice } from '../user-profile-active-devices-section.view'; -import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section.view'; +import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; +import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; import { UserProfileDangerSectionView } from '../user-profile-danger-section/user-profile-danger-section.view'; import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.controller.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.controller.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts similarity index 99% rename from packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts index 02e579baac2..bcae34b824d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts @@ -4,7 +4,7 @@ import { useClerk, useSession, useUser } from '@clerk/shared/react'; import type { SessionWithActivitiesResource } from '@clerk/shared/types'; import { useEffect, useRef, useState } from 'react'; -import { fill, toLocalizableApiError, useErrorText, useLocale, useMessages } from '../../localization'; +import { fill, toLocalizableApiError, useErrorText, useLocale, useMessages } from '../../../localization'; import type { UserProfileDevice } from './user-profile-active-devices.types'; export type UserProfileActiveDevicesModel = diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx similarity index 92% rename from packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx index 1c62edf4364..cbeb878f79d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; -import { Button } from '../../components/button'; -import { useMessages } from '../../localization'; +import { Button } from '../../../components/button'; +import { useMessages } from '../../../localization'; import { useUserProfileActiveDevicesController } from './user-profile-active-devices-section.controller'; import type { UserProfileActiveDevicesModel } from './user-profile-active-devices-section.model'; import { useUserProfileActiveDevicesModel } from './user-profile-active-devices-section.model'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx similarity index 91% rename from packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx index afc3b0e5856..3dc43cbb03c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx @@ -2,20 +2,20 @@ import * as stylex from '@stylexjs/stylex'; import type { Ref } from 'react'; import { useMemo, useRef, useState } from 'react'; -import { Confirmation } from '../../blocks/confirmation'; -import type { ActionMenuAction } from '../../components/action-menu'; -import { ActionMenu } from '../../components/action-menu'; -import { Badge } from '../../components/badge'; -import { Button } from '../../components/button'; -import { Dialog } from '../../components/dialog'; -import { Section } from '../../components/section'; -import { useListRemovalFocus } from '../../hooks/use-list-removal-focus'; -import type { MosaicMessages } from '../../localization'; -import { fill, plural, useLocale, useMessages } from '../../localization'; +import { Confirmation } from '../../../blocks/confirmation'; +import type { ActionMenuAction } from '../../../components/action-menu'; +import { ActionMenu } from '../../../components/action-menu'; +import { Badge } from '../../../components/badge'; +import { Button } from '../../../components/button'; +import { Dialog } from '../../../components/dialog'; +import { Section } from '../../../components/section'; +import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; +import type { MosaicMessages } from '../../../localization'; +import { fill, plural, useLocale, useMessages } from '../../../localization'; +import { UserProfileSecurityIcon } from '../user-profile-security-icon'; +import { styles } from '../user-profile-security-panel.styles'; import type { UserProfileDevice } from './user-profile-active-devices.types'; import { UserProfileDeviceDetailsDialog } from './user-profile-device-details.dialog'; -import { UserProfileSecurityIcon } from './user-profile-security-icon'; -import { styles } from './user-profile-security-panel.styles'; export type { UserProfileDevice }; diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices.types.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.types.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-active-devices.types.ts rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.types.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts similarity index 94% rename from packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts index a8e4cb1c074..16d9ad1e58e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts @@ -1,5 +1,5 @@ -import { setup } from '../../machine/setup'; -import { useMachine } from '../../machine/use-machine'; +import { setup } from '../../../machine/setup'; +import { useMachine } from '../../../machine/use-machine'; import type { UserProfileDevice } from './user-profile-active-devices.types'; interface Context { diff --git a/packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx similarity index 90% rename from packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx index 030ae9bfecf..5f3a44b65f7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx @@ -1,9 +1,9 @@ -import { Button, SubmitButton } from '../../components/button'; -import { Card } from '../../components/card'; -import { DataList } from '../../components/data-list'; -import type { DialogFocusTarget, DialogHandle } from '../../components/dialog'; -import { Dialog } from '../../components/dialog'; -import { fill, useMessages } from '../../localization'; +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { DataList } from '../../../components/data-list'; +import type { DialogFocusTarget, DialogHandle } from '../../../components/dialog'; +import { Dialog } from '../../../components/dialog'; +import { fill, useMessages } from '../../../localization'; import type { UserProfileDevice } from './user-profile-active-devices.types'; import { useUserProfileDeviceDetailsController } from './user-profile-device-details.controller'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index f5274b8d798..d3f11cedea5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -3,7 +3,7 @@ import type { ReactElement, ReactNode } from 'react'; import { Panel } from '../../components/panel'; import { Section } from '../../components/section'; import { themeProps } from '../../props'; -import type { UserProfileDevice } from './user-profile-active-devices-section.view'; +import type { UserProfileDevice } from './user-profile-active-devices-section/user-profile-active-devices-section.view'; import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view'; import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 9064579842f..9d154d38cfd 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -15,7 +15,7 @@ import { userProfileAddEmailMessages } from '../features/user-profile/user-profi import { userProfileAddPhoneMessages } from '../features/user-profile/user-profile-account-section/user-profile-add-phone.messages'; import { userProfileVerifyEmailLinkMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-link.messages'; import { userProfileVerifyEmailSsoMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages'; -import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices.messages'; +import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages'; import { userProfileAddAuthenticatorMessages } from '../features/user-profile/user-profile-add-authenticator.messages'; import { userProfileAddSmsMessages } from '../features/user-profile/user-profile-add-sms.messages'; import { userProfileAuthenticatorSetupMessages } from '../features/user-profile/user-profile-authenticator-setup.messages'; diff --git a/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx b/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx index 2fde0ecfbc6..dabf7c75e51 100644 --- a/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { UserProfileActiveDevicesSection } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section'; +import { UserProfileActiveDevicesSection } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section'; import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; import { useUser } from '@clerk/nextjs'; import Link from 'next/link'; diff --git a/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts b/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts index 050fb5510f0..b00ab3a2abd 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts @@ -1,4 +1,4 @@ -import type { UserProfileDevice } from '@clerk/mosaic/features/user-profile/user-profile-active-devices.types'; +import type { UserProfileDevice } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.types'; import { useRef, useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index 7af21de568f..a05abbf793e 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -1,6 +1,6 @@ import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import type { UserProfileViewProps } from '@clerk/mosaic/features/user-profile/user-profile.view'; -import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section.view'; +import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view'; import type { UserProfilePaymentMethod, UserProfileSubscription, diff --git a/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx index 5127d7e25a1..984ea0ca1fe 100644 --- a/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx @@ -1,4 +1,4 @@ -import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section.view'; +import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view'; import type { StoryMeta } from '@/lib/types'; @@ -15,7 +15,8 @@ export const meta: StoryMeta = { title: 'UserProfileActiveDevicesSection', label: 'Active devices', navigation: { category: 'Sections' }, - source: 'packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx', + source: + 'packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx', }; export function Default() { From 757bdad416d74cb992ebe0a815b126260ce0e53a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 5 Oct 2026 23:03:11 -0600 Subject: [PATCH 09/10] test(mosaic): consolidate active device behavior coverage --- .changeset/quiet-devices-feedback.md | 2 + ...er-profile-active-devices.feature.test.tsx | 293 ++++++++++++++ .../user-profile-device-actions.test.tsx | 368 ------------------ 3 files changed, 295 insertions(+), 368 deletions(-) create mode 100644 .changeset/quiet-devices-feedback.md delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx diff --git a/.changeset/quiet-devices-feedback.md b/.changeset/quiet-devices-feedback.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quiet-devices-feedback.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx index dd6f2fd6185..e03a04506c6 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx @@ -3,13 +3,16 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; +import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { type ActiveDeviceRecord, fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; +import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices.types'; import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section'; +import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; const alice = fapiUser({ id: 'user_1' }); @@ -124,12 +127,18 @@ describe('Active devices', () => { expect(screen.queryByRole('button', { name: 'Manage Safari on Expired laptop' })).toBeNull(); const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage Safari on MacBook Pro' })); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + expect(within(screen.getByRole('dialog')).queryByRole('button', { name: 'Sign out' })).toBeNull(); + await user.click(within(screen.getByRole('dialog')).getByText('Close')); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); await user.click(screen.getByRole('button', { name: 'Manage Safari on iPhone' })); await user.click(screen.getByRole('menuitem', { name: 'View details' })); expect(within(screen.getByRole('dialog')).getByText('192.0.2.1')).toBeInTheDocument(); expect(within(screen.getByRole('dialog')).getByText('Paris, France')).toBeInTheDocument(); expect(within(screen.getByRole('dialog')).queryByText('Original sign in')).toBeNull(); await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); + expect(screen.queryByRole('alertdialog')).toBeNull(); await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull()); expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked'); @@ -259,6 +268,10 @@ describe('Active devices', () => { const user = userEvent.setup(); await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' })); await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + expect( + within(screen.getByRole('alertdialog')).getByText(/Safari on iPhone will be signed out/), + ).toBeInTheDocument(); + expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active'); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not revoke device')); @@ -606,6 +619,36 @@ describe('Active devices', () => { expect(requests).toBe(1); }); + it('keeps device details open after a failed revoke and allows retrying', async () => { + const devices = serveDevices( + [device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })], + { failOnceId: 'sess_other' }, + ); + let requests = 0; + worker.use( + http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), () => { + requests += 1; + }), + ); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' })); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + const dialog = screen.getByRole('dialog'); + await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent('Could not revoke device')); + expect(dialog).toBeInTheDocument(); + expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active'); + expect(requests).toBe(1); + + await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked'); + expect(requests).toBe(2); + expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull(); + }); + it('allows details to close after a revoke fails', async () => { serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })]); worker.use( @@ -683,3 +726,253 @@ describe('Active devices', () => { it.todo('signs out every other eligible device after confirmation while preserving the current session'); it.todo('reverifies device revocation before retrying verification-required API errors'); }); + +const currentViewDevice: UserProfileDevice = { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, +}; + +const mobileViewDevice: UserProfileDevice = { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + lastActive: '4 days ago', + model: 'iPhone 16 Pro', + browser: 'Safari 18.4', + ipAddress: '2600:100e:b10b:787b:e8ae:6e75', + location: 'Orem, UT, United States', + signedInAt: 'July 5th, 2026', +}; + +async function renderDevices(onSignOutDevice?: (id: string) => void | Promise) { + serveDevices([device('sess_current', 'active')]); + return renderWithClerk( + , + ); +} + +async function openMenu(user: ReturnType, item: UserProfileDevice) { + await user.click(screen.getByRole('button', { name: `Manage ${item.name}` })); +} + +describe('active devices view contract', () => { + it('renders every provided device detail field', async () => { + const user = userEvent.setup(); + await renderDevices(); + await openMenu(user, mobileViewDevice); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByRole('heading', { name: 'Safari on iOS' })).toBeInTheDocument(); + expect(within(dialog).getByText('Last active 4 days ago')).toBeInTheDocument(); + expect(within(dialog).getByText('iPhone 16 Pro')).toBeInTheDocument(); + expect(within(dialog).getByText('2600:100e:b10b:787b:e8ae:6e75')).toBeInTheDocument(); + expect(within(dialog).getByText('July 5th, 2026')).toBeInTheDocument(); + }); + + it('omits the rows a device has no detail for', async () => { + const user = userEvent.setup(); + await renderDevices(); + await openMenu(user, currentViewDevice); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + + expect(within(screen.getByRole('dialog')).queryByText('Browser')).not.toBeInTheDocument(); + }); + + describe('signing out of all other devices', () => { + async function renderAll( + onSignOutAllOtherDevices: () => void | Promise, + devices = [currentViewDevice, mobileViewDevice], + ) { + serveDevices([device('sess_current', 'active')]); + return renderWithClerk( + , + ); + } + + const confirmation = () => screen.getByRole('alertdialog'); + + it('confirms first, naming how many devices it covers', async () => { + const user = userEvent.setup(); + const onSignOutAllOtherDevices = vi.fn(); + await renderAll(onSignOutAllOtherDevices, [ + currentViewDevice, + mobileViewDevice, + { id: 'desktop', name: 'Clerk App', type: 'desktop' }, + ]); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + + expect(within(confirmation()).getByText(/2 other devices will be signed out/)).toBeInTheDocument(); + expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); + + await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); + expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce(); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('leaves the devices alone when the confirmation is cancelled', async () => { + const user = userEvent.setup(); + const onSignOutAllOtherDevices = vi.fn(); + await renderAll(onSignOutAllOtherDevices); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + await user.click(within(confirmation()).getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Sign out of all devices' })).toHaveFocus(); + }); + + it('holds the confirmation open and explains a failure', async () => { + const user = userEvent.setup(); + const onSignOutAllOtherDevices = vi + .fn() + .mockRejectedValueOnce(new Error('Unable to sign out of all devices')) + .mockResolvedValue(undefined); + await renderAll(onSignOutAllOtherDevices); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); + + expect(await screen.findByText('Unable to sign out of all devices')).toBeInTheDocument(); + expect(confirmation()).toBeInTheDocument(); + + await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(2); + }); + + it('ignores a second press while one is in flight', async () => { + const user = userEvent.setup(); + const signOutAll = createDeferredPromise(); + const onSignOutAllOtherDevices = vi.fn(() => signOutAll.promise); + await renderAll(onSignOutAllOtherDevices); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + const confirm = within(confirmation()).getByRole('button', { name: 'Sign out' }); + await user.click(confirm); + await waitFor(() => expect(confirm).toHaveAttribute('aria-busy')); + expect(confirm).toHaveAttribute('aria-disabled', 'true'); + act(() => confirm.click()); + expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(1); + + await act(async () => { + signOutAll.resolve(); + await signOutAll.promise; + }); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('hands focus to the current device once the others are gone', async () => { + const user = userEvent.setup(); + function Example() { + const [devices, setDevices] = useState([currentViewDevice, mobileViewDevice]); + return ( + setDevices(list => list.filter(device => device.isCurrent))} + /> + ); + } + serveDevices([device('sess_current', 'active')]); + await renderWithClerk(); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).not.toBeInTheDocument(), + ); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus()); + }); + }); + + describe('focus after a delayed row update', () => { + const desktop: UserProfileDevice = { id: 'desktop', name: 'Clerk App on macOS', type: 'desktop' }; + it('skips the signed-out row when the list only catches up later', async () => { + const user = userEvent.setup(); + const catchUp = createDeferredPromise(); + function LateExample() { + const [devices, setDevices] = useState([currentViewDevice, mobileViewDevice, desktop]); + return ( + { + void catchUp.promise.then(() => setDevices(list => list.filter(device => device.id !== id))); + return Promise.resolve(); + }} + /> + ); + } + serveDevices([device('sess_current', 'active')]); + await renderWithClerk(); + await openMenu(user, mobileViewDevice); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); + + await act(async () => { + catchUp.resolve(); + await catchUp.promise; + }); + expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus(); + }); + }); +}); + +describe('active devices focus after connected revocation', () => { + it.each(['confirmation', 'details'])('hands focus to the next row after signing out from %s', async surface => { + serveDevices([ + device('sess_current', 'active'), + device('sess_other', 'active', { device_type: 'iPhone' }), + device('sess_next', 'active', { device_type: 'Next laptop' }), + ]); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' })); + await user.click(screen.getByRole('menuitem', { name: surface === 'details' ? 'View details' : 'Sign out' })); + const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog'); + await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on Next laptop' })).toHaveFocus()); + expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull(); + }); + + it('falls back to the previous row, then the current device', async () => { + serveDevices([ + device('sess_current', 'active'), + device('sess_other', 'active', { device_type: 'iPhone' }), + device('sess_last', 'active', { device_type: 'Last laptop' }), + ]); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on Last laptop' })); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toHaveFocus()); + + await user.click(screen.getByRole('button', { name: 'Manage Safari on iPhone' })); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on MacBook Pro' })).toHaveFocus()); + }); + + it('returns focus to the same row when sign out is cancelled', async () => { + serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })]); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' })); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toHaveFocus()); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx deleted file mode 100644 index a1ed2531c49..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; -import { act, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../mosaic-provider'; -import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices.types'; -import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; - -const current: UserProfileDevice = { - id: 'current', - name: 'Safari on macOS', - description: 'Salt Lake City, UT, United States', - type: 'desktop', - isCurrent: true, -}; - -const mobile: UserProfileDevice = { - id: 'mobile', - name: 'Safari on iOS', - description: 'Last seen 2 weeks ago · Orem, UT, United States', - type: 'mobile', - lastActive: '4 days ago', - model: 'iPhone 16 Pro', - browser: 'Safari 18.4', - ipAddress: '2600:100e:b10b:787b:e8ae:6e75', - location: 'Orem, UT, United States', - signedInAt: 'July 5th, 2026', -}; - -function renderDevices(onSignOutDevice?: (id: string) => void | Promise) { - return render( - - - , - ); -} - -async function openMenu(user: ReturnType, device: UserProfileDevice) { - await user.click(screen.getByRole('button', { name: `Manage ${device.name}` })); -} - -describe('active device details', () => { - it('opens the details of the device the menu belongs to', async () => { - const user = userEvent.setup(); - renderDevices(); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - - const dialog = screen.getByRole('dialog'); - expect(within(dialog).getByRole('heading', { name: 'Safari on iOS' })).toBeInTheDocument(); - expect(within(dialog).getByText('Last active 4 days ago')).toBeInTheDocument(); - expect(within(dialog).getByText('iPhone 16 Pro')).toBeInTheDocument(); - expect(within(dialog).getByText('2600:100e:b10b:787b:e8ae:6e75')).toBeInTheDocument(); - expect(within(dialog).getByText('July 5th, 2026')).toBeInTheDocument(); - }); - - it('omits the rows a device has no detail for', async () => { - const user = userEvent.setup(); - renderDevices(); - await openMenu(user, current); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - - expect(within(screen.getByRole('dialog')).queryByText('Browser')).not.toBeInTheDocument(); - }); - - it('closes the current device from the footer, with nothing to sign out', async () => { - const user = userEvent.setup(); - renderDevices(vi.fn()); - await openMenu(user, current); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - - const dialog = screen.getByRole('dialog'); - expect(within(dialog).queryByRole('button', { name: 'Sign out' })).not.toBeInTheDocument(); - // The header's corner dismiss is also named Close, so this picks the footer's by its text. - await user.click(within(dialog).getByText('Close')); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); -}); - -describe('active device sign out', () => { - it('confirms before signing a device out', async () => { - const user = userEvent.setup(); - const onSignOutDevice = vi.fn(); - renderDevices(onSignOutDevice); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - - const confirmation = screen.getByRole('alertdialog'); - expect(within(confirmation).getByText(/Safari on iOS will be signed out/)).toBeInTheDocument(); - expect(onSignOutDevice).not.toHaveBeenCalled(); - - await user.click(within(confirmation).getByRole('button', { name: 'Sign out' })); - expect(onSignOutDevice).toHaveBeenCalledWith('mobile'); - }); - - it('shows a failure in the confirmation and allows retrying', async () => { - const user = userEvent.setup(); - const onSignOutDevice = vi.fn().mockRejectedValueOnce(new Error('Unable to sign out')).mockResolvedValue(undefined); - renderDevices(onSignOutDevice); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Unable to sign out')); - - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onSignOutDevice).toHaveBeenCalledTimes(2); - }); - - it('signs out from the details dialog without a second confirmation', async () => { - const user = userEvent.setup(); - function Example() { - const [devices, setDevices] = useState([current, mobile]); - return ( - - setDevices(list => list.filter(device => device.id !== id))} - /> - - ); - } - render(); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); - - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument(); - }); - - it('holds the details dialog open while the sign out is in flight', async () => { - const user = userEvent.setup(); - const signOut = createDeferredPromise(); - renderDevices(() => signOut.promise); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - const dialog = screen.getByRole('dialog'); - await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy')); - expect(dialog).toBeInTheDocument(); - - await act(async () => { - signOut.resolve(); - await signOut.promise; - }); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - - it('keeps the details dialog open and explains a failed sign out', async () => { - const user = userEvent.setup(); - const onSignOutDevice = vi.fn().mockRejectedValueOnce(new Error('Unable to sign out')).mockResolvedValue(undefined); - renderDevices(onSignOutDevice); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Unable to sign out')); - expect(screen.getByRole('dialog')).toBeInTheDocument(); - - await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(onSignOutDevice).toHaveBeenCalledTimes(2); - }); -}); - -describe('signing out of all other devices', () => { - function renderAll(onSignOutAllOtherDevices: () => void | Promise, devices = [current, mobile]) { - return render( - - - , - ); - } - - const confirmation = () => screen.getByRole('alertdialog'); - - it('confirms first, naming how many devices it covers', async () => { - const user = userEvent.setup(); - const onSignOutAllOtherDevices = vi.fn(); - renderAll(onSignOutAllOtherDevices, [current, mobile, { id: 'desktop', name: 'Clerk App', type: 'desktop' }]); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - - expect(within(confirmation()).getByText(/2 other devices will be signed out/)).toBeInTheDocument(); - expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); - - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce(); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('leaves the devices alone when the confirmation is cancelled', async () => { - const user = userEvent.setup(); - const onSignOutAllOtherDevices = vi.fn(); - renderAll(onSignOutAllOtherDevices); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - await user.click(within(confirmation()).getByRole('button', { name: 'Cancel' })); - - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Sign out of all devices' })).toHaveFocus(); - }); - - it('holds the confirmation open and explains a failure', async () => { - const user = userEvent.setup(); - const onSignOutAllOtherDevices = vi - .fn() - .mockRejectedValueOnce(new Error('Unable to sign out of all devices')) - .mockResolvedValue(undefined); - renderAll(onSignOutAllOtherDevices); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - - expect(await screen.findByText('Unable to sign out of all devices')).toBeInTheDocument(); - expect(confirmation()).toBeInTheDocument(); - - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(2); - }); - - it('ignores a second press while one is in flight', async () => { - const user = userEvent.setup(); - const signOutAll = createDeferredPromise(); - const onSignOutAllOtherDevices = vi.fn(() => signOutAll.promise); - renderAll(onSignOutAllOtherDevices); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - const confirm = within(confirmation()).getByRole('button', { name: 'Sign out' }); - await user.click(confirm); - await waitFor(() => expect(confirm).toHaveAttribute('aria-busy')); - await user.click(confirm); - - expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(1); - - await act(async () => { - signOutAll.resolve(); - await signOutAll.promise; - }); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('hands focus to the current device once the others are gone', async () => { - const user = userEvent.setup(); - function Example() { - const [devices, setDevices] = useState([current, mobile]); - return ( - - setDevices(list => list.filter(device => device.isCurrent))} - /> - - ); - } - render(); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => - expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).not.toBeInTheDocument(), - ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus()); - }); -}); - -describe('focus after signing a device out', () => { - const desktop: UserProfileDevice = { id: 'desktop', name: 'Clerk App on macOS', type: 'desktop' }; - - function Example({ devices: initial }: { devices: UserProfileDevice[] }) { - const [devices, setDevices] = useState(initial); - return ( - - setDevices(list => list.filter(device => device.id !== id))} - /> - - ); - } - - it('hands focus to the row that took its place, from the confirmation', async () => { - const user = userEvent.setup(); - render(); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); - }); - - it('hands focus to the row that took its place, from the details dialog', async () => { - const user = userEvent.setup(); - render(); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); - }); - - it('falls back to the last row, then to the current device', async () => { - const user = userEvent.setup(); - render(); - await openMenu(user, desktop); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toHaveFocus()); - - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus()); - }); - - it('skips the signed-out row when the list only catches up later', async () => { - const user = userEvent.setup(); - const catchUp = createDeferredPromise(); - function LateExample() { - const [devices, setDevices] = useState([current, mobile, desktop]); - return ( - - { - void catchUp.promise.then(() => setDevices(list => list.filter(device => device.id !== id))); - return Promise.resolve(); - }} - /> - - ); - } - render(); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); - - await act(async () => { - catchUp.resolve(); - await catchUp.promise; - }); - expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus(); - }); - - it('still returns focus to the row itself when the sign out is cancelled', async () => { - const user = userEvent.setup(); - render(); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toHaveFocus()); - }); -}); From a431b3591fdb11ba2444c31ea1228139ff13fd28 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 5 Oct 2026 23:43:21 -0600 Subject: [PATCH 10/10] fix(mosaic): keep focus on the next row after removal --- .changeset/mosaic-list-removal-focus.md | 5 ++ .../__tests__/use-list-removal-focus.test.tsx | 69 +++++++++++++++++++ .../src/hooks/use-list-removal-focus.ts | 23 +++++-- 3 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 .changeset/mosaic-list-removal-focus.md create mode 100644 packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx diff --git a/.changeset/mosaic-list-removal-focus.md b/.changeset/mosaic-list-removal-focus.md new file mode 100644 index 00000000000..faaf23e19d7 --- /dev/null +++ b/.changeset/mosaic-list-removal-focus.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Keep keyboard focus on the next available row after removing a list item. diff --git a/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx b/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx new file mode 100644 index 00000000000..2e84fcc867c --- /dev/null +++ b/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useLayoutEffect, useRef, useState } from 'react'; +import { expect, it } from 'vitest'; + +import { useListRemovalFocus } from '../use-list-removal-focus'; + +function RestoreFocus({ target }: { target: () => HTMLElement | null }) { + const targetRef = useRef(target); + useLayoutEffect(() => { + targetRef.current = target; + }); + useLayoutEffect( + () => () => { + const element = targetRef.current(); + queueMicrotask(() => element?.focus()); + }, + [], + ); + return null; +} + +function Example() { + const [ids, setIds] = useState(['removed', 'next']); + const [open, setOpen] = useState(true); + const fallback = useRef(null); + const removalFocus = useListRemovalFocus({ + ids, + onRemove: id => setIds(current => current.filter(item => item !== id)), + fallback: () => fallback.current, + }); + + return ( + <> + + {ids.map(id => ( + + ))} +
{open ? : null}
+ + + ); +} + +it('focuses the next row when removal and dialog cleanup share a commit', async () => { + render(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'next' })).toHaveFocus(), { timeout: 1000 }); + expect(screen.queryByRole('button', { name: 'removed' })).not.toBeInTheDocument(); +}); diff --git a/packages/mosaic/src/hooks/use-list-removal-focus.ts b/packages/mosaic/src/hooks/use-list-removal-focus.ts index 890d4c4b8b9..82cca1944d0 100644 --- a/packages/mosaic/src/hooks/use-list-removal-focus.ts +++ b/packages/mosaic/src/hooks/use-list-removal-focus.ts @@ -9,15 +9,24 @@ export function useListRemovalFocus({ onRemove?: (id: string) => void | boolean | Promise; fallback: () => HTMLElement | null; }) { - const triggers = useRef(new Map()); + const triggers = useRef( + new Map void }>(), + ); const removed = useRef<{ id: string; index: number } | undefined>(undefined); - const registerTrigger = (id: string) => (element: HTMLButtonElement | null) => { - if (element) { - triggers.current.set(id, element); - } else { - triggers.current.delete(id); + const registerTrigger = (id: string) => { + const trigger = triggers.current.get(id); + if (trigger) { + return trigger.ref; } + const ref = (element: HTMLButtonElement | null) => { + if (element) { + triggers.current.set(id, { element, ref }); + } else { + triggers.current.delete(id); + } + }; + return ref; }; const remove = async (id: string) => { @@ -38,7 +47,7 @@ export function useListRemovalFocus({ } const remaining = ids.filter(id => id !== item.id); const next = remaining[Math.min(item.index, remaining.length - 1)]; - return (next ? triggers.current.get(next) : undefined) ?? fallback(); + return (next ? triggers.current.get(next)?.element : undefined) ?? fallback(); }; return { registerTrigger, remove, finalFocus };