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

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

Active devices

+

Review the devices signed in to your account.

+
+ {!isLoaded ?

Loading…

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

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

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index a330e15c410..6b2af89b51a 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -13,6 +13,7 @@ const liveRoutes = new Map([ ['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/user-profile/user-profile-api-keys-panel', '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/live/api-keys'], ['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/user-profile/user-profile-account-section', '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/live/account-section'], ['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/user-profile/user-profile-password-section', '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/live/password'], + ['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/user-profile/user-profile-active-devices-section', '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/live/active-devices'], ['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/user-profile/user-profile-connected-accounts-section', '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/live/connected-accounts'], ['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/user-profile/user-profile-enterprise-accounts-section', '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/live/enterprise-accounts'], ['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/user-profile/user-profile-danger-section', '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/live/user-danger'], From d78fc2d481f342b75223febb2918afea80d18be9 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 14:24:44 -0600 Subject: [PATCH 06/26] chore(mosaic): keep active-device deferrals in feature tests --- .../user-profile-active-devices-section.controller.ts | 1 - .../user-profile/user-profile-active-devices-section.model.ts | 2 -- .../user-profile/user-profile-active-devices-section.view.tsx | 1 - 3 files changed, 4 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts index 3879f27c2fa..93091f42ffe 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts @@ -16,6 +16,5 @@ export function useUserProfileActiveDevicesController( } return removed; }, - // TODO: Wire the model's future bulk action to onSignOutAllOtherDevices and refresh the device list after it settles. }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts index bfdfe26ce58..02e579baac2 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts @@ -126,8 +126,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode .filter(isActiveDevice) .sort((a, b) => Number(b.id === sessionId) - Number(a.id === sessionId)) .map(toDevice), - // TODO: Add bulk revocation when a dedicated API is available, preserving the current session and reverification. - // TODO: Add session reverification for device revocation; surface API errors until then. revoke: async id => { try { const currentUser = clerk.user; diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx index 53ef3187fd7..c65a52cb4e0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx @@ -23,7 +23,6 @@ export type { UserProfileDevice }; export interface UserProfileActiveDevicesSectionViewProps { devices: UserProfileDevice[]; onSignOutDevice?: (id: string) => void | boolean | Promise; - // TODO: Connect bulk sign-out when the model and controller support it; keep its confirmation open while pending. onSignOutAllOtherDevices?: () => void | Promise; } From ee6b84df401b26e5fd5e03b08ec61306babc57e8 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 3 Oct 2026 15:49:03 -0600 Subject: [PATCH 07/26] test(mosaic): await rendered device revoke errors --- .../user-profile-active-devices.feature.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx index b8214ee720b..86659246bb3 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx @@ -261,7 +261,7 @@ describe('Active devices', () => { await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Could not revoke device'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not revoke device')); expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active'); expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeInTheDocument(); @@ -310,7 +310,7 @@ describe('Active devices', () => { await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Cet appareil est indisponible.'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Cet appareil est indisponible.')); expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeVisible(); }); @@ -563,7 +563,7 @@ describe('Active devices', () => { const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog'); await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); - expect(await within(dialog).findByRole('alert')).toHaveTextContent('Verification required'); + await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent('Verification required')); expect(openReverification).not.toHaveBeenCalled(); expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active'); expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeInTheDocument(); @@ -619,7 +619,7 @@ describe('Active devices', () => { await user.click(screen.getByRole('menuitem', { name: 'View details' })); await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Could not revoke device'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not revoke device')); await user.keyboard('{Escape}'); await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toBeVisible(); From c8d73118988960e17d73329570b2949d8ade5721 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 5 Oct 2026 23:03:10 -0600 Subject: [PATCH 08/26] refactor(mosaic): group active device section files --- ...er-profile-active-devices.feature.test.tsx | 2 +- .../user-profile-device-actions.test.tsx | 4 +-- .../user-profile-security-panel.view.test.tsx | 4 +-- ...ofile-active-devices-section.controller.ts | 0 ...er-profile-active-devices-section.model.ts | 2 +- .../user-profile-active-devices-section.tsx | 4 +-- ...er-profile-active-devices-section.view.tsx | 26 +++++++++---------- .../user-profile-active-devices.messages.ts | 0 .../user-profile-active-devices.types.ts | 0 .../user-profile-device-details.controller.ts | 4 +-- .../user-profile-device-details.dialog.tsx | 12 ++++----- .../user-profile-security-panel.view.tsx | 2 +- packages/mosaic/src/localization/registry.ts | 2 +- .../app/(clerk)/live/active-devices/page.tsx | 2 +- .../fixtures/user-profile-active-devices.ts | 2 +- .../src/stories/fixtures/user-profile.tsx | 2 +- ...profile-active-devices-section.stories.tsx | 5 ++-- 17 files changed, 37 insertions(+), 36 deletions(-) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices-section.controller.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices-section.model.ts (99%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices-section.tsx (92%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices-section.view.tsx (89%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices.messages.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-active-devices.types.ts (100%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-device-details.controller.ts (94%) rename packages/mosaic/src/features/user-profile/{ => user-profile-active-devices-section}/user-profile-device-details.dialog.tsx (90%) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx index 86659246bb3..dd6f2fd6185 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx @@ -9,7 +9,7 @@ import { type ActiveDeviceRecord, fapiUrl, serveFapi, worker } from '../../../__ import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; -import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section'; +import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; const alice = fapiUser({ id: 'user_1' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx index 5f5ea410364..d6ed17d57ad 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx @@ -6,8 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { clerkApiError } from '../../../__tests__/clerk-errors'; import { MosaicProvider } from '../../../mosaic-provider'; -import type { UserProfileDevice } from '../user-profile-active-devices.types'; -import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section.view'; +import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices.types'; +import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; const current: UserProfileDevice = { id: 'current', diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 38d5bcd79a6..f4649c0cf4d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -5,8 +5,8 @@ import { describe, expect, it, vi } from 'vitest'; import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; import { MosaicProvider } from '../../../mosaic-provider'; -import type { UserProfileDevice } from '../user-profile-active-devices-section.view'; -import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section.view'; +import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; +import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; import { UserProfileDangerSectionView } from '../user-profile-danger-section/user-profile-danger-section.view'; import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view'; import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.controller.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-active-devices-section.controller.ts rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.controller.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts similarity index 99% rename from packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts index 02e579baac2..bcae34b824d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts @@ -4,7 +4,7 @@ import { useClerk, useSession, useUser } from '@clerk/shared/react'; import type { SessionWithActivitiesResource } from '@clerk/shared/types'; import { useEffect, useRef, useState } from 'react'; -import { fill, toLocalizableApiError, useErrorText, useLocale, useMessages } from '../../localization'; +import { fill, toLocalizableApiError, useErrorText, useLocale, useMessages } from '../../../localization'; import type { UserProfileDevice } from './user-profile-active-devices.types'; export type UserProfileActiveDevicesModel = diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx similarity index 92% rename from packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx index 1c62edf4364..cbeb878f79d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; -import { Button } from '../../components/button'; -import { useMessages } from '../../localization'; +import { Button } from '../../../components/button'; +import { useMessages } from '../../../localization'; import { useUserProfileActiveDevicesController } from './user-profile-active-devices-section.controller'; import type { UserProfileActiveDevicesModel } from './user-profile-active-devices-section.model'; import { useUserProfileActiveDevicesModel } from './user-profile-active-devices-section.model'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx similarity index 89% rename from packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx index c65a52cb4e0..28877fcbf63 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx @@ -2,21 +2,21 @@ import * as stylex from '@stylexjs/stylex'; import type { Ref } from 'react'; import { useMemo, useRef } from 'react'; -import { Confirmation } from '../../blocks/confirmation'; -import { useConfirmationController } from '../../blocks/confirmation/confirmation.controller'; -import type { ActionMenuAction } from '../../components/action-menu'; -import { ActionMenu } from '../../components/action-menu'; -import { Badge } from '../../components/badge'; -import { Button } from '../../components/button'; -import { Dialog } from '../../components/dialog'; -import { Section } from '../../components/section'; -import { useListRemovalFocus } from '../../hooks/use-list-removal-focus'; -import type { MosaicMessages } from '../../localization'; -import { fill, plural, useLocale, useMessages } from '../../localization'; +import { Confirmation } from '../../../blocks/confirmation'; +import { useConfirmationController } from '../../../blocks/confirmation/confirmation.controller'; +import type { ActionMenuAction } from '../../../components/action-menu'; +import { ActionMenu } from '../../../components/action-menu'; +import { Badge } from '../../../components/badge'; +import { Button } from '../../../components/button'; +import { Dialog } from '../../../components/dialog'; +import { Section } from '../../../components/section'; +import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; +import type { MosaicMessages } from '../../../localization'; +import { fill, plural, useLocale, useMessages } from '../../../localization'; +import { UserProfileSecurityIcon } from '../user-profile-security-icon'; +import { styles } from '../user-profile-security-panel.styles'; import type { UserProfileDevice } from './user-profile-active-devices.types'; import { UserProfileDeviceDetailsDialog } from './user-profile-device-details.dialog'; -import { UserProfileSecurityIcon } from './user-profile-security-icon'; -import { styles } from './user-profile-security-panel.styles'; export type { UserProfileDevice }; diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices.types.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.types.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-active-devices.types.ts rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.types.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts similarity index 94% rename from packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts index a8e4cb1c074..16d9ad1e58e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-device-details.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts @@ -1,5 +1,5 @@ -import { setup } from '../../machine/setup'; -import { useMachine } from '../../machine/use-machine'; +import { setup } from '../../../machine/setup'; +import { useMachine } from '../../../machine/use-machine'; import type { UserProfileDevice } from './user-profile-active-devices.types'; interface Context { diff --git a/packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx similarity index 90% rename from packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx index 030ae9bfecf..5f3a44b65f7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-device-details.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx @@ -1,9 +1,9 @@ -import { Button, SubmitButton } from '../../components/button'; -import { Card } from '../../components/card'; -import { DataList } from '../../components/data-list'; -import type { DialogFocusTarget, DialogHandle } from '../../components/dialog'; -import { Dialog } from '../../components/dialog'; -import { fill, useMessages } from '../../localization'; +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { DataList } from '../../../components/data-list'; +import type { DialogFocusTarget, DialogHandle } from '../../../components/dialog'; +import { Dialog } from '../../../components/dialog'; +import { fill, useMessages } from '../../../localization'; import type { UserProfileDevice } from './user-profile-active-devices.types'; import { useUserProfileDeviceDetailsController } from './user-profile-device-details.controller'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index a419f33f912..4ed712b6bd0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -3,7 +3,7 @@ import type { ReactElement, ReactNode } from 'react'; import { Panel } from '../../components/panel'; import { Section } from '../../components/section'; import { themeProps } from '../../props'; -import type { UserProfileDevice } from './user-profile-active-devices-section.view'; +import type { UserProfileDevice } from './user-profile-active-devices-section/user-profile-active-devices-section.view'; import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view'; import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 2a40f6b2d6c..5752830a381 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -15,7 +15,7 @@ import { userProfileAddEmailMessages } from '../features/user-profile/user-profi import { userProfileAddPhoneMessages } from '../features/user-profile/user-profile-account-section/user-profile-add-phone.messages'; import { userProfileVerifyEmailLinkMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-link.messages'; import { userProfileVerifyEmailSsoMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages'; -import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices.messages'; +import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages'; import { userProfileAddAuthenticatorMessages } from '../features/user-profile/user-profile-add-authenticator.messages'; import { userProfileAddSmsMessages } from '../features/user-profile/user-profile-add-sms.messages'; import { userProfileAuthenticatorSetupMessages } from '../features/user-profile/user-profile-authenticator-setup.messages'; diff --git a/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx b/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx index 2fde0ecfbc6..dabf7c75e51 100644 --- a/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/active-devices/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { UserProfileActiveDevicesSection } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section'; +import { UserProfileActiveDevicesSection } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section'; import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; import { useUser } from '@clerk/nextjs'; import Link from 'next/link'; diff --git a/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts b/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts index 050fb5510f0..b00ab3a2abd 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-active-devices.ts @@ -1,4 +1,4 @@ -import type { UserProfileDevice } from '@clerk/mosaic/features/user-profile/user-profile-active-devices.types'; +import type { UserProfileDevice } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.types'; import { useRef, useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index 7c26a8717b2..6dbada86017 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -1,6 +1,6 @@ import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import type { UserProfileViewProps } from '@clerk/mosaic/features/user-profile/user-profile.view'; -import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section.view'; +import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view'; import type { UserProfilePaymentMethod, UserProfileSubscription, diff --git a/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx index 5127d7e25a1..984ea0ca1fe 100644 --- a/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx @@ -1,4 +1,4 @@ -import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section.view'; +import { UserProfileActiveDevicesSectionView } from '@clerk/mosaic/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view'; import type { StoryMeta } from '@/lib/types'; @@ -15,7 +15,8 @@ export const meta: StoryMeta = { title: 'UserProfileActiveDevicesSection', label: 'Active devices', navigation: { category: 'Sections' }, - source: 'packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx', + source: + 'packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx', }; export function Default() { From e3722ce630f0fb15492d078c74bd239edd73889f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 5 Oct 2026 23:03:11 -0600 Subject: [PATCH 09/26] test(mosaic): consolidate active device behavior coverage --- .changeset/quiet-devices-feedback.md | 2 + ...er-profile-active-devices.feature.test.tsx | 293 +++++++++++++ .../user-profile-device-actions.test.tsx | 389 ------------------ 3 files changed, 295 insertions(+), 389 deletions(-) create mode 100644 .changeset/quiet-devices-feedback.md delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx diff --git a/.changeset/quiet-devices-feedback.md b/.changeset/quiet-devices-feedback.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quiet-devices-feedback.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx index dd6f2fd6185..e03a04506c6 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx @@ -3,13 +3,16 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; +import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { type ActiveDeviceRecord, fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; +import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices.types'; import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section'; +import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; const alice = fapiUser({ id: 'user_1' }); @@ -124,12 +127,18 @@ describe('Active devices', () => { expect(screen.queryByRole('button', { name: 'Manage Safari on Expired laptop' })).toBeNull(); const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage Safari on MacBook Pro' })); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + expect(within(screen.getByRole('dialog')).queryByRole('button', { name: 'Sign out' })).toBeNull(); + await user.click(within(screen.getByRole('dialog')).getByText('Close')); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); await user.click(screen.getByRole('button', { name: 'Manage Safari on iPhone' })); await user.click(screen.getByRole('menuitem', { name: 'View details' })); expect(within(screen.getByRole('dialog')).getByText('192.0.2.1')).toBeInTheDocument(); expect(within(screen.getByRole('dialog')).getByText('Paris, France')).toBeInTheDocument(); expect(within(screen.getByRole('dialog')).queryByText('Original sign in')).toBeNull(); await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); + expect(screen.queryByRole('alertdialog')).toBeNull(); await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull()); expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked'); @@ -259,6 +268,10 @@ describe('Active devices', () => { const user = userEvent.setup(); await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' })); await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + expect( + within(screen.getByRole('alertdialog')).getByText(/Safari on iPhone will be signed out/), + ).toBeInTheDocument(); + expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active'); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not revoke device')); @@ -606,6 +619,36 @@ describe('Active devices', () => { expect(requests).toBe(1); }); + it('keeps device details open after a failed revoke and allows retrying', async () => { + const devices = serveDevices( + [device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })], + { failOnceId: 'sess_other' }, + ); + let requests = 0; + worker.use( + http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), () => { + requests += 1; + }), + ); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' })); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + const dialog = screen.getByRole('dialog'); + await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent('Could not revoke device')); + expect(dialog).toBeInTheDocument(); + expect(devices.find(item => item.id === 'sess_other')?.status).toBe('active'); + expect(requests).toBe(1); + + await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(devices.find(item => item.id === 'sess_other')?.status).toBe('revoked'); + expect(requests).toBe(2); + expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull(); + }); + it('allows details to close after a revoke fails', async () => { serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })]); worker.use( @@ -683,3 +726,253 @@ describe('Active devices', () => { it.todo('signs out every other eligible device after confirmation while preserving the current session'); it.todo('reverifies device revocation before retrying verification-required API errors'); }); + +const currentViewDevice: UserProfileDevice = { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, +}; + +const mobileViewDevice: UserProfileDevice = { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + lastActive: '4 days ago', + model: 'iPhone 16 Pro', + browser: 'Safari 18.4', + ipAddress: '2600:100e:b10b:787b:e8ae:6e75', + location: 'Orem, UT, United States', + signedInAt: 'July 5th, 2026', +}; + +async function renderDevices(onSignOutDevice?: (id: string) => void | Promise) { + serveDevices([device('sess_current', 'active')]); + return renderWithClerk( + , + ); +} + +async function openMenu(user: ReturnType, item: UserProfileDevice) { + await user.click(screen.getByRole('button', { name: `Manage ${item.name}` })); +} + +describe('active devices view contract', () => { + it('renders every provided device detail field', async () => { + const user = userEvent.setup(); + await renderDevices(); + await openMenu(user, mobileViewDevice); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByRole('heading', { name: 'Safari on iOS' })).toBeInTheDocument(); + expect(within(dialog).getByText('Last active 4 days ago')).toBeInTheDocument(); + expect(within(dialog).getByText('iPhone 16 Pro')).toBeInTheDocument(); + expect(within(dialog).getByText('2600:100e:b10b:787b:e8ae:6e75')).toBeInTheDocument(); + expect(within(dialog).getByText('July 5th, 2026')).toBeInTheDocument(); + }); + + it('omits the rows a device has no detail for', async () => { + const user = userEvent.setup(); + await renderDevices(); + await openMenu(user, currentViewDevice); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + + expect(within(screen.getByRole('dialog')).queryByText('Browser')).not.toBeInTheDocument(); + }); + + describe('signing out of all other devices', () => { + async function renderAll( + onSignOutAllOtherDevices: () => void | Promise, + devices = [currentViewDevice, mobileViewDevice], + ) { + serveDevices([device('sess_current', 'active')]); + return renderWithClerk( + , + ); + } + + const confirmation = () => screen.getByRole('alertdialog'); + + it('confirms first, naming how many devices it covers', async () => { + const user = userEvent.setup(); + const onSignOutAllOtherDevices = vi.fn(); + await renderAll(onSignOutAllOtherDevices, [ + currentViewDevice, + mobileViewDevice, + { id: 'desktop', name: 'Clerk App', type: 'desktop' }, + ]); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + + expect(within(confirmation()).getByText(/2 other devices will be signed out/)).toBeInTheDocument(); + expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); + + await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); + expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce(); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('leaves the devices alone when the confirmation is cancelled', async () => { + const user = userEvent.setup(); + const onSignOutAllOtherDevices = vi.fn(); + await renderAll(onSignOutAllOtherDevices); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + await user.click(within(confirmation()).getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Sign out of all devices' })).toHaveFocus(); + }); + + it('holds the confirmation open and explains a failure', async () => { + const user = userEvent.setup(); + const onSignOutAllOtherDevices = vi + .fn() + .mockRejectedValueOnce(new Error('Unable to sign out of all devices')) + .mockResolvedValue(undefined); + await renderAll(onSignOutAllOtherDevices); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); + + expect(await screen.findByText('Unable to sign out of all devices')).toBeInTheDocument(); + expect(confirmation()).toBeInTheDocument(); + + await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(2); + }); + + it('ignores a second press while one is in flight', async () => { + const user = userEvent.setup(); + const signOutAll = createDeferredPromise(); + const onSignOutAllOtherDevices = vi.fn(() => signOutAll.promise); + await renderAll(onSignOutAllOtherDevices); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + const confirm = within(confirmation()).getByRole('button', { name: 'Sign out' }); + await user.click(confirm); + await waitFor(() => expect(confirm).toHaveAttribute('aria-busy')); + expect(confirm).toHaveAttribute('aria-disabled', 'true'); + act(() => confirm.click()); + expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(1); + + await act(async () => { + signOutAll.resolve(); + await signOutAll.promise; + }); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('hands focus to the current device once the others are gone', async () => { + const user = userEvent.setup(); + function Example() { + const [devices, setDevices] = useState([currentViewDevice, mobileViewDevice]); + return ( + setDevices(list => list.filter(device => device.isCurrent))} + /> + ); + } + serveDevices([device('sess_current', 'active')]); + await renderWithClerk(); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).not.toBeInTheDocument(), + ); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus()); + }); + }); + + describe('focus after a delayed row update', () => { + const desktop: UserProfileDevice = { id: 'desktop', name: 'Clerk App on macOS', type: 'desktop' }; + it('skips the signed-out row when the list only catches up later', async () => { + const user = userEvent.setup(); + const catchUp = createDeferredPromise(); + function LateExample() { + const [devices, setDevices] = useState([currentViewDevice, mobileViewDevice, desktop]); + return ( + { + void catchUp.promise.then(() => setDevices(list => list.filter(device => device.id !== id))); + return Promise.resolve(); + }} + /> + ); + } + serveDevices([device('sess_current', 'active')]); + await renderWithClerk(); + await openMenu(user, mobileViewDevice); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); + + await act(async () => { + catchUp.resolve(); + await catchUp.promise; + }); + expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus(); + }); + }); +}); + +describe('active devices focus after connected revocation', () => { + it.each(['confirmation', 'details'])('hands focus to the next row after signing out from %s', async surface => { + serveDevices([ + device('sess_current', 'active'), + device('sess_other', 'active', { device_type: 'iPhone' }), + device('sess_next', 'active', { device_type: 'Next laptop' }), + ]); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' })); + await user.click(screen.getByRole('menuitem', { name: surface === 'details' ? 'View details' : 'Sign out' })); + const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog'); + await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on Next laptop' })).toHaveFocus()); + expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull(); + }); + + it('falls back to the previous row, then the current device', async () => { + serveDevices([ + device('sess_current', 'active'), + device('sess_other', 'active', { device_type: 'iPhone' }), + device('sess_last', 'active', { device_type: 'Last laptop' }), + ]); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on Last laptop' })); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toHaveFocus()); + + await user.click(screen.getByRole('button', { name: 'Manage Safari on iPhone' })); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on MacBook Pro' })).toHaveFocus()); + }); + + it('returns focus to the same row when sign out is cancelled', async () => { + serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })]); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' })); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toHaveFocus()); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx deleted file mode 100644 index d6ed17d57ad..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx +++ /dev/null @@ -1,389 +0,0 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; -import { act, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { clerkApiError } from '../../../__tests__/clerk-errors'; -import { MosaicProvider } from '../../../mosaic-provider'; -import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices.types'; -import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; - -const current: UserProfileDevice = { - id: 'current', - name: 'Safari on macOS', - description: 'Salt Lake City, UT, United States', - type: 'desktop', - isCurrent: true, -}; - -const mobile: UserProfileDevice = { - id: 'mobile', - name: 'Safari on iOS', - description: 'Last seen 2 weeks ago · Orem, UT, United States', - type: 'mobile', - lastActive: '4 days ago', - model: 'iPhone 16 Pro', - browser: 'Safari 18.4', - ipAddress: '2600:100e:b10b:787b:e8ae:6e75', - location: 'Orem, UT, United States', - signedInAt: 'July 5th, 2026', -}; - -function renderDevices(onSignOutDevice?: (id: string) => void | Promise) { - return render( - - - , - ); -} - -async function openMenu(user: ReturnType, device: UserProfileDevice) { - await user.click(screen.getByRole('button', { name: `Manage ${device.name}` })); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('active device details', () => { - it('opens the details of the device the menu belongs to', async () => { - const user = userEvent.setup(); - renderDevices(); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - - const dialog = screen.getByRole('dialog'); - expect(within(dialog).getByRole('heading', { name: 'Safari on iOS' })).toBeInTheDocument(); - expect(within(dialog).getByText('Last active 4 days ago')).toBeInTheDocument(); - expect(within(dialog).getByText('iPhone 16 Pro')).toBeInTheDocument(); - expect(within(dialog).getByText('2600:100e:b10b:787b:e8ae:6e75')).toBeInTheDocument(); - expect(within(dialog).getByText('July 5th, 2026')).toBeInTheDocument(); - }); - - it('omits the rows a device has no detail for', async () => { - const user = userEvent.setup(); - renderDevices(); - await openMenu(user, current); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - - expect(within(screen.getByRole('dialog')).queryByText('Browser')).not.toBeInTheDocument(); - }); - - it('closes the current device from the footer, with nothing to sign out', async () => { - const user = userEvent.setup(); - renderDevices(vi.fn()); - await openMenu(user, current); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - - const dialog = screen.getByRole('dialog'); - expect(within(dialog).queryByRole('button', { name: 'Sign out' })).not.toBeInTheDocument(); - // The header's corner dismiss is also named Close, so this picks the footer's by its text. - await user.click(within(dialog).getByText('Close')); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); -}); - -describe('active device sign out', () => { - it('confirms before signing a device out', async () => { - const user = userEvent.setup(); - const onSignOutDevice = vi.fn(); - renderDevices(onSignOutDevice); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - - const confirmation = screen.getByRole('alertdialog'); - expect(within(confirmation).getByText(/Safari on iOS will be signed out/)).toBeInTheDocument(); - expect(onSignOutDevice).not.toHaveBeenCalled(); - - await user.click(within(confirmation).getByRole('button', { name: 'Sign out' })); - expect(onSignOutDevice).toHaveBeenCalledWith('mobile'); - }); - - it('shows a failure in the confirmation and allows retrying', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - const user = userEvent.setup(); - const onSignOutDevice = vi - .fn() - .mockRejectedValueOnce(new Error('Cannot read properties of undefined')) - .mockResolvedValue(undefined); - renderDevices(onSignOutDevice); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => - expect(screen.getByRole('alert')).toHaveTextContent( - 'Something went wrong signing this device out. Please try again.', - ), - ); - - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onSignOutDevice).toHaveBeenCalledTimes(2); - }); - - it('signs out from the details dialog without a second confirmation', async () => { - const user = userEvent.setup(); - function Example() { - const [devices, setDevices] = useState([current, mobile]); - return ( - - setDevices(list => list.filter(device => device.id !== id))} - /> - - ); - } - render(); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); - - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument(); - }); - - it('holds the details dialog open while the sign out is in flight', async () => { - const user = userEvent.setup(); - const signOut = createDeferredPromise(); - renderDevices(() => signOut.promise); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - const dialog = screen.getByRole('dialog'); - await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy')); - expect(dialog).toBeInTheDocument(); - - await act(async () => { - signOut.resolve(); - await signOut.promise; - }); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - - it('keeps the details dialog open and explains a failed sign out', async () => { - const user = userEvent.setup(); - const onSignOutDevice = vi - .fn() - .mockRejectedValueOnce(clerkApiError('session_not_found', 'Unable to sign out')) - .mockResolvedValue(undefined); - renderDevices(onSignOutDevice); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Unable to sign out')); - expect(screen.getByRole('dialog')).toBeInTheDocument(); - - await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(onSignOutDevice).toHaveBeenCalledTimes(2); - }); -}); - -describe('signing out of all other devices', () => { - function renderAll(onSignOutAllOtherDevices: () => void | Promise, devices = [current, mobile]) { - return render( - - - , - ); - } - - const confirmation = () => screen.getByRole('alertdialog'); - - it('confirms first, naming how many devices it covers', async () => { - const user = userEvent.setup(); - const onSignOutAllOtherDevices = vi.fn(); - renderAll(onSignOutAllOtherDevices, [current, mobile, { id: 'desktop', name: 'Clerk App', type: 'desktop' }]); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - - expect(within(confirmation()).getByText(/2 other devices will be signed out/)).toBeInTheDocument(); - expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); - - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce(); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('leaves the devices alone when the confirmation is cancelled', async () => { - const user = userEvent.setup(); - const onSignOutAllOtherDevices = vi.fn(); - renderAll(onSignOutAllOtherDevices); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - await user.click(within(confirmation()).getByRole('button', { name: 'Cancel' })); - - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Sign out of all devices' })).toHaveFocus(); - }); - - it('holds the confirmation open and explains a failure', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - const user = userEvent.setup(); - const onSignOutAllOtherDevices = vi - .fn() - .mockRejectedValueOnce(new Error('Cannot read properties of undefined')) - .mockResolvedValue(undefined); - renderAll(onSignOutAllOtherDevices); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => - expect(screen.getByRole('alert')).toHaveTextContent( - 'Something went wrong signing these devices out. Please try again.', - ), - ); - expect(confirmation()).toBeInTheDocument(); - - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(2); - }); - - it('ignores a second press while one is in flight', async () => { - const user = userEvent.setup(); - const signOutAll = createDeferredPromise(); - const onSignOutAllOtherDevices = vi.fn(() => signOutAll.promise); - renderAll(onSignOutAllOtherDevices); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - const confirm = within(confirmation()).getByRole('button', { name: 'Sign out' }); - await user.click(confirm); - await waitFor(() => expect(confirm).toHaveAttribute('aria-busy')); - await user.click(confirm); - - expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(1); - - await act(async () => { - signOutAll.resolve(); - await signOutAll.promise; - }); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('hands focus to the current device once the others are gone', async () => { - const user = userEvent.setup(); - function Example() { - const [devices, setDevices] = useState([current, mobile]); - return ( - - setDevices(list => list.filter(device => device.isCurrent))} - /> - - ); - } - render(); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => - expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).not.toBeInTheDocument(), - ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus()); - }); -}); - -describe('focus after signing a device out', () => { - const desktop: UserProfileDevice = { id: 'desktop', name: 'Clerk App on macOS', type: 'desktop' }; - - function Example({ devices: initial }: { devices: UserProfileDevice[] }) { - const [devices, setDevices] = useState(initial); - return ( - - setDevices(list => list.filter(device => device.id !== id))} - /> - - ); - } - - it('hands focus to the row that took its place, from the confirmation', async () => { - const user = userEvent.setup(); - render(); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); - }); - - it('hands focus to the row that took its place, from the details dialog', async () => { - const user = userEvent.setup(); - render(); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); - }); - - it('falls back to the last row, then to the current device', async () => { - const user = userEvent.setup(); - render(); - await openMenu(user, desktop); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toHaveFocus()); - - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus()); - }); - - it('skips the signed-out row when the list only catches up later', async () => { - const user = userEvent.setup(); - const catchUp = createDeferredPromise(); - function LateExample() { - const [devices, setDevices] = useState([current, mobile, desktop]); - return ( - - { - void catchUp.promise.then(() => setDevices(list => list.filter(device => device.id !== id))); - return Promise.resolve(); - }} - /> - - ); - } - render(); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); - - await act(async () => { - catchUp.resolve(); - await catchUp.promise; - }); - expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus(); - }); - - it('still returns focus to the row itself when the sign out is cancelled', async () => { - const user = userEvent.setup(); - render(); - await openMenu(user, mobile); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toHaveFocus()); - }); -}); From 8d1802c94e84ec1501a4c3db0a3dac6d28a3211b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 5 Oct 2026 23:43:21 -0600 Subject: [PATCH 10/26] fix(mosaic): keep focus on the next row after removal --- .changeset/mosaic-list-removal-focus.md | 5 ++ .../__tests__/use-list-removal-focus.test.tsx | 69 +++++++++++++++++++ .../src/hooks/use-list-removal-focus.ts | 23 +++++-- 3 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 .changeset/mosaic-list-removal-focus.md create mode 100644 packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx diff --git a/.changeset/mosaic-list-removal-focus.md b/.changeset/mosaic-list-removal-focus.md new file mode 100644 index 00000000000..faaf23e19d7 --- /dev/null +++ b/.changeset/mosaic-list-removal-focus.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Keep keyboard focus on the next available row after removing a list item. diff --git a/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx b/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx new file mode 100644 index 00000000000..2e84fcc867c --- /dev/null +++ b/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useLayoutEffect, useRef, useState } from 'react'; +import { expect, it } from 'vitest'; + +import { useListRemovalFocus } from '../use-list-removal-focus'; + +function RestoreFocus({ target }: { target: () => HTMLElement | null }) { + const targetRef = useRef(target); + useLayoutEffect(() => { + targetRef.current = target; + }); + useLayoutEffect( + () => () => { + const element = targetRef.current(); + queueMicrotask(() => element?.focus()); + }, + [], + ); + return null; +} + +function Example() { + const [ids, setIds] = useState(['removed', 'next']); + const [open, setOpen] = useState(true); + const fallback = useRef(null); + const removalFocus = useListRemovalFocus({ + ids, + onRemove: id => setIds(current => current.filter(item => item !== id)), + fallback: () => fallback.current, + }); + + return ( + <> + + {ids.map(id => ( + + ))} +
{open ? : null}
+ + + ); +} + +it('focuses the next row when removal and dialog cleanup share a commit', async () => { + render(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'next' })).toHaveFocus(), { timeout: 1000 }); + expect(screen.queryByRole('button', { name: 'removed' })).not.toBeInTheDocument(); +}); diff --git a/packages/mosaic/src/hooks/use-list-removal-focus.ts b/packages/mosaic/src/hooks/use-list-removal-focus.ts index 890d4c4b8b9..82cca1944d0 100644 --- a/packages/mosaic/src/hooks/use-list-removal-focus.ts +++ b/packages/mosaic/src/hooks/use-list-removal-focus.ts @@ -9,15 +9,24 @@ export function useListRemovalFocus({ onRemove?: (id: string) => void | boolean | Promise; fallback: () => HTMLElement | null; }) { - const triggers = useRef(new Map()); + const triggers = useRef( + new Map void }>(), + ); const removed = useRef<{ id: string; index: number } | undefined>(undefined); - const registerTrigger = (id: string) => (element: HTMLButtonElement | null) => { - if (element) { - triggers.current.set(id, element); - } else { - triggers.current.delete(id); + const registerTrigger = (id: string) => { + const trigger = triggers.current.get(id); + if (trigger) { + return trigger.ref; } + const ref = (element: HTMLButtonElement | null) => { + if (element) { + triggers.current.set(id, { element, ref }); + } else { + triggers.current.delete(id); + } + }; + return ref; }; const remove = async (id: string) => { @@ -38,7 +47,7 @@ export function useListRemovalFocus({ } const remaining = ids.filter(id => id !== item.id); const next = remaining[Math.min(item.index, remaining.length - 1)]; - return (next ? triggers.current.get(next) : undefined) ?? fallback(); + return (next ? triggers.current.get(next)?.element : undefined) ?? fallback(); }; return { registerTrigger, remove, finalFocus }; From c121e2786cc3049b668bf2d35a98e06e07d6c186 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 6 Oct 2026 10:33:19 -0600 Subject: [PATCH 11/26] fix(mosaic): preserve active device error descriptions --- ...er-profile-active-devices.feature.test.tsx | 4 +- ...ser-profile-device-errors.feature.test.tsx | 43 +++++++++++++++++ ...er-profile-active-devices-section.model.ts | 46 ++++++++----------- .../user-profile-device-details.controller.ts | 22 +++++---- 4 files changed, 76 insertions(+), 39 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx index e03a04506c6..df05a4d4299 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx @@ -842,7 +842,9 @@ describe('active devices view contract', () => { await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - expect(await screen.findByText('Unable to sign out of all devices')).toBeInTheDocument(); + expect( + await screen.findByText('Something went wrong signing these devices out. Please try again.'), + ).toBeInTheDocument(); expect(confirmation()).toBeInTheDocument(); await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx new file mode 100644 index 00000000000..161b026f154 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { clerkApiError } from '../../../__tests__/clerk-errors'; +import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; +import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; + +describe.each(['confirmation', 'details'] as const)('device %s errors', surface => { + it.each(['private', 'clerk'] as const)('renders a safe localized message for a %s error', async kind => { + const user = userEvent.setup(); + const cause = + kind === 'private' + ? new Error('Private implementation details') + : clerkApiError('invalid_action_for_session', 'Backend device message'); + const view = render( + + Promise.reject(cause)} + /> + , + ); + await user.click(screen.getByRole('button', { name: 'Manage Phone' })); + await user.click(screen.getByRole('menuitem', { name: surface === 'details' ? 'View details' : 'Sign out' })); + const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog'); + await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); + const expected = kind === 'clerk' ? 'Cet appareil est indisponible.' : 'Déconnexion impossible.'; + await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent(expected)); + expect(dialog).toBeVisible(); + expect(screen.queryByText('Private implementation details')).toBeNull(); + expect(within(dialog).getByRole('button', { name: 'Sign out' })).not.toHaveAttribute('aria-busy', 'true'); + view.unmount(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts index bcae34b824d..21b0a62467b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts @@ -1,10 +1,9 @@ import { differenceInCalendarDays } from '@clerk/shared/date'; -import { isClerkAPIResponseError } from '@clerk/shared/error'; import { useClerk, useSession, useUser } from '@clerk/shared/react'; import type { SessionWithActivitiesResource } from '@clerk/shared/types'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; -import { fill, toLocalizableApiError, useErrorText, useLocale, useMessages } from '../../../localization'; +import { fill, useLocale, useMessages } from '../../../localization'; import type { UserProfileDevice } from './user-profile-active-devices.types'; export type UserProfileActiveDevicesModel = @@ -45,9 +44,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode const sessionId = session?.id; const identity = userId && sessionId ? `${userId}:${sessionId}` : undefined; - const errorText = useErrorText(); - const translation = useRef({ m, errorText }); - translation.current = { m, errorText }; const [query, setQuery] = useState({ status: 'loading', identity }); const [attempt, setAttempt] = useState(0); @@ -127,29 +123,23 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode .sort((a, b) => Number(b.id === sessionId) - Number(a.id === sessionId)) .map(toDevice), revoke: async id => { - try { - const currentUser = clerk.user; - if ( - !currentUser || - !sessionId || - currentUser.id !== userId || - clerk.session?.id !== sessionId || - id === sessionId - ) { - throw new Error(translation.current.m.signOutError); - } - const sessions = await currentUser.getSessions({ forceRefresh: true, throwOnError: true }); - const target = sessions.find(item => item.id === id && isActiveDevice(item)); - if (!target || clerk.user?.id !== userId || clerk.session?.id !== sessionId) { - throw new Error(translation.current.m.signOutError); - } - await target.revoke(); - return clerk.user?.id === userId && clerk.session?.id === sessionId; - } catch (error) { - const { m: messages, errorText: translate } = translation.current; - const first = isClerkAPIResponseError(error) ? error.errors[0] : undefined; - throw new Error(first ? translate(toLocalizableApiError(first, messages.signOutError)) : messages.signOutError); + const currentUser = clerk.user; + if ( + !currentUser || + !sessionId || + currentUser.id !== userId || + clerk.session?.id !== sessionId || + id === sessionId + ) { + throw new Error(m.signOutError); + } + const sessions = await currentUser.getSessions({ forceRefresh: true, throwOnError: true }); + const target = sessions.find(item => item.id === id && isActiveDevice(item)); + if (!target || clerk.user?.id !== userId || clerk.session?.id !== sessionId) { + throw new Error(m.signOutError); } + await target.revoke(); + return clerk.user?.id === userId && clerk.session?.id === sessionId; }, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts index 16d9ad1e58e..fab94a49f32 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts @@ -1,11 +1,13 @@ +import type { ErrorDescription } from '../../../localization'; +import { useErrorText } from '../../../localization'; import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/use-machine'; +import { toLocalizableError } from '../../../utils/errors'; import type { UserProfileDevice } from './user-profile-active-devices.types'; interface Context { run: () => Promise; - errorMessage: string | undefined; - fallbackError: string; + error: ErrorDescription | undefined; } type Event = { type: 'OPEN' } | { type: 'CLOSE' } | { type: 'SIGN_OUT'; run: () => Promise }; @@ -15,17 +17,17 @@ const { createMachine, assign, fromPromise } = setup(); const machine = createMachine({ id: 'deviceDetails', initial: 'closed', - context: { run: () => Promise.resolve(), errorMessage: undefined, fallbackError: '' }, + context: { run: () => Promise.resolve(), error: undefined }, states: { closed: { - on: { OPEN: { target: 'open', actions: assign(() => ({ errorMessage: undefined })) } }, + on: { OPEN: { target: 'open', actions: assign(() => ({ error: undefined })) } }, }, open: { on: { CLOSE: 'closed', SIGN_OUT: { target: 'signingOut', - actions: assign((_, event) => ({ run: event.run, errorMessage: undefined })), + actions: assign((_, event) => ({ run: event.run, error: undefined })), }, }, }, @@ -34,9 +36,7 @@ const machine = createMachine({ onDone: 'closed', onError: { target: 'open', - actions: assign((context, event) => ({ - errorMessage: event.error instanceof Error ? event.error.message : context.fallbackError, - })), + actions: assign((_, event) => ({ error: toLocalizableError(event.error) })), }, }), }, @@ -50,13 +50,15 @@ export function useUserProfileDeviceDetailsController({ onSignOut?: (device: UserProfileDevice) => void | Promise; fallbackError: string; }) { - const [snapshot, send] = useMachine(machine, { context: { fallbackError } }); + const [snapshot, send] = useMachine(machine); + const errorText = useErrorText(); + const { error } = snapshot.context; return { open: snapshot.value !== 'closed', onOpenChange: (open: boolean) => send({ type: open ? 'OPEN' : 'CLOSE' }), isSigningOut: snapshot.value === 'signingOut', - errorMessage: snapshot.context.errorMessage, + errorMessage: error ? errorText(error, fallbackError) : undefined, onSignOut: onSignOut ? (device: UserProfileDevice) => send({ type: 'SIGN_OUT', run: async () => onSignOut(device) }) : undefined, From ca76053097bb3efde71d813636efb402d0da339c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 6 Oct 2026 10:58:20 -0600 Subject: [PATCH 12/26] test(mosaic): wait for device dialog visibility --- .../__tests__/user-profile-device-errors.feature.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx index 161b026f154..6c3d9d718c0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx @@ -32,10 +32,11 @@ describe.each(['confirmation', 'details'] as const)('device %s errors', surface await user.click(screen.getByRole('button', { name: 'Manage Phone' })); await user.click(screen.getByRole('menuitem', { name: surface === 'details' ? 'View details' : 'Sign out' })); const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog'); + await waitFor(() => expect(dialog).toBeVisible()); await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); const expected = kind === 'clerk' ? 'Cet appareil est indisponible.' : 'Déconnexion impossible.'; await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent(expected)); - expect(dialog).toBeVisible(); + await waitFor(() => expect(dialog).toBeVisible()); expect(screen.queryByText('Private implementation details')).toBeNull(); expect(within(dialog).getByRole('button', { name: 'Sign out' })).not.toHaveAttribute('aria-busy', 'true'); view.unmount(); From b58b850c753b608406323033d7750e2de10050da Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 6 Oct 2026 11:16:43 -0600 Subject: [PATCH 13/26] chore: keep one empty changeset --- .changeset/mosaic-list-removal-focus.md | 5 ----- .changeset/quiet-devices-feedback.md | 2 -- 2 files changed, 7 deletions(-) delete mode 100644 .changeset/mosaic-list-removal-focus.md delete mode 100644 .changeset/quiet-devices-feedback.md diff --git a/.changeset/mosaic-list-removal-focus.md b/.changeset/mosaic-list-removal-focus.md deleted file mode 100644 index faaf23e19d7..00000000000 --- a/.changeset/mosaic-list-removal-focus.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/mosaic': patch ---- - -Keep keyboard focus on the next available row after removing a list item. diff --git a/.changeset/quiet-devices-feedback.md b/.changeset/quiet-devices-feedback.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/quiet-devices-feedback.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- From 2d52843b451960b1701abc90a02af38492de27d9 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 14:34:18 -0600 Subject: [PATCH 14/26] fix(mosaic): simplify active devices sign-out actions --- .../user-profile-active-devices-section.view.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx index 28877fcbf63..75ffc33513f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx @@ -38,15 +38,18 @@ export function UserProfileActiveDevicesSectionView({ const currentDevices = devices.filter(device => device.isCurrent); const otherDevices = devices.filter(device => !device.isCurrent); - const openSignOut = onSignOutDevice ? (device: UserProfileDevice) => signOutDevice.open(device) : undefined; - const currentDeviceTrigger = useRef(null); const removalFocus = useListRemovalFocus({ ids: otherDevices.map(device => device.id), onRemove: onSignOutDevice, fallback: () => currentDeviceTrigger.current, }); - const signOutDeviceAt = onSignOutDevice ? (device: UserProfileDevice) => removalFocus.remove(device.id) : undefined; + const signOutActions = onSignOutDevice + ? { + open: (device: UserProfileDevice) => signOutDevice.open(device), + remove: (device: UserProfileDevice) => removalFocus.remove(device.id), + } + : undefined; const signOutAll = useConfirmationController({ errorFallback: m.signOutAllError }); const signedOutAll = useRef(false); @@ -106,7 +109,7 @@ export function UserProfileActiveDevicesSectionView({ key={device.id} device={device} triggerRef={removalFocus.registerTrigger(device.id)} - onSignOut={openSignOut} + onSignOut={signOutActions?.open} onViewDetails={device => deviceDetails.open(device)} /> ))} @@ -137,7 +140,7 @@ export function UserProfileActiveDevicesSectionView({ {onSignOutDevice ? ( signOutDeviceAt?.(device)} + onConfirm={device => signOutActions?.remove(device)} errorFallback={m.detailsDialog.signOutError} /> ) : null} From d7b4b2f83445a7adfad1e20f0651db4bf6f0919a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 15:10:53 -0600 Subject: [PATCH 15/26] test(mosaic): compose passkeys with active devices slot --- ...er-profile-passkeys-composition.feature.test.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx index f14a095dd5a..8c9b3ea65b6 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx @@ -5,6 +5,7 @@ import { serveFapi } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiEnvironment, fapiPasskey, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { UserProfileView } from '../user-profile.view'; +import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view'; import { passkeysSectionNode } from '../user-profile-passkeys-section/user-profile-passkeys-section'; import { useUserProfilePasskeysModel } from '../user-profile-passkeys-section/user-profile-passkeys-section.model'; @@ -41,7 +42,7 @@ function SecurityHost() { passwordSlot={passwordSlot} passkeysSlot={passkeysSlot} mfaMethods={[]} - devices={[]} + activeDevicesSlot={} /> ); } @@ -53,7 +54,15 @@ function ProfileHost() { {}} - pages={{ account: {}, security: { passwordSlot, passkeysSlot, mfaMethods: [], devices: [] } }} + pages={{ + account: {}, + security: { + passwordSlot, + passkeysSlot, + mfaMethods: [], + activeDevicesSlot: , + }, + }} /> ); } From a0684b30a3abb5a77f19a5d4e6dac212fc921a83 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 17:43:38 -0600 Subject: [PATCH 16/26] refactor(mosaic): reuse clock and confirmation hooks for active devices --- .../__tests__/confirmation.controller.test.ts | 20 ++++++ .../confirmation/confirmation.controller.ts | 2 +- ...er-profile-active-devices.feature.test.tsx | 9 ++- ...er-profile-active-devices-section.model.ts | 8 ++- .../user-profile-device-details.controller.ts | 66 ------------------- .../user-profile-device-details.dialog.tsx | 10 +-- 6 files changed, 37 insertions(+), 78 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts diff --git a/packages/mosaic/src/blocks/confirmation/__tests__/confirmation.controller.test.ts b/packages/mosaic/src/blocks/confirmation/__tests__/confirmation.controller.test.ts index fb5a3adfbd3..b8aec6ccf65 100644 --- a/packages/mosaic/src/blocks/confirmation/__tests__/confirmation.controller.test.ts +++ b/packages/mosaic/src/blocks/confirmation/__tests__/confirmation.controller.test.ts @@ -1,3 +1,4 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -114,4 +115,23 @@ describe('useConfirmationController', () => { await waitFor(() => expect(result.current.errorMessage).toBe('Unable to remove this member.')); }); + + it('clears the previous error while a retry is pending', async () => { + const { result } = renderHook(() => useConfirmationController()); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onConfirm(() => Promise.reject(blocked))); + await waitFor(() => expect(result.current.errorMessage).toMatch(/contact support/)); + + const pending = createDeferredPromise(); + try { + act(() => result.current.onConfirm(() => pending.promise)); + + expect(result.current.isOpen).toBe(true); + expect(result.current.isConfirming).toBe(true); + expect(result.current.errorMessage).toBeUndefined(); + } finally { + await act(() => pending.resolve()); + } + await waitFor(() => expect(result.current.isOpen).toBe(false)); + }); }); diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts index 053bb1a3f7d..09aace70042 100644 --- a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts +++ b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts @@ -32,7 +32,7 @@ export const confirmationMachine = createMachine({ }, confirming: { on: { - CONFIRM: { target: 'pending', actions: assign((_, event) => ({ run: event.run })) }, + CONFIRM: { target: 'pending', actions: assign((_, event) => ({ run: event.run, error: undefined })) }, CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, }, }, diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx index df05a4d4299..63da8ee4911 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest'; import { type ActiveDeviceRecord, fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; +import { MosaicNowProvider } from '../../../hooks/use-now'; import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices.types'; import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section'; @@ -487,8 +488,8 @@ describe('Active devices', () => { } }); - it('localizes relative and formatted activity dates with surrounding text', async () => { - const now = new Date(); + it('localizes activity dates using the provider clock', async () => { + const now = new Date(2025, 5, 15, 12); const yesterdayLate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 30); const todayEarly = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 30); const older = new Date('2024-01-05T12:00:00Z'); @@ -504,7 +505,9 @@ describe('Active devices', () => { messages: { userProfileActiveDevices: { lastSeen: 'Vu {date}', deviceName: '{browser} sur {device}' } }, })} > - + + + , ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts index 21b0a62467b..636790de752 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts @@ -3,6 +3,7 @@ import { useClerk, useSession, useUser } from '@clerk/shared/react'; import type { SessionWithActivitiesResource } from '@clerk/shared/types'; import { useEffect, useState } from 'react'; +import { useNow } from '../../../hooks/use-now'; import { fill, useLocale, useMessages } from '../../../localization'; import type { UserProfileDevice } from './user-profile-active-devices.types'; @@ -26,8 +27,8 @@ function isActiveDevice(session: SessionWithActivitiesResource): boolean { return session.status === 'active' || session.status === 'pending'; } -function lastActiveLabel(date: Date, locale: string): string { - const days = differenceInCalendarDays(new Date(), date, { absolute: false }); +function lastActiveLabel(date: Date, now: Date, locale: string): string { + const days = differenceInCalendarDays(now, date, { absolute: false }); if (Math.abs(days) <= 6) { return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(days, 'day'); } @@ -38,6 +39,7 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode const clerk = useClerk(); const { isLoaded: isUserLoaded, user } = useUser(); const { isLoaded: isSessionLoaded, session } = useSession(); + const now = useNow({ updateInterval: 60_000 }); const locale = useLocale(); const m = useMessages('userProfileActiveDevices'); const userId = user?.id; @@ -78,7 +80,7 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode const model = activity.deviceType || (isMobile ? m.mobileDevice : m.desktopDevice); const browser = [activity.browserName, activity.browserVersion].filter(Boolean).join(' ') || m.webBrowser; const location = [activity.city, activity.country].filter(Boolean).join(', '); - const lastActive = lastActiveLabel(item.lastActiveAt, locale); + const lastActive = lastActiveLabel(item.lastActiveAt, now, locale); const description = [fill(m.lastSeen, { date: lastActive }), location].filter(Boolean).join(' · '); return { diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts deleted file mode 100644 index fab94a49f32..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.controller.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ErrorDescription } from '../../../localization'; -import { useErrorText } from '../../../localization'; -import { setup } from '../../../machine/setup'; -import { useMachine } from '../../../machine/use-machine'; -import { toLocalizableError } from '../../../utils/errors'; -import type { UserProfileDevice } from './user-profile-active-devices.types'; - -interface Context { - run: () => Promise; - error: ErrorDescription | undefined; -} - -type Event = { type: 'OPEN' } | { type: 'CLOSE' } | { type: 'SIGN_OUT'; run: () => Promise }; - -const { createMachine, assign, fromPromise } = setup(); - -const machine = createMachine({ - id: 'deviceDetails', - initial: 'closed', - context: { run: () => Promise.resolve(), error: undefined }, - states: { - closed: { - on: { OPEN: { target: 'open', actions: assign(() => ({ error: undefined })) } }, - }, - open: { - on: { - CLOSE: 'closed', - SIGN_OUT: { - target: 'signingOut', - actions: assign((_, event) => ({ run: event.run, error: undefined })), - }, - }, - }, - signingOut: { - invoke: fromPromise(context => context.run(), { - onDone: 'closed', - onError: { - target: 'open', - actions: assign((_, event) => ({ error: toLocalizableError(event.error) })), - }, - }), - }, - }, -}); - -export function useUserProfileDeviceDetailsController({ - onSignOut, - fallbackError, -}: { - onSignOut?: (device: UserProfileDevice) => void | Promise; - fallbackError: string; -}) { - const [snapshot, send] = useMachine(machine); - const errorText = useErrorText(); - const { error } = snapshot.context; - - return { - open: snapshot.value !== 'closed', - onOpenChange: (open: boolean) => send({ type: open ? 'OPEN' : 'CLOSE' }), - isSigningOut: snapshot.value === 'signingOut', - errorMessage: error ? errorText(error, fallbackError) : undefined, - onSignOut: onSignOut - ? (device: UserProfileDevice) => send({ type: 'SIGN_OUT', run: async () => onSignOut(device) }) - : undefined, - }; -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx index 5f3a44b65f7..f308e6928ca 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx @@ -1,3 +1,4 @@ +import { useConfirmationController } from '../../../blocks/confirmation/confirmation.controller'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; import { DataList } from '../../../components/data-list'; @@ -5,7 +6,6 @@ import type { DialogFocusTarget, DialogHandle } from '../../../components/dialog import { Dialog } from '../../../components/dialog'; import { fill, useMessages } from '../../../localization'; import type { UserProfileDevice } from './user-profile-active-devices.types'; -import { useUserProfileDeviceDetailsController } from './user-profile-device-details.controller'; export interface UserProfileDeviceDetailsDialogProps { handle: DialogHandle; @@ -15,12 +15,12 @@ export interface UserProfileDeviceDetailsDialogProps { export function UserProfileDeviceDetailsDialog({ handle, finalFocus, onSignOut }: UserProfileDeviceDetailsDialogProps) { const m = useMessages('userProfileActiveDevices'); - const controller = useUserProfileDeviceDetailsController({ onSignOut, fallbackError: m.detailsDialog.signOutError }); + const controller = useConfirmationController({ errorFallback: m.detailsDialog.signOutError }); return ( {({ payload: device }) => @@ -31,8 +31,8 @@ export function UserProfileDeviceDetailsDialog({ handle, finalFocus, onSignOut } > controller.onConfirm(async () => onSignOut(device)) : undefined} + isSigningOut={controller.isConfirming} errorMessage={controller.errorMessage} /> From 296782dc6e5b6c216a24184a7eb4b48b60ac5474 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 18:45:02 -0600 Subject: [PATCH 17/26] refactor(mosaic): use the existing SDK API for active devices --- .../core/resources/SessionWithActivities.ts | 9 +-- packages/clerk-js/src/core/resources/User.ts | 7 +- .../resources/__tests__/User.sessions.test.ts | 78 ------------------- ...er-profile-active-devices.feature.test.tsx | 12 +-- ...er-profile-active-devices-section.model.ts | 37 ++------- .../user-profile-active-devices-section.tsx | 11 --- .../user-profile-active-devices.messages.ts | 2 - packages/shared/src/types/user.ts | 10 +-- 8 files changed, 24 insertions(+), 142 deletions(-) delete mode 100644 packages/clerk-js/src/core/resources/__tests__/User.sessions.test.ts diff --git a/packages/clerk-js/src/core/resources/SessionWithActivities.ts b/packages/clerk-js/src/core/resources/SessionWithActivities.ts index 0210f6c4ba0..23c8ababed2 100644 --- a/packages/clerk-js/src/core/resources/SessionWithActivities.ts +++ b/packages/clerk-js/src/core/resources/SessionWithActivities.ts @@ -1,6 +1,5 @@ import type { ActClaim, - GetSessionsParams, SessionActivity, SessionActivityJSON, SessionWithActivitiesJSON, @@ -37,10 +36,10 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc this.fromJSON(data); } - static retrieve(params?: Pick): Promise { + static retrieve(): Promise { const sessionId = BaseResource.clerk.session?.id; - const request = this.clerk + return this.clerk .getFapiClient() .request({ method: 'GET', @@ -51,8 +50,8 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc // https://www.notion.so/clerkdev/Align-SessionWithActivities-retrieval-with-the-rest-of-Client-API-a043f72f6b9d4344bd2f21dc1d3f79de const sessionWithActivitiesJSON = res.payload as unknown as SessionWithActivitiesJSON[]; return sessionWithActivitiesJSON.map(sa => new SessionWithActivities(sa, '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/me/sessions')); - }); - return params?.throwOnError ? request : request.catch(() => []); + }) + .catch(() => []); } revoke(): Promise { diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index 7d6a8c08bc7..a57e8a6249e 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -19,7 +19,6 @@ import type { ExternalAccountResource, GetEnterpriseConnectionsParams, GetOrganizationMemberships, - GetSessionsParams, GetUserOrganizationInvitationsParams, GetUserOrganizationSuggestionsParams, ImageResource, @@ -331,11 +330,11 @@ export class User extends BaseResource implements UserResource { }); }; - getSessions = async (params?: GetSessionsParams): Promise => { - if (!params?.forceRefresh && this.cachedSessionsWithActivities) { + getSessions = async (): Promise => { + if (this.cachedSessionsWithActivities) { return this.cachedSessionsWithActivities; } - const res = await SessionWithActivities.retrieve(params); + const res = await SessionWithActivities.retrieve(); this.cachedSessionsWithActivities = res; return res; }; diff --git a/packages/clerk-js/src/core/resources/__tests__/User.sessions.test.ts b/packages/clerk-js/src/core/resources/__tests__/User.sessions.test.ts deleted file mode 100644 index 2f3c8c2a50f..00000000000 --- a/packages/clerk-js/src/core/resources/__tests__/User.sessions.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { createSession, createUser } from '@/test/core-fixtures'; - -import { Clerk } from '../../clerk'; -import { BaseResource } from '../internal'; -import { User } from '../User'; - -function response(id: string) { - const { user: _user, ...session } = createSession({ id, status: 'active' }); - return Object.assign(new Response(), { - payload: [{ ...session, user: null, latest_activity: { object: 'session_activity', id: `activity_${id}` } }], - }); -} - -describe('User session retrieval', () => { - const clerk = new Clerk('pk_test_Y2xlcmsuZXhhbXBsZS5jb20k'); - const previousClerk = BaseResource.clerk; - - beforeEach(() => { - BaseResource.clerk = clerk; - }); - - afterEach(() => { - vi.restoreAllMocks(); - BaseResource.clerk = previousClerk; - }); - - it('preserves cached successes for callers without options', async () => { - const request = vi.spyOn(clerk.getFapiClient(), 'request').mockResolvedValue(response('sess_first')); - const user = new User(createUser()); - expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_first']); - request.mockResolvedValue(response('sess_next')); - expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_first']); - expect(request).toHaveBeenCalledOnce(); - }); - - it('preserves the cached empty result after failure for callers without options', async () => { - const request = vi.spyOn(clerk.getFapiClient(), 'request').mockRejectedValue(new Error('Network failure')); - const user = new User(createUser()); - await expect(user.getSessions()).resolves.toEqual([]); - request.mockResolvedValue(response('sess_next')); - await expect(user.getSessions()).resolves.toEqual([]); - expect(request).toHaveBeenCalledOnce(); - }); - - it('makes a fresh read after a legacy caller cached a failed request', async () => { - const request = vi.spyOn(clerk.getFapiClient(), 'request').mockRejectedValue(new Error('Network failure')); - const user = new User(createUser()); - await expect(user.getSessions()).resolves.toEqual([]); - request.mockResolvedValue(response('sess_next')); - const sessions = await user.getSessions({ forceRefresh: true, throwOnError: true }); - expect(sessions.map(session => session.id)).toEqual(['sess_next']); - expect(request).toHaveBeenCalledTimes(2); - }); - - it('propagates a strict failure without overwriting a successful cache', async () => { - const request = vi.spyOn(clerk.getFapiClient(), 'request').mockResolvedValue(response('sess_first')); - const user = new User(createUser()); - await user.getSessions(); - const failure = new Error('Network failure'); - request.mockRejectedValue(failure); - await expect(user.getSessions({ forceRefresh: true, throwOnError: true })).rejects.toBe(failure); - expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_first']); - }); - - it('retries a strict failed request and refreshes the cached sessions on success', async () => { - const request = vi.spyOn(clerk.getFapiClient(), 'request').mockRejectedValue(new Error('Network failure')); - const user = new User(createUser()); - await expect(user.getSessions({ forceRefresh: true, throwOnError: true })).rejects.toThrow('Network failure'); - request.mockResolvedValue(response('sess_next')); - expect((await user.getSessions({ forceRefresh: true, throwOnError: true })).map(session => session.id)).toEqual([ - 'sess_next', - ]); - expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_next']); - expect(request).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx index 63da8ee4911..b71bbd72f62 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx @@ -159,7 +159,7 @@ describe('Active devices', () => { expect(screen.queryByRole('button', { name: 'Manage Safari on Expired laptop' })).toBeNull(); }); - it('shows a load error and retries after the device request recovers', async () => { + it('preserves the SDK empty-list behavior after a failed device request', async () => { serveDevices([device('sess_current', 'active')]); let failing = true; worker.use( @@ -170,12 +170,14 @@ describe('Active devices', () => { ), ); - await renderWithClerk(); + const view = await renderWithClerk(); - expect(await screen.findByRole('alert')).toHaveTextContent('Could not load active devices.'); + expect(await screen.findByText('No current device available')).toBeVisible(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull(); failing = false; - await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' })); - expect(await screen.findByText('This device')).toBeVisible(); + await expect(view.clerk.user?.getSessions()).resolves.toEqual([]); + expect(screen.queryByText('This device')).toBeNull(); }); it('maps impersonation sessions to the distinct badges', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts index 636790de752..fc50f63a685 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts @@ -10,7 +10,6 @@ import type { UserProfileDevice } from './user-profile-active-devices.types'; export type UserProfileActiveDevicesModel = | { status: 'loading' } | { status: 'hidden' } - | { status: 'error'; message: string; retry: () => void } | { status: 'ready'; identity: string; @@ -20,7 +19,6 @@ export type UserProfileActiveDevicesModel = type SessionsQuery = | { status: 'loading'; identity: string | undefined } - | { status: 'error'; identity: string } | { status: 'ready'; identity: string; sessions: SessionWithActivitiesResource[] }; function isActiveDevice(session: SessionWithActivitiesResource): boolean { @@ -47,7 +45,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode const identity = userId && sessionId ? `${userId}:${sessionId}` : undefined; const [query, setQuery] = useState({ status: 'loading', identity }); - const [attempt, setAttempt] = useState(0); useEffect(() => { const currentUser = clerk.user; @@ -56,22 +53,15 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode } let active = true; setQuery({ status: 'loading', identity }); - void currentUser.getSessions({ forceRefresh: true, throwOnError: true }).then( - sessions => { - if (active && clerk.user?.id === userId && clerk.session?.id === sessionId) { - setQuery({ status: 'ready', identity, sessions }); - } - }, - () => { - if (active && clerk.user?.id === userId && clerk.session?.id === sessionId) { - setQuery({ status: 'error', identity }); - } - }, - ); + void currentUser.getSessions().then(sessions => { + if (active && clerk.user?.id === userId && clerk.session?.id === sessionId) { + setQuery({ status: 'ready', identity, sessions }); + } + }); return () => { active = false; }; - }, [clerk, userId, sessionId, identity, attempt]); + }, [clerk, userId, sessionId, identity]); const toDevice = (item: SessionWithActivitiesResource): UserProfileDevice => { const activity = item.latestActivity; @@ -109,14 +99,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode if (query.identity !== identity || query.status === 'loading') { return { status: 'loading' }; } - if (query.status === 'error') { - return { - status: 'error', - message: m.loadError, - retry: () => setAttempt(value => value + 1), - }; - } - return { status: 'ready', identity, @@ -126,7 +108,9 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode .map(toDevice), revoke: async id => { const currentUser = clerk.user; + const target = query.sessions.find(item => item.id === id && isActiveDevice(item)); if ( + !target || !currentUser || !sessionId || currentUser.id !== userId || @@ -135,11 +119,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode ) { throw new Error(m.signOutError); } - const sessions = await currentUser.getSessions({ forceRefresh: true, throwOnError: true }); - const target = sessions.find(item => item.id === id && isActiveDevice(item)); - if (!target || clerk.user?.id !== userId || clerk.session?.id !== sessionId) { - throw new Error(m.signOutError); - } await target.revoke(); return clerk.user?.id === userId && clerk.session?.id === sessionId; }, diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx index cbeb878f79d..73da4491512 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.tsx @@ -1,7 +1,5 @@ import type { ReactNode } from 'react'; -import { Button } from '../../../components/button'; -import { useMessages } from '../../../localization'; import { useUserProfileActiveDevicesController } from './user-profile-active-devices-section.controller'; import type { UserProfileActiveDevicesModel } from './user-profile-active-devices-section.model'; import { useUserProfileActiveDevicesModel } from './user-profile-active-devices-section.model'; @@ -9,21 +7,12 @@ import { UserProfileActiveDevicesSectionView } from './user-profile-active-devic export function UserProfileActiveDevicesSection({ fallback = null }: { fallback?: ReactNode }) { const model = useUserProfileActiveDevicesModel(); - const m = useMessages('userProfileActiveDevices'); if (model.status === 'hidden') { return null; } if (model.status === 'loading') { return fallback; } - if (model.status === 'error') { - return ( -
- {model.message} - -
- ); - } return ( boolean; /** - * Gets all **active** sessions for this user. Results are cached unless `forceRefresh` is enabled. - * Failed requests resolve to an empty array unless `throwOnError` is enabled. + * Gets all **active** sessions for this user. This method uses a cache so a network request will only be triggered only once. * @returns An array of [`SessionWithActivities`](https://clerk.com/docs/reference/types/session-with-activities) objects. */ - getSessions: (params?: GetSessionsParams) => Promise; + getSessions: () => Promise; /** * Adds the user's profile image or replaces it if one already exists. This method will upload an image and associate it with the user. */ From 80892a99798686da9e9147bfad208e94a4b16d9a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 18:55:37 -0600 Subject: [PATCH 18/26] test(mosaic): reproduce stale device details after locale changes --- ...vice-details-localization.feature.test.tsx | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx new file mode 100644 index 00000000000..9ec5078ce01 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx @@ -0,0 +1,104 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { expect, it } from 'vitest'; + +import { type ActiveDeviceRecord, fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { MosaicNowProvider } from '../../../hooks/use-now'; +import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; +import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section'; + +const alice = fapiUser({ id: 'user_1' }); + +function device(id: string, deviceType: string): ActiveDeviceRecord { + const { user: _owner, ...session } = fapiSession({ id, user: alice }); + return { + ...session, + user: null, + ownerUserId: alice.id, + last_active_at: new Date(2025, 5, 14, 12).getTime(), + latest_activity: { + object: 'session_activity', + id: `activity_${id}`, + browser_name: 'Safari', + device_type: deviceType, + }, + }; +} + +function section(locale: string) { + return ( + + + + + + ); +} + +it.each(['idle', 'pending'] as const)('updates open device details after a locale change while %s', async state => { + serveFapi({ + client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]), + activeDevices: [device('sess_current', 'Laptop'), device('sess_other', 'Phone')], + }); + const pending = createDeferredPromise(); + worker.use( + http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), async () => { + await pending.promise; + return HttpResponse.json({ + response: fapiSession({ id: 'sess_other', user: alice, status: 'revoked' }), + client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]), + }); + }), + ); + const view = await renderWithClerk(section('en-US')); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' })); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + const dialog = screen.getByRole('dialog'); + await waitFor(() => expect(dialog).toBeVisible()); + expect(within(dialog).getByText('Last active yesterday')).toBeVisible(); + + try { + if (state === 'pending') { + await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); + await waitFor(() => + expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy'), + ); + } + + view.rerender(section('fr-FR')); + + expect(screen.getAllByText('Vu hier')).toHaveLength(2); + expect(screen.getByRole('dialog')).toBe(dialog); + expect(dialog).toHaveTextContent('Dernière activité hier'); + expect(within(dialog).getByRole('heading', { name: 'Safari sur Phone' })).toBeVisible(); + if (state === 'pending') { + expect(within(dialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute('aria-busy'); + await user.keyboard('{Escape}'); + expect(dialog).toBeVisible(); + } + } finally { + pending.resolve(); + if (state === 'pending') { + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + } + } +}); From a1a40a5e70f90dbef53ca786fa6023390ac0062a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 19:00:59 -0600 Subject: [PATCH 19/26] fix(mosaic): refresh open device details when the locale changes --- ...er-profile-active-devices-section.view.tsx | 1 + .../user-profile-device-details.dialog.tsx | 22 ++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx index 75ffc33513f..ace04e57e3c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx @@ -139,6 +139,7 @@ export function UserProfileActiveDevicesSectionView({ ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx index f308e6928ca..787634639ff 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx @@ -9,11 +9,17 @@ import type { UserProfileDevice } from './user-profile-active-devices.types'; export interface UserProfileDeviceDetailsDialogProps { handle: DialogHandle; + devices: UserProfileDevice[]; finalFocus?: DialogFocusTarget; onSignOut?: (device: UserProfileDevice) => void | Promise; } -export function UserProfileDeviceDetailsDialog({ handle, finalFocus, onSignOut }: UserProfileDeviceDetailsDialogProps) { +export function UserProfileDeviceDetailsDialog({ + handle, + devices, + finalFocus, + onSignOut, +}: UserProfileDeviceDetailsDialogProps) { const m = useMessages('userProfileActiveDevices'); const controller = useConfirmationController({ errorFallback: m.detailsDialog.signOutError }); @@ -23,8 +29,14 @@ export function UserProfileDeviceDetailsDialog({ handle, finalFocus, onSignOut } open={controller.isOpen} onOpenChange={controller.onOpenChange} > - {({ payload: device }) => - device === undefined ? null : ( + {({ payload }) => { + if (payload === undefined) { + return null; + } + + const device = devices.find(candidate => candidate.id === payload.id) ?? payload; + + return ( - ) - } + ); + }}
); } From c50b3c0bc6df7b20ed2a4e7dab1a1fcbc678b239 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 19:18:55 -0600 Subject: [PATCH 20/26] test(mosaic): align active device tests with feature conventions --- ...vice-details-localization.feature.test.tsx | 104 ----------------- ...e-active-devices-section.feature.test.tsx} | 78 +++---------- ...ices-section.localization.feature.test.tsx | 108 ++++++++++++++++++ ...ices-section.view.errors.feature.test.tsx} | 2 +- 4 files changed, 128 insertions(+), 164 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx rename packages/mosaic/src/features/user-profile/{__tests__/user-profile-active-devices.feature.test.tsx => user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx} (93%) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx rename packages/mosaic/src/features/user-profile/{__tests__/user-profile-device-errors.feature.test.tsx => user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx} (94%) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx deleted file mode 100644 index 9ec5078ce01..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-details-localization.feature.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; -import { screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; -import { expect, it } from 'vitest'; - -import { type ActiveDeviceRecord, fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; -import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; -import { renderWithClerk } from '../../../__tests__/feature/render'; -import { MosaicNowProvider } from '../../../hooks/use-now'; -import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; -import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section'; - -const alice = fapiUser({ id: 'user_1' }); - -function device(id: string, deviceType: string): ActiveDeviceRecord { - const { user: _owner, ...session } = fapiSession({ id, user: alice }); - return { - ...session, - user: null, - ownerUserId: alice.id, - last_active_at: new Date(2025, 5, 14, 12).getTime(), - latest_activity: { - object: 'session_activity', - id: `activity_${id}`, - browser_name: 'Safari', - device_type: deviceType, - }, - }; -} - -function section(locale: string) { - return ( - - - - - - ); -} - -it.each(['idle', 'pending'] as const)('updates open device details after a locale change while %s', async state => { - serveFapi({ - client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]), - activeDevices: [device('sess_current', 'Laptop'), device('sess_other', 'Phone')], - }); - const pending = createDeferredPromise(); - worker.use( - http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), async () => { - await pending.promise; - return HttpResponse.json({ - response: fapiSession({ id: 'sess_other', user: alice, status: 'revoked' }), - client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]), - }); - }), - ); - const view = await renderWithClerk(section('en-US')); - const user = userEvent.setup(); - await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' })); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - const dialog = screen.getByRole('dialog'); - await waitFor(() => expect(dialog).toBeVisible()); - expect(within(dialog).getByText('Last active yesterday')).toBeVisible(); - - try { - if (state === 'pending') { - await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); - await waitFor(() => - expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy'), - ); - } - - view.rerender(section('fr-FR')); - - expect(screen.getAllByText('Vu hier')).toHaveLength(2); - expect(screen.getByRole('dialog')).toBe(dialog); - expect(dialog).toHaveTextContent('Dernière activité hier'); - expect(within(dialog).getByRole('heading', { name: 'Safari sur Phone' })).toBeVisible(); - if (state === 'pending') { - expect(within(dialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute('aria-busy'); - await user.keyboard('{Escape}'); - expect(dialog).toBeVisible(); - } - } finally { - pending.resolve(); - if (state === 'pending') { - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - } - } -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx similarity index 93% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx index b71bbd72f62..21fd71bbb6e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-active-devices.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx @@ -6,15 +6,21 @@ import { http, HttpResponse } from 'msw'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { type ActiveDeviceRecord, fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; +import { + type ActiveDeviceRecord, + fapiUrl, + holdRequests, + serveFapi, + worker, +} from '../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { MosaicNowProvider } from '../../../hooks/use-now'; import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; -import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices.types'; -import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section'; -import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; +import type { UserProfileDevice } from './user-profile-active-devices.types'; +import { UserProfileActiveDevicesSection } from './user-profile-active-devices-section'; +import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view'; const alice = fapiUser({ id: 'user_1' }); @@ -525,44 +531,6 @@ describe('Active devices', () => { expect(screen.getAllByRole('button', { name: 'Manage Safari sur MacBook Pro' })).toHaveLength(3); }); - it('keeps device details open while the locale changes during a pending revoke', async () => { - serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })]); - const pending = createDeferredPromise(); - worker.use( - http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), async () => { - await pending.promise; - return HttpResponse.json({ - response: fapiSession({ id: 'sess_other', user: alice, status: 'revoked' }), - client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]), - }); - }), - ); - const section = (locale: string) => ( - - - - ); - const view = await renderWithClerk(section('en-US')); - const user = userEvent.setup(); - try { - await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' })); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - const dialog = screen.getByRole('dialog'); - await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); - await waitFor(() => - expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy'), - ); - - view.rerender(section('fr-FR')); - - expect(screen.getByRole('dialog')).toBe(dialog); - expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy'); - } finally { - pending.resolve(); - } - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - }); - it.each(['confirmation', 'details'] as const)( 'surfaces verification-required errors in the %s without opening reverification', async surface => { @@ -592,36 +560,28 @@ describe('Active devices', () => { it('keeps device details open until its pending sign out completes', async () => { const otherDevice = device('sess_other', 'active', { device_type: 'iPhone' }); serveDevices([device('sess_current', 'active'), otherDevice]); - const pending = createDeferredPromise(); - let requests = 0; - worker.use( - http.post(fapiUrl('/v1/me/sessions/sess_other/revoke'), async () => { - requests += 1; - await pending.promise; - return HttpResponse.json({ response: { ...otherDevice, status: 'revoked' }, client: null }); - }), - ); await renderWithClerk(); const user = userEvent.setup(); + const revoke = holdRequests('post', '/v1/me/sessions/sess_other/revoke'); try { await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' })); await user.click(screen.getByRole('menuitem', { name: 'View details' })); const dialog = screen.getByRole('dialog'); await user.dblClick(within(dialog).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(requests).toBe(1)); + await waitFor(() => expect(revoke.requests).toHaveLength(1)); await user.keyboard('{Escape}'); - expect(screen.getByRole('dialog')).toBe(dialog); - await user.click(within(dialog).getByRole('button', { name: 'Close' })); - expect(screen.getByRole('dialog')).toBe(dialog); + expect(screen.getByRole('dialog')).toBeVisible(); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' })); + expect(screen.getByRole('dialog')).toBeVisible(); await user.click(document.body); - expect(screen.getByRole('dialog')).toBe(dialog); - expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy'); + expect(screen.getByRole('dialog')).toBeVisible(); + expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy'); } finally { - pending.resolve(); + revoke.release(); } await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull(); - expect(requests).toBe(1); + expect(revoke.requests).toHaveLength(1); }); it('keeps device details open after a failed revoke and allows retrying', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx new file mode 100644 index 00000000000..212527dd8df --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx @@ -0,0 +1,108 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { type ActiveDeviceRecord, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { MosaicNowProvider } from '../../../hooks/use-now'; +import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; +import { UserProfileActiveDevicesSection } from './user-profile-active-devices-section'; + +const alice = fapiUser({ id: 'user_1' }); + +function device(id: string, deviceType: string): ActiveDeviceRecord { + const { user: _owner, ...session } = fapiSession({ id, user: alice }); + return { + ...session, + user: null, + ownerUserId: alice.id, + last_active_at: new Date(2025, 5, 14, 12).getTime(), + latest_activity: { + object: 'session_activity', + id: `activity_${id}`, + browser_name: 'Safari', + device_type: deviceType, + }, + }; +} + +function section(locale: string) { + return ( + + + + + + ); +} + +describe('Change language while viewing device details', () => { + it.each(['idle', 'pending'] as const)('updates open device details after a locale change while %s', async state => { + serveFapi({ + client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]), + activeDevices: [device('sess_current', 'Laptop'), device('sess_other', 'Phone')], + }); + const view = await renderWithClerk(section('en-US')); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' })); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + const dialog = screen.getByRole('dialog'); + await waitFor(() => expect(dialog).toBeVisible()); + expect(within(dialog).getByText('Last active yesterday')).toBeVisible(); + const revoke = state === 'pending' ? holdRequests('post', '/v1/me/sessions/sess_other/revoke') : undefined; + + try { + if (revoke) { + await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(revoke.requests).toHaveLength(1)); + await waitFor(() => + expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })).toHaveAttribute( + 'aria-busy', + 'true', + ), + ); + } + + view.rerender(section('fr-FR')); + + expect(screen.getAllByText('Vu hier')).toHaveLength(2); + const translatedDialog = screen.getByRole('dialog'); + expect(translatedDialog).toHaveTextContent('Dernière activité hier'); + expect(within(translatedDialog).getByRole('heading', { name: 'Safari sur Phone' })).toBeVisible(); + if (revoke) { + expect(within(translatedDialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute( + 'aria-busy', + 'true', + ); + await user.keyboard('{Escape}'); + const stillOpenDialog = screen.getByRole('dialog'); + expect(stillOpenDialog).toBeVisible(); + expect(within(stillOpenDialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute( + 'aria-busy', + 'true', + ); + } + } finally { + revoke?.release(); + if (revoke) { + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage Safari sur Phone' })).toBeNull()); + } + } + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx similarity index 94% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx index 6c3d9d718c0..ef1e6f9aa31 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-errors.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { clerkApiError } from '../../../__tests__/clerk-errors'; import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; -import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; +import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view'; describe.each(['confirmation', 'details'] as const)('device %s errors', surface => { it.each(['private', 'clerk'] as const)('renders a safe localized message for a %s error', async kind => { From 82f05705379b18b08316dd90903298551c3b5ed6 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 19:56:00 -0600 Subject: [PATCH 21/26] test(mosaic): reproduce unavailable device guard error fallback --- ...ices-section.guard-errors.feature.test.tsx | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx new file mode 100644 index 00000000000..74bc79e363d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx @@ -0,0 +1,78 @@ +import { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { type ActiveDeviceRecord, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; +import { UserProfileActiveDevicesSection } from './user-profile-active-devices-section'; + +describe('Sign out an unavailable device', () => { + it.each([ + ['en-US', 'This device is no longer available. Please try again.'], + ['fr-FR', 'Cet appareil est indisponible.'], + ])('shows the catalog message in %s', async (locale, expectedMessage) => { + const alice = fapiUser({ id: 'user_1' }); + serveFapi({ + client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]), + activeDevices: ['sess_current', 'sess_other'].map(id => { + const { user: _owner, ...session } = fapiSession({ id, user: alice }); + return { + ...session, + user: null, + ownerUserId: alice.id, + latest_activity: { + object: 'session_activity', + id: `activity_${id}`, + browser_name: 'Safari', + device_type: id === 'sess_other' ? 'Phone' : 'Laptop', + }, + }; + }), + }); + const view = await renderWithClerk( + + + , + ); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' })); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible()); + + const currentUser = view.clerk.user; + if (!currentUser) { + throw new Error('Expected a signed-in user'); + } + const target = (await currentUser.getSessions()).find(session => session.id === 'sess_other'); + if (!target) { + throw new Error('Expected another device'); + } + await act(async () => { + await target.revoke(); + }); + + const logError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => + expect(within(screen.getByRole('dialog')).getByRole('alert')).toHaveTextContent(expectedMessage), + ); + expect(screen.getByRole('dialog')).toBeVisible(); + expect(screen.queryByText('Unexpected device failure.')).toBeNull(); + expect(logError).not.toHaveBeenCalled(); + } finally { + logError.mockRestore(); + } + }); +}); From 5da06e8a1e0edb1fe83c87fdd919cd4be1fb928b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 20:03:28 -0600 Subject: [PATCH 22/26] fix(mosaic): localize rejected device actions and document removal focus --- packages/mosaic/AGENTS.md | 4 +++ packages/mosaic/docs/views.md | 33 ++++++++++--------- ...le-active-devices-section.feature.test.tsx | 2 +- ...er-profile-active-devices-section.model.ts | 3 +- .../user-profile-active-devices.messages.ts | 1 - .../src/localization/errors.messages.ts | 1 + 6 files changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 16abbc46736..c7670131c37 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -84,6 +84,10 @@ Docs say what the code cannot: rules, reasons, and which tool fits which problem A PR that adds, renames or changes the signature of a shared helper in `src/hooks/`, `src/utils/`, `src/blocks/`, `src/components/form/` or `src/machine/` updates the table above, and any doc that names it, in the same PR. +## Reuse + +Use `useListRemovalFocus` (`src/hooks/use-list-removal-focus.ts`) when removing a list row also removes its dialog trigger. Pass the row ids, `onRemove`, and a surviving fallback control. Return `false` from `onRemove` to skip removal tracking and replacement focus for that action, such as when its result is stale. See [Views](../../.claude/skills/mosaic/references/views.md) for the wiring. + ## Lint Mosaic runs stricter lint rules than the rest of the repo (the `packages/mosaic` block in `eslint.config.mjs`). diff --git a/packages/mosaic/docs/views.md b/packages/mosaic/docs/views.md index 9603b61bf07..c8f5bfecb3d 100644 --- a/packages/mosaic/docs/views.md +++ b/packages/mosaic/docs/views.md @@ -106,32 +106,33 @@ opened the dialog unmounted with the menu, and a dialog mounted at the section rather than inside the menu has no floating-tree ancestor to walk back to. A keyboard user loses their place mid-list and a screen reader announces nothing. -Hand focus to a surviving element with `useListRemovalFocus` (`hooks/`). It -records which row was removed and, through `finalFocus`, picks the row that took -its place, the last row when it was the last, or `fallback()` once the list is -empty. `finalFocus` on `Dialog.Popup` and on the `Confirmation` block takes a -function resolved when the dialog closes, after the row has gone: +Use `useListRemovalFocus` (`packages/mosaic/src/hooks/use-list-removal-focus.ts`) +to hand focus to a surviving element. Pass `finalFocus` to `Dialog.Popup` or +`Confirmation`. The dialog calls it when it closes, after the row has gone: ```tsx +const fallbackTrigger = useRef(null); const removalFocus = useListRemovalFocus({ - ids: methods.map(method => method.id), + ids: rows.map(row => row.id), onRemove, - fallback: () => section.current?.querySelector('button:not([disabled])') ?? section.current, + fallback: () => fallbackTrigger.current, }); -// each row's trigger -triggerRef={removalFocus.registerTrigger(method.id)} - -// the dialog +triggerRef={removalFocus.registerTrigger(row.id)} finalFocus={removalFocus.finalFocus} -onConfirm={method => removalFocus.remove(method.id)} +onConfirm={row => removalFocus.remove(row.id)} ``` -A cancelled or failed removal keeps its own trigger, so `finalFocus` returns -`null` and the dialog's default (the trigger) applies. +When `onRemove` resolves to `void` or `true`, the hook records the row and its +index. `finalFocus` then picks the next row's registered trigger, the previous +row's trigger if the removed row was last, or `fallback()` if the chosen +trigger is unavailable. Attach `fallbackTrigger` to a control that outlives the list. -Prefer the row that took the removed one's place, the last row when it was the -last, and a control that outlives the list once it is empty. +When `onRemove` resolves to `false`, the hook does not record that attempt or +choose replacement focus for it. A rejection also leaves that attempt +unrecorded; let the existing error owner handle it. If no earlier removal is +recorded, `finalFocus` returns `null` and the dialog's default focus behavior +applies. Test the removal, not just the cancel: `toHaveFocus()` on the row that should have caught it. A suite that only asserts focus after cancelling passes while diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx index 21fd71bbb6e..fb04bb00a6b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx @@ -313,7 +313,7 @@ describe('Active devices', () => { ), ); const messages = { - userProfileActiveDevices: { signOutError: 'Erreur de déconnexion.' }, + userProfileActiveDevices: { detailsDialog: { signOutError: 'Erreur de déconnexion.' } }, errors: { invalid_action_for_session: 'Cet appareil est indisponible.' }, }; await renderWithClerk( diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts index fc50f63a685..71391033e07 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts @@ -1,4 +1,5 @@ import { differenceInCalendarDays } from '@clerk/shared/date'; +import { ClerkRuntimeError } from '@clerk/shared/error'; import { useClerk, useSession, useUser } from '@clerk/shared/react'; import type { SessionWithActivitiesResource } from '@clerk/shared/types'; import { useEffect, useState } from 'react'; @@ -117,7 +118,7 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode clerk.session?.id !== sessionId || id === sessionId ) { - throw new Error(m.signOutError); + throw new ClerkRuntimeError('This device is no longer available.', { code: 'active_device_unavailable' }); } await target.revoke(); return clerk.user?.id === userId && clerk.session?.id === sessionId; diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts index 61c5e3d0f72..cd67ebc65bf 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages.ts @@ -6,7 +6,6 @@ export const userProfileActiveDevicesMessages = { mobileDevice: 'Mobile device', webBrowser: 'Web browser', lastSeen: 'Last seen {date}', - signOutError: 'This device is no longer available. Please try again.', otherDevice: '{count} other device', otherDevices: '{count} other devices', signOutAll: 'Sign out of all devices', diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 9efc9041a00..4219fed3d4e 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -1,6 +1,7 @@ export const errorMessages: { readonly generic: string } & Readonly> = { generic: 'Something went wrong. Please try again.', action_blocked: "This action couldn't be completed. Please try again later or contact support if this persists.", + active_device_unavailable: 'This device is no longer available. Please try again.', avatar_file_count_exceeded: 'Only one file can be uploaded at a time.', avatar_file_size_exceeded: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', avatar_file_type_invalid: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', From b2e0fdba828742ce9199605c0c3abd3bb575c845 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 20:32:31 -0600 Subject: [PATCH 23/26] refactor(mosaic): keep shared focus hook changes outside active devices --- packages/mosaic/AGENTS.md | 4 -- packages/mosaic/docs/views.md | 33 +++++---- ...ofile-active-devices-section.controller.ts | 7 +- ...er-profile-active-devices-section.model.ts | 3 +- ...er-profile-active-devices-section.view.tsx | 2 +- .../__tests__/use-list-removal-focus.test.tsx | 69 ------------------- .../src/hooks/use-list-removal-focus.ts | 30 +++----- 7 files changed, 30 insertions(+), 118 deletions(-) delete mode 100644 packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index c7670131c37..16abbc46736 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -84,10 +84,6 @@ Docs say what the code cannot: rules, reasons, and which tool fits which problem A PR that adds, renames or changes the signature of a shared helper in `src/hooks/`, `src/utils/`, `src/blocks/`, `src/components/form/` or `src/machine/` updates the table above, and any doc that names it, in the same PR. -## Reuse - -Use `useListRemovalFocus` (`src/hooks/use-list-removal-focus.ts`) when removing a list row also removes its dialog trigger. Pass the row ids, `onRemove`, and a surviving fallback control. Return `false` from `onRemove` to skip removal tracking and replacement focus for that action, such as when its result is stale. See [Views](../../.claude/skills/mosaic/references/views.md) for the wiring. - ## Lint Mosaic runs stricter lint rules than the rest of the repo (the `packages/mosaic` block in `eslint.config.mjs`). diff --git a/packages/mosaic/docs/views.md b/packages/mosaic/docs/views.md index c8f5bfecb3d..9603b61bf07 100644 --- a/packages/mosaic/docs/views.md +++ b/packages/mosaic/docs/views.md @@ -106,33 +106,32 @@ opened the dialog unmounted with the menu, and a dialog mounted at the section rather than inside the menu has no floating-tree ancestor to walk back to. A keyboard user loses their place mid-list and a screen reader announces nothing. -Use `useListRemovalFocus` (`packages/mosaic/src/hooks/use-list-removal-focus.ts`) -to hand focus to a surviving element. Pass `finalFocus` to `Dialog.Popup` or -`Confirmation`. The dialog calls it when it closes, after the row has gone: +Hand focus to a surviving element with `useListRemovalFocus` (`hooks/`). It +records which row was removed and, through `finalFocus`, picks the row that took +its place, the last row when it was the last, or `fallback()` once the list is +empty. `finalFocus` on `Dialog.Popup` and on the `Confirmation` block takes a +function resolved when the dialog closes, after the row has gone: ```tsx -const fallbackTrigger = useRef(null); const removalFocus = useListRemovalFocus({ - ids: rows.map(row => row.id), + ids: methods.map(method => method.id), onRemove, - fallback: () => fallbackTrigger.current, + fallback: () => section.current?.querySelector('button:not([disabled])') ?? section.current, }); -triggerRef={removalFocus.registerTrigger(row.id)} +// each row's trigger +triggerRef={removalFocus.registerTrigger(method.id)} + +// the dialog finalFocus={removalFocus.finalFocus} -onConfirm={row => removalFocus.remove(row.id)} +onConfirm={method => removalFocus.remove(method.id)} ``` -When `onRemove` resolves to `void` or `true`, the hook records the row and its -index. `finalFocus` then picks the next row's registered trigger, the previous -row's trigger if the removed row was last, or `fallback()` if the chosen -trigger is unavailable. Attach `fallbackTrigger` to a control that outlives the list. +A cancelled or failed removal keeps its own trigger, so `finalFocus` returns +`null` and the dialog's default (the trigger) applies. -When `onRemove` resolves to `false`, the hook does not record that attempt or -choose replacement focus for it. A rejection also leaves that attempt -unrecorded; let the existing error owner handle it. If no earlier removal is -recorded, `finalFocus` returns `null` and the dialog's default focus behavior -applies. +Prefer the row that took the removed one's place, the last row when it was the +last, and a control that outlives the list once it is empty. Test the removal, not just the cancel: `toHaveFocus()` on the row that should have caught it. A suite that only asserts focus after cancelling passes while diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.controller.ts index 93091f42ffe..f9e10a6f93d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.controller.ts @@ -10,11 +10,8 @@ export function useUserProfileActiveDevicesController( return { devices: model.devices.filter(device => !removedIds.includes(device.id)), onSignOutDevice: async (id: string) => { - const removed = await model.revoke(id); - if (removed) { - setRemovedIds(ids => [...ids, id]); - } - return removed; + await model.revoke(id); + setRemovedIds(ids => [...ids, id]); }, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts index 71391033e07..39cc1a0202d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts @@ -15,7 +15,7 @@ export type UserProfileActiveDevicesModel = status: 'ready'; identity: string; devices: UserProfileDevice[]; - revoke: (id: string) => Promise; + revoke: (id: string) => Promise; }; type SessionsQuery = @@ -121,7 +121,6 @@ export function useUserProfileActiveDevicesModel(): UserProfileActiveDevicesMode throw new ClerkRuntimeError('This device is no longer available.', { code: 'active_device_unavailable' }); } await target.revoke(); - return clerk.user?.id === userId && clerk.session?.id === sessionId; }, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx index ace04e57e3c..7a48f1330ae 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx @@ -22,7 +22,7 @@ export type { UserProfileDevice }; export interface UserProfileActiveDevicesSectionViewProps { devices: UserProfileDevice[]; - onSignOutDevice?: (id: string) => void | boolean | Promise; + onSignOutDevice?: (id: string) => void | Promise; onSignOutAllOtherDevices?: () => void | Promise; } diff --git a/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx b/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx deleted file mode 100644 index 2e84fcc867c..00000000000 --- a/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useLayoutEffect, useRef, useState } from 'react'; -import { expect, it } from 'vitest'; - -import { useListRemovalFocus } from '../use-list-removal-focus'; - -function RestoreFocus({ target }: { target: () => HTMLElement | null }) { - const targetRef = useRef(target); - useLayoutEffect(() => { - targetRef.current = target; - }); - useLayoutEffect( - () => () => { - const element = targetRef.current(); - queueMicrotask(() => element?.focus()); - }, - [], - ); - return null; -} - -function Example() { - const [ids, setIds] = useState(['removed', 'next']); - const [open, setOpen] = useState(true); - const fallback = useRef(null); - const removalFocus = useListRemovalFocus({ - ids, - onRemove: id => setIds(current => current.filter(item => item !== id)), - fallback: () => fallback.current, - }); - - return ( - <> - - {ids.map(id => ( - - ))} -
{open ? : null}
- - - ); -} - -it('focuses the next row when removal and dialog cleanup share a commit', async () => { - render(); - await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(screen.getByRole('button', { name: 'next' })).toHaveFocus(), { timeout: 1000 }); - expect(screen.queryByRole('button', { name: 'removed' })).not.toBeInTheDocument(); -}); diff --git a/packages/mosaic/src/hooks/use-list-removal-focus.ts b/packages/mosaic/src/hooks/use-list-removal-focus.ts index 82cca1944d0..1c38c6ae607 100644 --- a/packages/mosaic/src/hooks/use-list-removal-focus.ts +++ b/packages/mosaic/src/hooks/use-list-removal-focus.ts @@ -6,27 +6,18 @@ export function useListRemovalFocus({ fallback, }: { ids: string[]; - onRemove?: (id: string) => void | boolean | Promise; + onRemove?: (id: string) => void | Promise; fallback: () => HTMLElement | null; }) { - const triggers = useRef( - new Map void }>(), - ); + const triggers = useRef(new Map()); const removed = useRef<{ id: string; index: number } | undefined>(undefined); - const registerTrigger = (id: string) => { - const trigger = triggers.current.get(id); - if (trigger) { - return trigger.ref; + const registerTrigger = (id: string) => (element: HTMLButtonElement | null) => { + if (element) { + triggers.current.set(id, element); + } else { + triggers.current.delete(id); } - const ref = (element: HTMLButtonElement | null) => { - if (element) { - triggers.current.set(id, { element, ref }); - } else { - triggers.current.delete(id); - } - }; - return ref; }; const remove = async (id: string) => { @@ -34,9 +25,8 @@ export function useListRemovalFocus({ return; } const index = ids.indexOf(id); - if ((await onRemove(id)) !== false) { - removed.current = { id, index }; - } + await onRemove(id); + removed.current = { id, index }; }; const finalFocus = () => { @@ -47,7 +37,7 @@ export function useListRemovalFocus({ } const remaining = ids.filter(id => id !== item.id); const next = remaining[Math.min(item.index, remaining.length - 1)]; - return (next ? triggers.current.get(next)?.element : undefined) ?? fallback(); + return (next ? triggers.current.get(next) : undefined) ?? fallback(); }; return { registerTrigger, remove, finalFocus }; From a32ff9f4704f1b2f3308d1637fe842f378bd2194 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 21:05:25 -0600 Subject: [PATCH 24/26] test(mosaic): scope active device feature coverage --- .../user-profile-device-actions.test.tsx | 175 ++++++++++ ...le-active-devices-section.feature.test.tsx | 303 ++++-------------- ...ices-section.guard-errors.feature.test.tsx | 78 ----- ...vices-section.view.errors.feature.test.tsx | 44 --- 4 files changed, 233 insertions(+), 367 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx new file mode 100644 index 00000000000..35a94b3a722 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx @@ -0,0 +1,175 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../mosaic-provider'; +import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices.types'; +import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; + +const current: UserProfileDevice = { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, +}; + +const mobile: UserProfileDevice = { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', +}; + +async function openMenu(user: ReturnType, device: UserProfileDevice) { + await user.click(screen.getByRole('button', { name: `Manage ${device.name}` })); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('signing out of all other devices', () => { + function renderAll(onSignOutAllOtherDevices: () => void | Promise, devices = [current, mobile]) { + return render( + + + , + ); + } + + const confirmation = () => screen.getByRole('alertdialog'); + + it('confirms first, naming how many devices it covers', async () => { + const user = userEvent.setup(); + const onSignOutAllOtherDevices = vi.fn(); + renderAll(onSignOutAllOtherDevices, [current, mobile, { id: 'desktop', name: 'Clerk App', type: 'desktop' }]); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + + expect(within(confirmation()).getByText(/2 other devices will be signed out/)).toBeInTheDocument(); + expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); + + await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); + expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce(); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('leaves the devices alone when the confirmation is cancelled', async () => { + const user = userEvent.setup(); + const onSignOutAllOtherDevices = vi.fn(); + renderAll(onSignOutAllOtherDevices); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + await user.click(within(confirmation()).getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Sign out of all devices' })).toHaveFocus(); + }); + + it('holds the confirmation open and explains a failure', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const user = userEvent.setup(); + const onSignOutAllOtherDevices = vi + .fn() + .mockRejectedValueOnce(new Error('Cannot read properties of undefined')) + .mockResolvedValue(undefined); + renderAll(onSignOutAllOtherDevices); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent( + 'Something went wrong signing these devices out. Please try again.', + ), + ); + expect(confirmation()).toBeInTheDocument(); + + await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(2); + }); + + it('ignores a second press while one is in flight', async () => { + const user = userEvent.setup(); + const signOutAll = createDeferredPromise(); + const onSignOutAllOtherDevices = vi.fn(() => signOutAll.promise); + renderAll(onSignOutAllOtherDevices); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + const confirm = within(confirmation()).getByRole('button', { name: 'Sign out' }); + await user.click(confirm); + await waitFor(() => expect(confirm).toHaveAttribute('aria-busy')); + await user.click(confirm); + + expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(1); + + await act(async () => { + signOutAll.resolve(); + await signOutAll.promise; + }); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('hands focus to the current device once the others are gone', async () => { + const user = userEvent.setup(); + function Example() { + const [devices, setDevices] = useState([current, mobile]); + return ( + + setDevices(list => list.filter(device => device.isCurrent))} + /> + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).not.toBeInTheDocument(), + ); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus()); + }); +}); + +describe('focus after a delayed row update', () => { + const desktop: UserProfileDevice = { id: 'desktop', name: 'Clerk App on macOS', type: 'desktop' }; + + it('skips the signed-out row when the list only catches up later', async () => { + const user = userEvent.setup(); + const catchUp = createDeferredPromise(); + function LateExample() { + const [devices, setDevices] = useState([current, mobile, desktop]); + return ( + + { + void catchUp.promise.then(() => setDevices(list => list.filter(device => device.id !== id))); + return Promise.resolve(); + }} + /> + + ); + } + render(); + await openMenu(user, mobile); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); + + await act(async () => { + catchUp.resolve(); + await catchUp.promise; + }); + expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx index fb04bb00a6b..4de9810a759 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx @@ -3,7 +3,6 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; -import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { @@ -18,9 +17,7 @@ import { renderWithClerk } from '../../../__tests__/feature/render'; import { MosaicNowProvider } from '../../../hooks/use-now'; import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; -import type { UserProfileDevice } from './user-profile-active-devices.types'; import { UserProfileActiveDevicesSection } from './user-profile-active-devices-section'; -import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view'; const alice = fapiUser({ id: 'user_1' }); @@ -120,11 +117,7 @@ describe('Active devices', () => { }, { ...device('sess_expired', 'active', { device_type: 'Expired laptop' }), expire_at: 1 }, ]); - const { clerk } = await renderWithClerk( - } />, - ); - - expect(await clerk.user?.getSessions()).toHaveLength(3); + await renderWithClerk(} />); expect(await screen.findByRole('button', { name: 'Manage Safari on MacBook Pro' })).toBeInTheDocument(); expect(screen.getByText('This device')).toBeVisible(); @@ -165,24 +158,19 @@ describe('Active devices', () => { expect(screen.queryByRole('button', { name: 'Manage Safari on Expired laptop' })).toBeNull(); }); - it('preserves the SDK empty-list behavior after a failed device request', async () => { + it('shows an empty state after a failed device request', async () => { serveDevices([device('sess_current', 'active')]); - let failing = true; worker.use( http.get(fapiUrl('/v1/me/sessions/active'), () => - failing - ? HttpResponse.json({ errors: [{ code: 'internal_clerk_error', message: 'Unavailable' }] }, { status: 500 }) - : HttpResponse.json([device('sess_current', 'active')]), + HttpResponse.json({ errors: [{ code: 'internal_clerk_error', message: 'Unavailable' }] }, { status: 500 }), ), ); - const view = await renderWithClerk(); + await renderWithClerk(); expect(await screen.findByText('No current device available')).toBeVisible(); expect(screen.queryByRole('alert')).toBeNull(); expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull(); - failing = false; - await expect(view.clerk.user?.getSessions()).resolves.toEqual([]); expect(screen.queryByText('This device')).toBeNull(); }); @@ -242,32 +230,6 @@ describe('Active devices', () => { expect(screen.queryByText('Paris, France')).toBeNull(); }); - it('rejects backend-forbidden revokes through session resources', async () => { - const current = device('sess_current', 'active'); - const foreign = { ...device('sess_foreign', 'active'), ownerUserId: 'user_2' }; - const revoked = device('sess_revoked', 'revoked'); - const unknown = device('sess_unknown', 'active'); - serveDevices([current, foreign, revoked]); - worker.use( - http.get(fapiUrl('/v1/me/sessions/active'), () => HttpResponse.json([current, foreign, revoked, unknown])), - ); - const { clerk } = await renderWithClerk(); - const sessions = await clerk.user?.getSessions(); - - for (const [id, code] of [ - ['sess_current', 'invalid_action_for_session'], - ['sess_foreign', 'action_for_session_not_authorized'], - ['sess_revoked', 'invalid_action_for_session'], - ['sess_unknown', 'action_for_session_not_authorized'], - ]) { - const session = sessions?.find(item => item.id === id); - if (!session) { - throw new Error(`Missing session resource ${id}`); - } - await expect(session.revoke()).rejects.toMatchObject({ errors: [{ code }] }); - } - }); - it('keeps a device after a failed revoke and allows retrying', async () => { const devices = serveDevices( [device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })], @@ -687,214 +649,13 @@ describe('Active devices', () => { removeListener?.(); } }); +}); +describe('Deferred active-device actions', () => { it.todo('signs out every other eligible device after confirmation while preserving the current session'); it.todo('reverifies device revocation before retrying verification-required API errors'); }); -const currentViewDevice: UserProfileDevice = { - id: 'current', - name: 'Safari on macOS', - description: 'Salt Lake City, UT, United States', - type: 'desktop', - isCurrent: true, -}; - -const mobileViewDevice: UserProfileDevice = { - id: 'mobile', - name: 'Safari on iOS', - description: 'Last seen 2 weeks ago · Orem, UT, United States', - type: 'mobile', - lastActive: '4 days ago', - model: 'iPhone 16 Pro', - browser: 'Safari 18.4', - ipAddress: '2600:100e:b10b:787b:e8ae:6e75', - location: 'Orem, UT, United States', - signedInAt: 'July 5th, 2026', -}; - -async function renderDevices(onSignOutDevice?: (id: string) => void | Promise) { - serveDevices([device('sess_current', 'active')]); - return renderWithClerk( - , - ); -} - -async function openMenu(user: ReturnType, item: UserProfileDevice) { - await user.click(screen.getByRole('button', { name: `Manage ${item.name}` })); -} - -describe('active devices view contract', () => { - it('renders every provided device detail field', async () => { - const user = userEvent.setup(); - await renderDevices(); - await openMenu(user, mobileViewDevice); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - - const dialog = screen.getByRole('dialog'); - expect(within(dialog).getByRole('heading', { name: 'Safari on iOS' })).toBeInTheDocument(); - expect(within(dialog).getByText('Last active 4 days ago')).toBeInTheDocument(); - expect(within(dialog).getByText('iPhone 16 Pro')).toBeInTheDocument(); - expect(within(dialog).getByText('2600:100e:b10b:787b:e8ae:6e75')).toBeInTheDocument(); - expect(within(dialog).getByText('July 5th, 2026')).toBeInTheDocument(); - }); - - it('omits the rows a device has no detail for', async () => { - const user = userEvent.setup(); - await renderDevices(); - await openMenu(user, currentViewDevice); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - - expect(within(screen.getByRole('dialog')).queryByText('Browser')).not.toBeInTheDocument(); - }); - - describe('signing out of all other devices', () => { - async function renderAll( - onSignOutAllOtherDevices: () => void | Promise, - devices = [currentViewDevice, mobileViewDevice], - ) { - serveDevices([device('sess_current', 'active')]); - return renderWithClerk( - , - ); - } - - const confirmation = () => screen.getByRole('alertdialog'); - - it('confirms first, naming how many devices it covers', async () => { - const user = userEvent.setup(); - const onSignOutAllOtherDevices = vi.fn(); - await renderAll(onSignOutAllOtherDevices, [ - currentViewDevice, - mobileViewDevice, - { id: 'desktop', name: 'Clerk App', type: 'desktop' }, - ]); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - - expect(within(confirmation()).getByText(/2 other devices will be signed out/)).toBeInTheDocument(); - expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); - - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce(); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('leaves the devices alone when the confirmation is cancelled', async () => { - const user = userEvent.setup(); - const onSignOutAllOtherDevices = vi.fn(); - await renderAll(onSignOutAllOtherDevices); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - await user.click(within(confirmation()).getByRole('button', { name: 'Cancel' })); - - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Sign out of all devices' })).toHaveFocus(); - }); - - it('holds the confirmation open and explains a failure', async () => { - const user = userEvent.setup(); - const onSignOutAllOtherDevices = vi - .fn() - .mockRejectedValueOnce(new Error('Unable to sign out of all devices')) - .mockResolvedValue(undefined); - await renderAll(onSignOutAllOtherDevices); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - - expect( - await screen.findByText('Something went wrong signing these devices out. Please try again.'), - ).toBeInTheDocument(); - expect(confirmation()).toBeInTheDocument(); - - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(2); - }); - - it('ignores a second press while one is in flight', async () => { - const user = userEvent.setup(); - const signOutAll = createDeferredPromise(); - const onSignOutAllOtherDevices = vi.fn(() => signOutAll.promise); - await renderAll(onSignOutAllOtherDevices); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - const confirm = within(confirmation()).getByRole('button', { name: 'Sign out' }); - await user.click(confirm); - await waitFor(() => expect(confirm).toHaveAttribute('aria-busy')); - expect(confirm).toHaveAttribute('aria-disabled', 'true'); - act(() => confirm.click()); - expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(1); - - await act(async () => { - signOutAll.resolve(); - await signOutAll.promise; - }); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('hands focus to the current device once the others are gone', async () => { - const user = userEvent.setup(); - function Example() { - const [devices, setDevices] = useState([currentViewDevice, mobileViewDevice]); - return ( - setDevices(list => list.filter(device => device.isCurrent))} - /> - ); - } - serveDevices([device('sess_current', 'active')]); - await renderWithClerk(); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => - expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).not.toBeInTheDocument(), - ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus()); - }); - }); - - describe('focus after a delayed row update', () => { - const desktop: UserProfileDevice = { id: 'desktop', name: 'Clerk App on macOS', type: 'desktop' }; - it('skips the signed-out row when the list only catches up later', async () => { - const user = userEvent.setup(); - const catchUp = createDeferredPromise(); - function LateExample() { - const [devices, setDevices] = useState([currentViewDevice, mobileViewDevice, desktop]); - return ( - { - void catchUp.promise.then(() => setDevices(list => list.filter(device => device.id !== id))); - return Promise.resolve(); - }} - /> - ); - } - serveDevices([device('sess_current', 'active')]); - await renderWithClerk(); - await openMenu(user, mobileViewDevice); - await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus()); - - await act(async () => { - catchUp.resolve(); - await catchUp.promise; - }); - expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus(); - }); - }); -}); - describe('active devices focus after connected revocation', () => { it.each(['confirmation', 'details'])('hands focus to the next row after signing out from %s', async surface => { serveDevices([ @@ -943,3 +704,55 @@ describe('active devices focus after connected revocation', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iPhone' })).toHaveFocus()); }); }); + +describe('Sign out an unavailable device', () => { + it.each([ + ['en-US', 'This device is no longer available. Please try again.'], + ['fr-FR', 'Cet appareil est indisponible.'], + ])('shows the catalog message in %s', async (locale, expectedMessage) => { + serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'Phone' })]); + const view = await renderWithClerk( + + + , + ); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' })); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible()); + + const currentUser = view.clerk.user; + if (!currentUser) { + throw new Error('Expected a signed-in user'); + } + const target = (await currentUser.getSessions()).find(session => session.id === 'sess_other'); + if (!target) { + throw new Error('Expected another device'); + } + await act(async () => { + await target.revoke(); + }); + + const logError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); + + await waitFor(() => + expect(within(screen.getByRole('dialog')).getByRole('alert')).toHaveTextContent(expectedMessage), + ); + expect(screen.getByRole('dialog')).toBeVisible(); + expect(screen.queryByText('Unexpected device failure.')).toBeNull(); + expect(logError).not.toHaveBeenCalled(); + } finally { + logError.mockRestore(); + } + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx deleted file mode 100644 index 74bc79e363d..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.guard-errors.feature.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { act, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { type ActiveDeviceRecord, serveFapi } from '../../../__tests__/feature/fake-fapi'; -import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; -import { renderWithClerk } from '../../../__tests__/feature/render'; -import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; -import { UserProfileActiveDevicesSection } from './user-profile-active-devices-section'; - -describe('Sign out an unavailable device', () => { - it.each([ - ['en-US', 'This device is no longer available. Please try again.'], - ['fr-FR', 'Cet appareil est indisponible.'], - ])('shows the catalog message in %s', async (locale, expectedMessage) => { - const alice = fapiUser({ id: 'user_1' }); - serveFapi({ - client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]), - activeDevices: ['sess_current', 'sess_other'].map(id => { - const { user: _owner, ...session } = fapiSession({ id, user: alice }); - return { - ...session, - user: null, - ownerUserId: alice.id, - latest_activity: { - object: 'session_activity', - id: `activity_${id}`, - browser_name: 'Safari', - device_type: id === 'sess_other' ? 'Phone' : 'Laptop', - }, - }; - }), - }); - const view = await renderWithClerk( - - - , - ); - const user = userEvent.setup(); - await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' })); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible()); - - const currentUser = view.clerk.user; - if (!currentUser) { - throw new Error('Expected a signed-in user'); - } - const target = (await currentUser.getSessions()).find(session => session.id === 'sess_other'); - if (!target) { - throw new Error('Expected another device'); - } - await act(async () => { - await target.revoke(); - }); - - const logError = vi.spyOn(console, 'error').mockImplementation(() => {}); - try { - await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => - expect(within(screen.getByRole('dialog')).getByRole('alert')).toHaveTextContent(expectedMessage), - ); - expect(screen.getByRole('dialog')).toBeVisible(); - expect(screen.queryByText('Unexpected device failure.')).toBeNull(); - expect(logError).not.toHaveBeenCalled(); - } finally { - logError.mockRestore(); - } - }); -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx deleted file mode 100644 index ef1e6f9aa31..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.errors.feature.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; - -import { clerkApiError } from '../../../__tests__/clerk-errors'; -import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; -import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view'; - -describe.each(['confirmation', 'details'] as const)('device %s errors', surface => { - it.each(['private', 'clerk'] as const)('renders a safe localized message for a %s error', async kind => { - const user = userEvent.setup(); - const cause = - kind === 'private' - ? new Error('Private implementation details') - : clerkApiError('invalid_action_for_session', 'Backend device message'); - const view = render( - - Promise.reject(cause)} - /> - , - ); - await user.click(screen.getByRole('button', { name: 'Manage Phone' })); - await user.click(screen.getByRole('menuitem', { name: surface === 'details' ? 'View details' : 'Sign out' })); - const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog'); - await waitFor(() => expect(dialog).toBeVisible()); - await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); - const expected = kind === 'clerk' ? 'Cet appareil est indisponible.' : 'Déconnexion impossible.'; - await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent(expected)); - await waitFor(() => expect(dialog).toBeVisible()); - expect(screen.queryByText('Private implementation details')).toBeNull(); - expect(within(dialog).getByRole('button', { name: 'Sign out' })).not.toHaveAttribute('aria-busy', 'true'); - view.unmount(); - }); -}); From d65f4b79ab292bbcaaff9e8e3b85dee97217d4ca Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 7 Oct 2026 21:59:15 -0600 Subject: [PATCH 25/26] test(mosaic): keep active device scenarios together --- ...le-active-devices-section.feature.test.tsx | 89 +++++++++++++++ ...ices-section.localization.feature.test.tsx | 108 ------------------ 2 files changed, 89 insertions(+), 108 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx index 4de9810a759..62bbfbdc1f3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx @@ -651,6 +651,95 @@ describe('Active devices', () => { }); }); +describe('Change language while viewing device details', () => { + function section(locale: string) { + return ( + + + + + + ); + } + + it.each(['idle', 'pending'] as const)('updates open device details after a locale change while %s', async state => { + serveFapi({ + client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]), + activeDevices: [ + { + ...device('sess_current', 'active', { device_type: 'Laptop', city: undefined, country: undefined }), + last_active_at: new Date(2025, 5, 14, 12).getTime(), + }, + { + ...device('sess_other', 'active', { device_type: 'Phone', city: undefined, country: undefined }), + last_active_at: new Date(2025, 5, 14, 12).getTime(), + }, + ], + }); + const view = await renderWithClerk(section('en-US')); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' })); + await user.click(screen.getByRole('menuitem', { name: 'View details' })); + const dialog = screen.getByRole('dialog'); + await waitFor(() => expect(dialog).toBeVisible()); + expect(within(dialog).getByText('Last active yesterday')).toBeVisible(); + const revoke = state === 'pending' ? holdRequests('post', '/v1/me/sessions/sess_other/revoke') : undefined; + + try { + if (revoke) { + await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); + await waitFor(() => expect(revoke.requests).toHaveLength(1)); + await waitFor(() => + expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })).toHaveAttribute( + 'aria-busy', + 'true', + ), + ); + } + + view.rerender(section('fr-FR')); + + expect(screen.getAllByText('Vu hier')).toHaveLength(2); + const translatedDialog = screen.getByRole('dialog'); + expect(translatedDialog).toHaveTextContent('Dernière activité hier'); + expect(within(translatedDialog).getByRole('heading', { name: 'Safari sur Phone' })).toBeVisible(); + if (revoke) { + expect(within(translatedDialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute( + 'aria-busy', + 'true', + ); + await user.keyboard('{Escape}'); + const stillOpenDialog = screen.getByRole('dialog'); + expect(stillOpenDialog).toBeVisible(); + expect(within(stillOpenDialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute( + 'aria-busy', + 'true', + ); + } + } finally { + revoke?.release(); + if (revoke) { + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage Safari sur Phone' })).toBeNull()); + } + } + }); +}); + describe('Deferred active-device actions', () => { it.todo('signs out every other eligible device after confirmation while preserving the current session'); it.todo('reverifies device revocation before retrying verification-required API errors'); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx deleted file mode 100644 index 212527dd8df..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.localization.feature.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; - -import { type ActiveDeviceRecord, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; -import { fapiClient, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; -import { renderWithClerk } from '../../../__tests__/feature/render'; -import { MosaicNowProvider } from '../../../hooks/use-now'; -import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; -import { UserProfileActiveDevicesSection } from './user-profile-active-devices-section'; - -const alice = fapiUser({ id: 'user_1' }); - -function device(id: string, deviceType: string): ActiveDeviceRecord { - const { user: _owner, ...session } = fapiSession({ id, user: alice }); - return { - ...session, - user: null, - ownerUserId: alice.id, - last_active_at: new Date(2025, 5, 14, 12).getTime(), - latest_activity: { - object: 'session_activity', - id: `activity_${id}`, - browser_name: 'Safari', - device_type: deviceType, - }, - }; -} - -function section(locale: string) { - return ( - - - - - - ); -} - -describe('Change language while viewing device details', () => { - it.each(['idle', 'pending'] as const)('updates open device details after a locale change while %s', async state => { - serveFapi({ - client: fapiClient([fapiSession({ id: 'sess_current', user: alice })]), - activeDevices: [device('sess_current', 'Laptop'), device('sess_other', 'Phone')], - }); - const view = await renderWithClerk(section('en-US')); - const user = userEvent.setup(); - await user.click(await screen.findByRole('button', { name: 'Manage Safari on Phone' })); - await user.click(screen.getByRole('menuitem', { name: 'View details' })); - const dialog = screen.getByRole('dialog'); - await waitFor(() => expect(dialog).toBeVisible()); - expect(within(dialog).getByText('Last active yesterday')).toBeVisible(); - const revoke = state === 'pending' ? holdRequests('post', '/v1/me/sessions/sess_other/revoke') : undefined; - - try { - if (revoke) { - await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); - await waitFor(() => expect(revoke.requests).toHaveLength(1)); - await waitFor(() => - expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' })).toHaveAttribute( - 'aria-busy', - 'true', - ), - ); - } - - view.rerender(section('fr-FR')); - - expect(screen.getAllByText('Vu hier')).toHaveLength(2); - const translatedDialog = screen.getByRole('dialog'); - expect(translatedDialog).toHaveTextContent('Dernière activité hier'); - expect(within(translatedDialog).getByRole('heading', { name: 'Safari sur Phone' })).toBeVisible(); - if (revoke) { - expect(within(translatedDialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute( - 'aria-busy', - 'true', - ); - await user.keyboard('{Escape}'); - const stillOpenDialog = screen.getByRole('dialog'); - expect(stillOpenDialog).toBeVisible(); - expect(within(stillOpenDialog).getByRole('button', { name: 'Déconnecter' })).toHaveAttribute( - 'aria-busy', - 'true', - ); - } - } finally { - revoke?.release(); - if (revoke) { - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage Safari sur Phone' })).toBeNull()); - } - } - }); -}); From cdfe5cf5f48e458ff54bb35ebaa3763d171204b1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 12:51:15 -0600 Subject: [PATCH 26/26] test(mosaic): remove duplicate active-device focus coverage --- ...file-active-devices-section.feature.test.tsx | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx index 62bbfbdc1f3..bdd5e4e77d3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx @@ -746,23 +746,6 @@ describe('Deferred active-device actions', () => { }); describe('active devices focus after connected revocation', () => { - it.each(['confirmation', 'details'])('hands focus to the next row after signing out from %s', async surface => { - serveDevices([ - device('sess_current', 'active'), - device('sess_other', 'active', { device_type: 'iPhone' }), - device('sess_next', 'active', { device_type: 'Next laptop' }), - ]); - await renderWithClerk(); - const user = userEvent.setup(); - await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' })); - await user.click(screen.getByRole('menuitem', { name: surface === 'details' ? 'View details' : 'Sign out' })); - const dialog = screen.getByRole(surface === 'details' ? 'dialog' : 'alertdialog'); - await user.click(within(dialog).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on Next laptop' })).toHaveFocus()); - expect(screen.queryByRole('button', { name: 'Manage Safari on iPhone' })).toBeNull(); - }); - it('falls back to the previous row, then the current device', async () => { serveDevices([ device('sess_current', 'active'),