Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d7da276
feat(mosaic): wire up user profile active devices
austincalvelage Sep 30, 2026
33faa81
refactor(mosaic): compose active devices through a panel slot
austincalvelage Sep 30, 2026
8b24c1a
fix(mosaic): defer active-device reverification
austincalvelage Sep 30, 2026
6abf564
fix(mosaic): preserve active device state and restore retries
austincalvelage Oct 2, 2026
c76ed26
fix(swingset): add active devices live flow
austincalvelage Oct 2, 2026
54365fa
chore(mosaic): keep active-device deferrals in feature tests
austincalvelage Oct 3, 2026
2c1aa5f
test(mosaic): await rendered device revoke errors
austincalvelage Oct 3, 2026
c04c68a
refactor(mosaic): group active device section files
austincalvelage Oct 6, 2026
60f1b28
test(mosaic): consolidate active device behavior coverage
austincalvelage Oct 6, 2026
c22e835
fix(mosaic): keep focus on the next row after removal
austincalvelage Oct 6, 2026
4d759b3
fix(mosaic): preserve active device error descriptions
austincalvelage Oct 6, 2026
ab37ddc
test(mosaic): wait for device dialog visibility
austincalvelage Oct 6, 2026
88dd0f3
chore: keep one empty changeset
austincalvelage Oct 6, 2026
d6b3652
fix(mosaic): simplify active devices sign-out actions
austincalvelage Oct 7, 2026
0ca1f20
test(mosaic): compose passkeys with active devices slot
austincalvelage Oct 7, 2026
e1fe4dd
refactor(mosaic): reuse clock and confirmation hooks for active devices
austincalvelage Oct 7, 2026
d589b9d
refactor(mosaic): use the existing SDK API for active devices
austincalvelage Oct 8, 2026
ee3b2bc
test(mosaic): reproduce stale device details after locale changes
austincalvelage Oct 8, 2026
ae96645
fix(mosaic): refresh open device details when the locale changes
austincalvelage Oct 8, 2026
ddf3725
test(mosaic): align active device tests with feature conventions
austincalvelage Oct 8, 2026
b570105
test(mosaic): reproduce unavailable device guard error fallback
austincalvelage Oct 8, 2026
d8d069f
fix(mosaic): localize rejected device actions and document removal focus
austincalvelage Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/clear-active-devices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
47 changes: 23 additions & 24 deletions .claude/skills/mosaic/references/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,34 +94,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. `finalFocus` on `Dialog.Popup` and on the
`Confirmation` block takes a function, resolved when the dialog closes — which is
after the row has gone, so it can pick from what is left:
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 triggers = useRef(new Map<string, HTMLButtonElement>());
const removed = useRef<number | undefined>(undefined);

const removeRow = async (row: Row) => {
const index = rows.findIndex(candidate => candidate.id === row.id);
await onRemove(row.id);
// Only once it is really gone: a cancelled or failed attempt keeps its own trigger.
removed.current = index;
};

const focusAfterRemove = () => {
const index = removed.current;
removed.current = undefined;
if (index === undefined) {
return null; // null keeps the default — the trigger, which is still there
}
const next = rows[Math.min(index, rows.length - 1)] ?? anchorRow;
return (next && triggers.current.get(next.id)) ?? null;
};
const fallbackTrigger = useRef<HTMLButtonElement>(null);
const removalFocus = useListRemovalFocus({
ids: rows.map(row => row.id),
onRemove,
fallback: () => fallbackTrigger.current,
});

triggerRef={removalFocus.registerTrigger(row.id)}
finalFocus={removalFocus.finalFocus}
onConfirm={row => removalFocus.remove(row.id)}
```

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 `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.

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
Expand Down
4 changes: 4 additions & 0 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

`@clerk/mosaic` rules. The repo root `AGENTS.md` still applies; for architecture see `references/mosaic-architecture.md`.

## 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`).
Expand Down
89 changes: 89 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
OrganizationMembershipJSON,
OrganizationSuggestionJSON,
SessionJSON,
SessionWithActivitiesJSON,
UserJSON,
UserOrganizationInvitationJSON,
} from '@clerk/shared/types';
Expand Down Expand Up @@ -51,6 +52,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<Omit<FakeFapiState, 'verification' | 'enterpriseLinking'>> & {
Expand Down Expand Up @@ -121,6 +132,36 @@ function deleteOrganization(state: FakeFapiState, organizationId: string): void
};
}

function requestingSession(state: FakeFapiState, request: Request): SessionJSON | undefined {
const sessionId = new URL(request.url).searchParams.get('_clerk_session_id') ?? state.client.last_active_session_id;
return findSession(state, sessionId);
}

function 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 = {
Expand All @@ -132,6 +173,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
apiKeys: [],
passwordUpdates: [],
enterpriseConnections: [],
deviceTrackingEnabled: true,
...rest,
verification: createVerificationState(verification),
enterpriseLinking: {
Expand Down Expand Up @@ -257,6 +299,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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 })) },
},
},
Expand Down
Loading
Loading