From f153dbb11ca9832d3e46150bbf258b2e58dfa472 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 09:38:08 -0600
Subject: [PATCH 01/10] test(mosaic): model passkey contracts in fake FAPI
---
.changeset/tall-pandas-wait.md | 2 +
.../mosaic/src/__tests__/feature/fake-fapi.ts | 143 ++++++++++++++++++
packages/mosaic/src/__tests__/feature/fapi.ts | 13 ++
3 files changed, 158 insertions(+)
create mode 100644 .changeset/tall-pandas-wait.md
diff --git a/.changeset/tall-pandas-wait.md b/.changeset/tall-pandas-wait.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/tall-pandas-wait.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
index 8dae98cfedd..e2ea07d8cdf 100644
--- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
@@ -6,6 +6,7 @@ import type {
OAuthProvider,
OrganizationMembershipJSON,
OrganizationSuggestionJSON,
+ PasskeyJSON,
SessionJSON,
UserJSON,
UserOrganizationInvitationJSON,
@@ -29,6 +30,7 @@ import {
fapiMembership,
fapiOrganization,
fapiPage,
+ fapiPasskey,
fapiToken,
fapiVerification,
} from './fapi';
@@ -102,6 +104,11 @@ function activeUser(state: FakeFapiState): UserJSON | undefined {
return findSession(state, state.client.last_active_session_id)?.user;
}
+function requestUser(state: FakeFapiState, request: Request): UserJSON | undefined {
+ const sessionId = new URL(request.url).searchParams.get('_clerk_session_id') ?? state.client.last_active_session_id;
+ return findSession(state, sessionId)?.user;
+}
+
function updateUser(state: FakeFapiState, user: UserJSON): void {
state.client = {
...state.client,
@@ -135,6 +142,9 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
},
};
+ const pendingPasskeys = new Map();
+ let nextPasskeyId = 1;
+
worker.use(
...verificationHandlers(state, fapiUrl),
...enterpriseHandlers(state, fapiUrl),
@@ -268,6 +278,139 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
state.client = { ...state.client, sessions: [], last_active_session_id: null };
return envelope(state.client, state.client);
}),
+ http.post(fapiUrl('/v1/me/passkeys'), ({ request }) => {
+ const user = requestUser(state, request);
+ if (!user) {
+ return missing();
+ }
+ if (!state.environment.user_settings.attributes.passkey.enabled) {
+ return error('feature_not_enabled', 403);
+ }
+ if (
+ state.environment.user_settings.enterprise_sso.enabled &&
+ user.enterprise_accounts.some(
+ account =>
+ account.enterprise_connection?.active && account.enterprise_connection.disable_additional_identifications,
+ )
+ ) {
+ return error('enterprise_sso_additional_identifications_disabled', 422);
+ }
+ if (user.passkeys.length >= 10) {
+ return error('passkey_quota_exceeded', 403);
+ }
+ const now = Date.now();
+ for (const [id, pending] of pendingPasskeys) {
+ if (pending.userId === user.id && pending.expiresAt <= now) {
+ pendingPasskeys.delete(id);
+ }
+ }
+ const expiresAt = now + 60000;
+ const passkey = fapiPasskey({
+ id: `passkey_${nextPasskeyId++}`,
+ name: null,
+ verification: {
+ id: 'verification_passkey',
+ object: 'verification',
+ status: 'unverified',
+ verified_at_client: '',
+ strategy: 'passkey',
+ attempts: 0,
+ expire_at: expiresAt,
+ error: { code: '', message: '' },
+ nonce: JSON.stringify({
+ challenge: 'Y2hhbGxlbmdl',
+ rp: { name: 'Acme', id: 'localhost' },
+ user: { id: 'dXNlcg', name: 'user@example.com', displayName: 'Test user' },
+ pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
+ }),
+ },
+ });
+ pendingPasskeys.set(passkey.id, { userId: user.id, passkey, expiresAt });
+ return envelope(passkey, state.client);
+ }),
+ http.post(fapiUrl('/v1/me/passkeys/:id/attempt_verification'), async ({ params, request }) => {
+ const pending = typeof params.id === 'string' ? pendingPasskeys.get(params.id) : undefined;
+ const user = state.client.sessions.find(session => session.user.id === pending?.userId)?.user;
+ if (!requestUser(state, request) || !user || !pending) {
+ return missing();
+ }
+ const { passkey } = pending;
+ const verification = passkey.verification;
+ if (!verification || pending.expiresAt <= Date.now()) {
+ return error('verification_expired', 400);
+ }
+ const body = new URLSearchParams(await request.text());
+ if (body.get('strategy') !== 'passkey' || !body.get('public_key_credential')) {
+ return HttpResponse.json(
+ { errors: [{ code: 'form_param_missing', message: 'Passkey credential required.' }] },
+ { status: 400 },
+ );
+ }
+ const verified = {
+ ...passkey,
+ name: passkey.name ?? 'New passkey',
+ last_used_at: Date.now(),
+ verification: {
+ ...verification,
+ status: 'verified' as const,
+ attempts: verification.attempts + 1,
+ verified_at_client: state.client.id,
+ nonce: undefined,
+ error: undefined,
+ },
+ };
+ pendingPasskeys.delete(passkey.id);
+ updateUser(state, { ...user, passkeys: [...user.passkeys, verified] });
+ return envelope(verified, state.client);
+ }),
+ http.post(fapiUrl('/v1/me/passkeys/:id'), async ({ params, request }) => {
+ const user = requestUser(state, request);
+ if (!user || typeof params.id !== 'string') {
+ return missing();
+ }
+ const pending = pendingPasskeys.get(params.id);
+ const passkey =
+ user.passkeys.find(candidate => candidate.id === params.id) ??
+ (pending?.userId === user.id ? pending.passkey : undefined);
+ if (!passkey) {
+ return missing();
+ }
+ const method = new URL(request.url).searchParams.get('_method');
+ if (method === 'DELETE') {
+ pendingPasskeys.delete(passkey.id);
+ updateUser(state, { ...user, passkeys: user.passkeys.filter(candidate => candidate.id !== passkey.id) });
+ return envelope({ object: 'passkey', id: passkey.id, deleted: true }, state.client);
+ }
+ if (method === 'PATCH') {
+ const body = new URLSearchParams(await request.text());
+ const name = body.get('name');
+ if (name !== null && new TextEncoder().encode(name).length > 256) {
+ return HttpResponse.json(
+ {
+ errors: [
+ {
+ code: 'form_param_max_length_exceeded',
+ message: 'Passkey name is too long.',
+ meta: { param_name: 'name' },
+ },
+ ],
+ },
+ { status: 422 },
+ );
+ }
+ const renamed = { ...passkey, name: name ?? passkey.name };
+ if (pending?.userId === user.id) {
+ pendingPasskeys.set(passkey.id, { ...pending, passkey: renamed });
+ return envelope(renamed, state.client);
+ }
+ updateUser(state, {
+ ...user,
+ passkeys: user.passkeys.map(candidate => (candidate.id === passkey.id ? renamed : candidate)),
+ });
+ return envelope(renamed, state.client);
+ }
+ return missing();
+ }),
http.get(fapiUrl('/v1/me/organization_memberships'), ({ request }) =>
envelope(page(state.memberships, new URL(request.url)), null),
),
diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts
index ae84f69a4e1..0c0744ea63d 100644
--- a/packages/mosaic/src/__tests__/feature/fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fapi.ts
@@ -16,6 +16,7 @@ import type {
OrganizationMembershipJSON,
OrganizationSettingsJSON,
OrganizationSuggestionJSON,
+ PasskeyJSON,
PublicKeyCredentialRequestOptionsJSON,
PublicOrganizationDataJSON,
SessionJSON,
@@ -549,3 +550,15 @@ export function fapiApiKey(overrides: Partial & Pick(data: T[], totalCount = data.length): FapiPage {
return { data, total_count: totalCount };
}
+
+export function fapiPasskey(overrides: Partial & Pick): PasskeyJSON {
+ return {
+ object: 'passkey',
+ name: 'Laptop',
+ verification: null,
+ created_at: createdAt,
+ updated_at: createdAt,
+ last_used_at: null,
+ ...overrides,
+ };
+}
From 7c4fa50fbf00b30f1dc0878db7c48ee28d9ccce8 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 09:39:05 -0600
Subject: [PATCH 02/10] feat(mosaic): wire passkey management to the current
account
---
.changeset/tidy-passkey-dates.md | 5 +
.../user-profile-security-panel.view.test.tsx | 71 +++++--
.../user-profile-passkey-row.view.tsx | 4 +-
.../user-profile-passkeys-section.messages.ts | 11 +
.../user-profile-passkeys-section.view.tsx | 16 +-
...ser-profile-passkeys-section.controller.ts | 55 +++++
...ser-profile-passkeys-section.model.test.ts | 21 ++
.../user-profile-passkeys-section.model.ts | 196 ++++++++++++++++++
.../user-profile-passkeys-section.tsx | 47 +++++
.../user-profile-passkeys-section.types.ts | 12 ++
.../user-profile-rename-passkey.controller.ts | 12 +-
.../user-profile-rename-passkey.dialog.tsx | 3 +-
.../user-profile-security-panel.view.tsx | 30 +--
.../src/hooks/use-mosaic-environment.ts | 6 +-
packages/mosaic/src/localization/registry.ts | 2 +
.../src/app/(clerk)/live/passkeys/page.tsx | 34 +++
packages/swingset/src/lib/live-navigation.ts | 1 +
.../src/stories/fixtures/user-profile.tsx | 7 +-
18 files changed, 467 insertions(+), 66 deletions(-)
create mode 100644 .changeset/tidy-passkey-dates.md
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.test.ts
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.tsx
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.types.ts
create mode 100644 packages/swingset/src/app/(clerk)/live/passkeys/page.tsx
diff --git a/.changeset/tidy-passkey-dates.md b/.changeset/tidy-passkey-dates.md
new file mode 100644
index 00000000000..e006c85181a
--- /dev/null
+++ b/.changeset/tidy-passkey-dates.md
@@ -0,0 +1,5 @@
+---
+'@clerk/mosaic': patch
+---
+
+Connect passkey management to the signed-in user, clear passkey dialogs and feedback when accounts or sessions change, and use the configured locale for passkey errors and dates. Omit the Authentication section when it has no visible methods.
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 cf514d03431..61768becf26 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
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import { useDestructiveController } from '../../../blocks/destructive/destructive.controller';
import { MosaicProvider } from '../../../mosaic-provider';
import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view';
+import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view';
import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
@@ -14,15 +15,17 @@ function DeleteAccount({ onDelete = () => Promise.resolve() }: { onDelete?: () =
return ;
}
+const passkeys = [
+ {
+ id: 'passkey_1',
+ name: 'Passkey',
+ createdAtLabel: 'Created today at 10:12 PM',
+ lastUsedAtLabel: 'Last used 1h ago',
+ },
+];
+
const props: UserProfileSecurityPanelViewProps = {
- passkeys: [
- {
- id: 'passkey_1',
- name: 'Passkey',
- createdAtLabel: 'Created today at 10:12 PM',
- lastUsedAtLabel: 'Last used 1h ago',
- },
- ],
+ passkeysSlot: { content: },
mfaMethods: [
{ id: 'sms_1', type: 'sms', description: '+1 801-888-8181' },
{ id: 'totp_1', type: 'authenticator' },
@@ -114,9 +117,16 @@ describe('UserProfileSecurityPanelView', () => {
const user = userEvent.setup();
renderView({
- onAddPasskey,
- onRenamePasskey,
- onRemovePasskey,
+ passkeysSlot: {
+ content: (
+
+ ),
+ },
onSignOutDevice,
onSignOutAllOtherDevices,
deleteAccountSlot: ,
@@ -166,10 +176,16 @@ describe('UserProfileSecurityPanelView', () => {
it('keeps supported empty authentication methods actionable', () => {
renderView({
- passkeys: [],
+ passkeysSlot: {
+ content: (
+
+ ),
+ },
mfaMethods: [],
devices: [],
- onAddPasskey: vi.fn(),
onAddMfaMethod: vi.fn(),
addableMfaMethods: ['sms', 'authenticator'],
});
@@ -192,10 +208,7 @@ describe('UserProfileSecurityPanelView', () => {
it('keeps the authentication section on MFA when existing passkeys are hidden', () => {
renderView({
- passkeysVisible: false,
- onAddPasskey: vi.fn(),
- onRenamePasskey: vi.fn(),
- onRemovePasskey: vi.fn(),
+ passkeysSlot: null,
});
expect(screen.queryByText('Passkeys')).not.toBeInTheDocument();
@@ -206,7 +219,7 @@ describe('UserProfileSecurityPanelView', () => {
});
it('keeps the passkeys card in the authentication section when passkeys are empty and Add is unavailable', () => {
- renderView({ passkeys: [], onAddPasskey: undefined });
+ renderView({ passkeysSlot: { content: } });
const section = screen.getByRole('region', { name: 'Authentication' });
expect(within(section).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeVisible();
@@ -221,7 +234,17 @@ describe('UserProfileSecurityPanelView', () => {
const onRemovePasskey = vi.fn(async () => {
await removal.promise;
});
- const { rerender } = renderView({ mfaMethods: undefined, onRemovePasskey });
+ const { rerender } = renderView({
+ mfaMethods: undefined,
+ passkeysSlot: {
+ content: (
+
+ ),
+ },
+ });
await user.click(screen.getByRole('button', { name: 'Manage Passkey' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
@@ -230,8 +253,14 @@ describe('UserProfileSecurityPanelView', () => {
rerender(
+ ),
+ }}
/>
,
);
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx
index b8343d0c3c9..0c75e25b59c 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx
@@ -6,8 +6,7 @@ import { ActionMenu } from '../../components/action-menu';
import { Dialog } from '../../components/dialog';
import { Icon, IconFrame } from '../../components/icon';
import { Section } from '../../components/section';
-import { fill } from '../../localization/messages';
-import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages';
+import { fill, useMessages } from '../../localization';
import { styles } from './user-profile-passkeys-section.styles';
import type { UserProfilePasskey } from './user-profile-passkeys-section.view';
import { useUserProfileRenamePasskeyController } from './user-profile-rename-passkey.controller';
@@ -24,6 +23,7 @@ export function UserProfilePasskeyRowView({
onRename?: (id: string, name: string) => void | Promise;
onRemove?: () => void;
}) {
+ const m = useMessages('userProfilePasskeys');
const renameDialog = useMemo(() => Dialog.createHandle(), []);
const controller = useUserProfileRenamePasskeyController({ id: passkey.id, name: passkey.name, onRename });
const description =
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts
index 537fad1cc35..8f0a96feeae 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts
@@ -4,6 +4,15 @@ export const userProfilePasskeysMessages = {
addLabel: 'Add passkey',
empty: 'No passkeys added',
details: '{createdAt} · {lastUsedAt}',
+ createdAt: 'Created: {date}',
+ lastUsedAt: 'Last used: {date}',
+ dates: {
+ sameDay: 'Today at {time}',
+ lastDay: 'Yesterday at {time}',
+ nextDay: 'Tomorrow at {time}',
+ previous6Days: 'Last {weekday} at {time}',
+ next6Days: '{weekday} at {time}',
+ },
manage: 'Manage {name}',
rename: 'Rename',
renameTitle: 'Rename passkey',
@@ -11,6 +20,8 @@ export const userProfilePasskeysMessages = {
nameLabel: 'Passkey name',
save: 'Save',
cancel: 'Cancel',
+ unavailableError: 'This passkey is no longer available.',
+ accountUnavailableError: 'This account is no longer available.',
saveError: 'Something went wrong. Please try again.',
removeAction: 'Remove passkey',
removeTitle: 'Remove passkey',
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx
index 38de095b398..03c5e18494b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx
@@ -6,21 +6,17 @@ import { Field } from '../../components/field';
import { Icon } from '../../components/icon';
import { Section } from '../../components/section';
import { useListRemovalFocus } from '../../hooks/use-list-removal-focus';
-import { fill } from '../../localization/messages';
+import { fill, useMessages } from '../../localization';
import { UserProfilePasskeyRowView } from './user-profile-passkey-row.view';
-import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages';
import { styles } from './user-profile-passkeys-section.styles';
+import type { UserProfilePasskey } from './user-profile-passkeys-section/user-profile-passkeys-section.types';
-export interface UserProfilePasskey {
- id: string;
- name: string;
- createdAtLabel?: string;
- lastUsedAtLabel?: string;
-}
+export type { UserProfilePasskey } from './user-profile-passkeys-section/user-profile-passkeys-section.types';
export interface UserProfilePasskeysSectionViewProps {
passkeys: UserProfilePasskey[];
onAdd?: () => void;
+ isAdding?: boolean;
addError?: string;
onRename?: (id: string, name: string) => void | Promise;
onRemove?: (id: string) => void | Promise;
@@ -29,10 +25,12 @@ export interface UserProfilePasskeysSectionViewProps {
export function UserProfilePasskeysSectionView({
passkeys,
onAdd,
+ isAdding,
addError,
onRename,
onRemove,
}: UserProfilePasskeysSectionViewProps) {
+ const m = useMessages('userProfilePasskeys');
const addButton = useRef(null);
const section = useRef(null);
const removalFocus = useListRemovalFocus({
@@ -58,6 +56,8 @@ export function UserProfilePasskeysSectionView({
color='neutral'
size='sm'
variant='outline'
+ disabled={isAdding}
+ aria-busy={isAdding}
onClick={onAdd}
>
Promise;
+ error: string | undefined;
+ fallbackErrorMessage: string;
+}
+
+type Event = { type: 'ADD'; run: () => Promise; fallbackErrorMessage: string };
+
+const { createMachine, assign, fromPromise } = setup();
+
+const machine = createMachine({
+ id: 'createPasskey',
+ initial: 'idle',
+ context: { run: () => Promise.resolve(), error: undefined, fallbackErrorMessage: '' },
+ states: {
+ idle: {
+ on: {
+ ADD: {
+ target: 'creating',
+ actions: assign((_, event) => ({
+ run: event.run,
+ error: undefined,
+ fallbackErrorMessage: event.fallbackErrorMessage,
+ })),
+ },
+ },
+ },
+ creating: {
+ invoke: fromPromise(context => context.run(), {
+ onDone: { target: 'idle' },
+ onError: {
+ target: 'idle',
+ actions: assign((context, event) => ({
+ error:
+ event.error instanceof Error && event.error.message ? event.error.message : context.fallbackErrorMessage,
+ })),
+ },
+ }),
+ },
+ },
+});
+
+export function useUserProfilePasskeysSectionController({ onAdd }: { onAdd?: () => Promise }) {
+ const messages = useMessages('userProfilePasskeys');
+ const [snapshot, send] = useMachine(machine);
+ return {
+ isAdding: snapshot.value === 'creating',
+ addError: snapshot.context.error,
+ onAdd: onAdd ? () => send({ type: 'ADD', run: onAdd, fallbackErrorMessage: messages.saveError }) : undefined,
+ };
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.test.ts
new file mode 100644
index 00000000000..51ade15b7f0
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+
+import { projectPasskeys } from './user-profile-passkeys-section.model';
+
+const passkeys = [
+ { id: 'first', name: null, createdAt: new Date('2026-09-20'), lastUsedAt: null },
+ { id: 'second', name: 'Phone', createdAt: new Date('2026-09-21'), lastUsedAt: new Date('2026-09-22') },
+];
+
+describe('projectPasskeys', () => {
+ it('preserves order, names, and timestamps, and hides only Add on a satellite', () => {
+ expect(projectPasskeys({ passkeys, enabled: true, allowIdentificationCreation: true, isSatellite: true })).toEqual({
+ status: 'ready',
+ passkeys: [
+ { id: 'first', name: '', createdAt: passkeys[0].createdAt, lastUsedAt: null },
+ { id: 'second', name: 'Phone', createdAt: passkeys[1].createdAt, lastUsedAt: passkeys[1].lastUsedAt },
+ ],
+ canAdd: false,
+ });
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts
new file mode 100644
index 00000000000..6049bfe6f4d
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts
@@ -0,0 +1,196 @@
+import { formatRelative } from '@clerk/shared/date';
+import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error';
+import { useClerk, useSession, useUser } from '@clerk/shared/react';
+import type { EnvironmentResource, PasskeyResource, UserResource } from '@clerk/shared/types';
+
+import { getMosaicEnvironment, useMosaicEnvironment } from '../../../hooks/use-mosaic-environment';
+import { fill, useErrorText, useLocale, useMessages } from '../../../localization';
+import type { UserProfilePasskey } from './user-profile-passkeys-section.types';
+
+type Passkey = Pick;
+
+export type PasskeysProjection =
+ | { status: 'hidden' }
+ | {
+ status: 'ready';
+ passkeys: { id: string; name: string; createdAt: Date; lastUsedAt: Date | null }[];
+ canAdd: boolean;
+ };
+
+export function projectPasskeys({
+ passkeys,
+ enabled,
+ allowIdentificationCreation,
+ isSatellite,
+}: {
+ passkeys: readonly Passkey[];
+ enabled: boolean;
+ allowIdentificationCreation: boolean;
+ isSatellite: boolean;
+}): PasskeysProjection {
+ if (!enabled || !allowIdentificationCreation) {
+ return { status: 'hidden' };
+ }
+
+ return {
+ status: 'ready',
+ passkeys: passkeys.map(passkey => ({
+ id: passkey.id,
+ name: passkey.name ?? '',
+ createdAt: passkey.createdAt,
+ lastUsedAt: passkey.lastUsedAt,
+ })),
+ canAdd: !isSatellite,
+ };
+}
+
+export type UserProfilePasskeysModel =
+ | { status: 'loading' }
+ | { status: 'hidden' }
+ | {
+ status: 'ready';
+ userId: string;
+ sessionId: string;
+ passkeys: UserProfilePasskey[];
+ onAdd?: () => Promise;
+ onRename: (id: string, name: string) => Promise;
+ onRemove: (id: string) => Promise;
+ };
+
+function getPasskeysProjection(
+ user: UserResource,
+ environment: EnvironmentResource,
+ isSatellite: boolean,
+): PasskeysProjection {
+ return projectPasskeys({
+ passkeys: user.passkeys,
+ enabled: Boolean(environment.userSettings.attributes.passkey?.enabled),
+ allowIdentificationCreation:
+ !environment.userSettings.enterpriseSSO.enabled ||
+ !user.enterpriseAccounts.some(
+ account => account.active && account.enterpriseConnection?.disableAdditionalIdentifications,
+ ),
+ isSatellite,
+ });
+}
+
+export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
+ const clerk = useClerk();
+ const { isLoaded: isUserLoaded, user } = useUser();
+ const { isLoaded: isSessionLoaded, session } = useSession();
+ const environment = useMosaicEnvironment();
+ const locale = useLocale();
+ const messages = useMessages('userProfilePasskeys');
+ const errorText = useErrorText();
+
+ if (!isUserLoaded || !isSessionLoaded || !environment) {
+ return { status: 'loading' };
+ }
+ if (!user || !session) {
+ return { status: 'hidden' };
+ }
+
+ const userId = user.id;
+ const sessionId = session.id;
+ const projection = getPasskeysProjection(user, environment, clerk.isSatellite);
+ if (projection.status === 'hidden') {
+ return projection;
+ }
+
+ const currentUser = () => {
+ const current = clerk.user;
+ const currentEnvironment = getMosaicEnvironment(clerk);
+ if (
+ !current ||
+ !currentEnvironment ||
+ current.id !== userId ||
+ clerk.session?.id !== sessionId ||
+ getPasskeysProjection(current, currentEnvironment, clerk.isSatellite).status === 'hidden'
+ ) {
+ throw new Error(messages.accountUnavailableError);
+ }
+ return current;
+ };
+
+ function actionError(error: unknown): Error {
+ if (isClerkAPIResponseError(error)) {
+ const first = error.errors[0];
+ return new Error(
+ errorText({
+ code: first?.code,
+ paramName: first?.meta?.paramName,
+ message: first?.longMessage || first?.message || messages.saveError,
+ }),
+ );
+ }
+ return new Error(
+ errorText({
+ code: isClerkRuntimeError(error) ? error.code : undefined,
+ message: error instanceof Error && error.message ? error.message : messages.saveError,
+ }),
+ );
+ }
+
+ function formatPasskeyDate(date: Date): string {
+ const relative = formatRelative({ date, relativeTo: new Date() });
+ if (!relative) {
+ return '';
+ }
+ if (relative.relativeDateCase === 'other') {
+ return new Intl.DateTimeFormat(locale).format(date);
+ }
+ return fill(messages.dates[relative.relativeDateCase], {
+ time: new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(date),
+ weekday: new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(date),
+ });
+ }
+
+ return {
+ status: 'ready',
+ userId,
+ sessionId,
+ passkeys: projection.passkeys.map(passkey => ({
+ id: passkey.id,
+ name: passkey.name,
+ createdAtLabel: fill(messages.createdAt, { date: formatPasskeyDate(passkey.createdAt) }),
+ lastUsedAtLabel: passkey.lastUsedAt
+ ? fill(messages.lastUsedAt, { date: formatPasskeyDate(passkey.lastUsedAt) })
+ : undefined,
+ })),
+ onAdd: projection.canAdd
+ ? async () => {
+ const current = currentUser();
+ if (clerk.isSatellite) {
+ throw new Error(messages.accountUnavailableError);
+ }
+ try {
+ await current.createPasskey();
+ } catch (error) {
+ throw actionError(error);
+ }
+ }
+ : undefined,
+ onRename: async (id, name) => {
+ const passkey = currentUser().passkeys.find(candidate => candidate.id === id);
+ if (!passkey) {
+ throw new Error(messages.unavailableError);
+ }
+ try {
+ await passkey.update({ name });
+ } catch (error) {
+ throw actionError(error);
+ }
+ },
+ onRemove: async id => {
+ const passkey = currentUser().passkeys.find(candidate => candidate.id === id);
+ if (!passkey) {
+ throw new Error(messages.unavailableError);
+ }
+ try {
+ await passkey.delete();
+ } catch (error) {
+ throw actionError(error);
+ }
+ },
+ };
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.tsx
new file mode 100644
index 00000000000..f643ac9d7b6
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.tsx
@@ -0,0 +1,47 @@
+import type { ReactNode } from 'react';
+
+import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
+import { useUserProfilePasskeysSectionController } from './user-profile-passkeys-section.controller';
+import type { UserProfilePasskeysModel } from './user-profile-passkeys-section.model';
+import { useUserProfilePasskeysModel } from './user-profile-passkeys-section.model';
+import type { UserProfilePasskeysSlot } from './user-profile-passkeys-section.types';
+
+export interface UserProfilePasskeysSectionProps {
+ fallback?: ReactNode;
+}
+
+export function UserProfilePasskeysSection(props: UserProfilePasskeysSectionProps = {}) {
+ return useUserProfilePasskeysSlot(props)?.content ?? null;
+}
+
+export function useUserProfilePasskeysSlot({
+ fallback = null,
+}: UserProfilePasskeysSectionProps = {}): UserProfilePasskeysSlot | null {
+ const model = useUserProfilePasskeysModel();
+ if (model.status === 'loading') {
+ return fallback ? { content: fallback } : null;
+ }
+ if (model.status === 'hidden') {
+ return null;
+ }
+ return {
+ content: (
+
+ ),
+ };
+}
+
+function PasskeysEditor({ model }: { model: Extract }) {
+ const controller = useUserProfilePasskeysSectionController({ onAdd: model.onAdd });
+ return (
+
+ );
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.types.ts
new file mode 100644
index 00000000000..017b6971f6b
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.types.ts
@@ -0,0 +1,12 @@
+import type { ReactNode } from 'react';
+
+export interface UserProfilePasskey {
+ id: string;
+ name: string;
+ createdAtLabel?: string;
+ lastUsedAtLabel?: string;
+}
+
+export interface UserProfilePasskeysSlot {
+ content: ReactNode;
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts
index 5aa10686cc9..ac112faecc1 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts
@@ -1,6 +1,6 @@
+import { useMessages } from '../../localization';
import { setup } from '../../machine/setup';
import { useMachine } from '../../machine/use-machine';
-import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages';
interface UserProfileRenamePasskeyContext {
passkeyId: string;
@@ -8,6 +8,7 @@ interface UserProfileRenamePasskeyContext {
name: string;
onRename: ((id: string, name: string) => void | Promise) | undefined;
error: string | undefined;
+ fallbackErrorMessage: string;
}
type UserProfileRenamePasskeyEvent =
@@ -31,6 +32,7 @@ const userProfileRenamePasskeyMachine = createMachine({
name: '',
onRename: undefined,
error: undefined,
+ fallbackErrorMessage: '',
},
states: {
idle: {
@@ -53,7 +55,10 @@ const userProfileRenamePasskeyMachine = createMachine({
onDone: { target: 'idle' },
onError: {
target: 'editing',
- actions: assign((_, event) => ({ error: event.error instanceof Error ? event.error.message : m.saveError })),
+ actions: assign((context, event) => ({
+ error:
+ event.error instanceof Error && event.error.message ? event.error.message : context.fallbackErrorMessage,
+ })),
},
}),
},
@@ -82,8 +87,9 @@ export function useUserProfileRenamePasskeyController({
name,
onRename,
}: UserProfileRenamePasskeyControllerOptions): UserProfileRenamePasskeyController {
+ const messages = useMessages('userProfilePasskeys');
const [snapshot, send] = useMachine(userProfileRenamePasskeyMachine, {
- context: { passkeyId: id, savedName: name, onRename },
+ context: { passkeyId: id, savedName: name, onRename, fallbackErrorMessage: messages.saveError },
});
return {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx
index 25cef6658d9..bb3451ddc7d 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx
@@ -6,7 +6,7 @@ import type { DialogHandle } from '../../components/dialog';
import { Dialog } from '../../components/dialog';
import { Field } from '../../components/field';
import { Input } from '../../components/input';
-import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages';
+import { useMessages } from '../../localization';
export interface UserProfileRenamePasskeyDialogProps {
handle: DialogHandle;
@@ -31,6 +31,7 @@ export function UserProfileRenamePasskeyDialog({
error,
onSubmit,
}: UserProfileRenamePasskeyDialogProps) {
+ const m = useMessages('userProfilePasskeys');
const formId = useId();
const inputRef = useRef(null);
return (
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 4c1d9143a9c..acf8e2f8ef8 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
@@ -11,23 +11,18 @@ import { UserProfileActiveDevicesSectionView } from './user-profile-active-devic
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';
-import { UserProfilePasskeysSectionView } from './user-profile-passkeys-section.view';
+import type { UserProfilePasskeysSlot } from './user-profile-passkeys-section/user-profile-passkeys-section.types';
import type { UserProfilePasswordSlot } from './user-profile-password-section/user-profile-password-section.types';
export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey };
export interface UserProfileSecurityPanelViewProps extends Omit {
passwordSlot?: UserProfilePasswordSlot | null;
- passkeys?: UserProfilePasskey[];
- passkeysVisible?: boolean;
+ passkeysSlot?: UserProfilePasskeysSlot | null;
mfaMethods?: UserProfileMfaMethod[];
addableMfaMethods?: readonly UserProfileMfaAddableMethod[];
mfaAddControl?: ReactNode;
devices?: UserProfileDevice[];
- onAddPasskey?: () => void;
- addPasskeyError?: string;
- onRenamePasskey?: (id: string, name: string) => void | Promise;
- onRemovePasskey?: (id: string) => void | Promise;
onAddMfaMethod?: (type: UserProfileMfaAddableMethod) => void;
onRegenerateBackupCodes?: () => void;
onRemoveMfaMethod?: (id: string) => void | Promise;
@@ -38,16 +33,11 @@ export interface UserProfileSecurityPanelViewProps extends Omit}>
@@ -67,15 +55,7 @@ export function UserProfileSecurityPanelView({
{hasAuthentication ? (
{passwordSlot?.content}
- {showPasskeys ? (
-
- ) : null}
+ {passkeysSlot?.content}
{mfaMethods !== undefined ? (
+
+
+
Passkeys
+
Manage passkeys for the signed-in user.
+
+ {!isLoaded ?
Loading…
: null}
+ {isLoaded && !isSignedIn ? (
+
+
+ Sign in
+ {' '}
+ to manage passkeys.
+
+ ) : null}
+ {isLoaded && isSignedIn ?
: null}
+
+
+ );
+}
diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts
index da710a19657..3ff35463b80 100644
--- a/packages/swingset/src/lib/live-navigation.ts
+++ b/packages/swingset/src/lib/live-navigation.ts
@@ -16,6 +16,7 @@ const liveRoutes = new Map([
['/user-profile/user-profile-connected-accounts-section', '/live/connected-accounts'],
['/user-profile/user-profile-enterprise-accounts-section', '/live/enterprise-accounts'],
['/user-profile/user-profile-delete-section', '/live/delete-account'],
+ ['/user-profile/user-profile-passkeys-section', '/live/passkeys'],
['/reverification/reverification', '/live/reverification'],
]);
diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx
index fa3e7a5b8ab..0951e048f5b 100644
--- a/packages/swingset/src/stories/fixtures/user-profile.tsx
+++ b/packages/swingset/src/stories/fixtures/user-profile.tsx
@@ -6,6 +6,7 @@ import type {
} from '@clerk/mosaic/features/user-profile/user-profile-billing-panel.view';
import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view';
import { UserProfileDeleteSectionView } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.view';
+import { UserProfilePasskeysSectionView } from '@clerk/mosaic/features/user-profile/user-profile-passkeys-section.view';
import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view';
import type {
UserProfileEmail,
@@ -156,14 +157,10 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions
},
security: {
passwordSlot: { content: },
- passkeys: passkeys.passkeys,
- addPasskeyError: passkeys.addError,
- onRenamePasskey: passkeys.onRename,
+ passkeysSlot: { content: },
...mfa.security,
devices: activeDevices.devices,
- onAddPasskey: passkeys.onAdd,
deleteAccountSlot: ,
- onRemovePasskey: passkeys.onRemove,
onSignOutAllOtherDevices: activeDevices.onSignOutAllOtherDevices,
onSignOutDevice: activeDevices.onSignOutDevice,
},
From 6af60331b3bd75fc489c4d2b5abf9cad2b033679 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 09:40:22 -0600
Subject: [PATCH 03/10] test(mosaic): cover passkey lifecycle with connected
features
---
...rofile-passkeys-lifecycle.feature.test.tsx | 402 +++++++++++++++
...-profile-passkeys-section.feature.test.tsx | 487 ++++++++++++++++++
...ser-profile-passkeys-section.view.test.tsx | 251 ---------
3 files changed, 889 insertions(+), 251 deletions(-)
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
new file mode 100644
index 00000000000..1cc2432238e
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
@@ -0,0 +1,402 @@
+import { act, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiClient,
+ fapiEnterpriseAccount,
+ fapiEnvironment,
+ fapiPasskey,
+ fapiSession,
+ fapiUser,
+} from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { MosaicProvider } from '../../../mosaic-provider';
+import {
+ UserProfilePasskeysSection,
+ useUserProfilePasskeysSlot,
+} from '../user-profile-passkeys-section/user-profile-passkeys-section';
+import {
+ UserProfileSecurityPanelView,
+ type UserProfileSecurityPanelViewProps,
+} from '../user-profile-security-panel.view';
+
+afterEach(() => vi.restoreAllMocks());
+
+function SecurityHost(props: Pick = {}) {
+ const passkeysSlot = useUserProfilePasskeysSlot();
+ return (
+
+ );
+}
+
+function serveAccounts(enabled = true) {
+ vi.spyOn(navigator, 'webdriver', 'get').mockReturnValue(false);
+ const environment = fapiEnvironment();
+ environment.user_settings.attributes.passkey.enabled = enabled;
+ return serveFapi({
+ environment,
+ client: fapiClient([
+ fapiSession({
+ id: 'sess_a',
+ user: fapiUser({ id: 'user_a', passkeys: [fapiPasskey({ id: 'pk_a', name: 'Alice laptop' })] }),
+ }),
+ fapiSession({
+ id: 'sess_b',
+ user: fapiUser({ id: 'user_b', passkeys: [fapiPasskey({ id: 'pk_b', name: 'Bob phone' })] }),
+ }),
+ ]),
+ });
+}
+
+function authenticator() {
+ vi.spyOn(navigator, 'webdriver', 'get').mockReturnValue(false);
+ return vi.spyOn(navigator.credentials, 'create').mockResolvedValue({
+ id: 'credential_1',
+ type: 'public-key',
+ rawId: new Uint8Array([1, 2, 3]).buffer,
+ authenticatorAttachment: 'platform',
+ response: {
+ clientDataJSON: new TextEncoder().encode('{}').buffer,
+ attestationObject: new Uint8Array([4, 5, 6]).buffer,
+ getTransports: () => ['internal'],
+ },
+ });
+}
+
+describe('Changing the active passkey account', () => {
+ it('clears the previous account Add error after a user switch', async () => {
+ serveAccounts();
+ const { clerk } = await renderWithClerk();
+ const creation = holdRequests('post', '/v1/me/passkeys');
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ await waitFor(() => expect(creation.requests).toHaveLength(1));
+ creation.fail('form_param_invalid', 'Alice credential failed');
+ expect(await screen.findByRole('alert')).toHaveTextContent('Alice credential failed');
+ await act(() => clerk.setActive({ session: 'sess_b' }));
+ expect(await screen.findByText('Bob phone')).toBeVisible();
+ expect(screen.queryByRole('alert')).toBeNull();
+ });
+
+ it('drops a held creation failure after switching users', async () => {
+ serveAccounts();
+ const { clerk } = await renderWithClerk();
+ const creation = holdRequests('post', '/v1/me/passkeys');
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ await waitFor(() => expect(creation.requests).toHaveLength(1));
+ await act(() => clerk.setActive({ session: 'sess_b' }));
+ expect(await screen.findByText('Bob phone')).toBeVisible();
+ creation.fail('form_param_invalid', 'Alice pending credential failed');
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Add passkey' })).toBeEnabled());
+ expect(screen.queryByRole('alert')).toBeNull();
+ });
+
+ it('clears a failed Add after signing out the active account', async () => {
+ serveAccounts();
+ const { clerk } = await renderWithClerk();
+ const creation = holdRequests('post', '/v1/me/passkeys');
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ await waitFor(() => expect(creation.requests).toHaveLength(1));
+ creation.fail('form_param_invalid', 'Signed-out account error');
+ expect(await screen.findByRole('alert')).toBeVisible();
+ await act(() => clerk.signOut({ sessionId: 'sess_a' }));
+ await act(() => clerk.setActive({ session: 'sess_b' }));
+ expect(await screen.findByText('Bob phone')).toBeVisible();
+ expect(screen.queryByRole('alert')).toBeNull();
+ });
+
+ it('hides the section during sign-out even with a held request', async () => {
+ serveAccounts();
+ const { clerk } = await renderWithClerk();
+ const creation = holdRequests('post', '/v1/me/passkeys');
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ await waitFor(() => expect(creation.requests).toHaveLength(1));
+ await act(() => clerk.signOut());
+ creation.fail('form_param_invalid');
+ await waitFor(() => expect(screen.queryByRole('group', { name: 'Passkeys' })).toBeNull());
+ expect(screen.queryByRole('alert')).toBeNull();
+ });
+
+ it('does not verify Alice pending credentials onto Bob in the FAPI fake', async () => {
+ const fapi = serveAccounts();
+ authenticator();
+ const { clerk } = await renderWithClerk();
+ const verification = holdRequests('post', '/v1/me/passkeys/passkey_1/attempt_verification');
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ await waitFor(() => expect(verification.requests).toHaveLength(1));
+ await act(() => clerk.setActive({ session: 'sess_b' }));
+ verification.release();
+ await waitFor(() =>
+ expect(
+ fapi.client.sessions.find(session => session.id === 'sess_a')?.user.passkeys.map(passkey => passkey.name),
+ ).toContain('New passkey'),
+ );
+ expect(
+ fapi.client.sessions.find(session => session.id === 'sess_b')?.user.passkeys.map(passkey => passkey.name),
+ ).toEqual(['Bob phone']);
+ });
+});
+
+describe('Composing passkeys in Security', () => {
+ it('checks disabled passkey absence against the real group DOM', async () => {
+ serveAccounts(false);
+ await renderWithClerk();
+ expect(screen.queryByRole('group', { name: 'Passkeys' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Add passkey' })).toBeNull();
+ });
+
+ it('does not leave an empty Authentication region when a connected passkeys slot is hidden', async () => {
+ serveAccounts(false);
+ await renderWithClerk();
+ expect(screen.queryByText('Passkeys')).toBeNull();
+ expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull();
+ });
+
+ it('orders connected passkeys before MFA and active devices', async () => {
+ serveAccounts();
+ await renderWithClerk(
+ ,
+ );
+ expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Passkeys');
+ expect(
+ screen.getByText('Passkeys').compareDocumentPosition(screen.getByText('2-step verification')) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+});
+
+describe('Submitting passkey actions', () => {
+ it('deduplicates keyboard rename submissions while a request is held', async () => {
+ serveAccounts();
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Alice laptop' }));
+ await user.keyboard('{ArrowDown}{Enter}');
+ const dialog = await screen.findByRole('dialog');
+ const input = within(dialog).getByRole('textbox', { name: 'Passkey name' });
+ await user.clear(input);
+ await user.type(input, 'Alice work laptop');
+ const rename = holdRequests('post', '/v1/me/passkeys/pk_a');
+ await user.keyboard('{Enter}{Enter}');
+ await waitFor(() => expect(rename.requests).toHaveLength(1));
+ await user.keyboard('{Escape}');
+ expect(dialog).toBeVisible();
+ rename.release();
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(screen.getByText('Alice work laptop')).toBeVisible();
+ });
+
+ it('rejects passkey creation in the fake when instance passkeys are disabled', async () => {
+ serveAccounts(false);
+ authenticator();
+ const { clerk } = await renderWithClerk();
+ const user = clerk.user;
+ expect(user).toBeDefined();
+ if (!user) {throw new Error('Missing user');}
+ await expect(user.createPasskey()).rejects.toBeDefined();
+ });
+
+ it('uses localized coded cancellation errors', async () => {
+ serveAccounts();
+ authenticator().mockRejectedValueOnce(new DOMException('Cancelled', 'NotAllowedError'));
+ await renderWithClerk(
+
+
+ ,
+ );
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent('Création annulée.');
+ });
+});
+
+describe('Passkey backend contract', () => {
+ it('rejects creation at the ten claimed passkey quota', async () => {
+ const fapi = serveAccounts();
+ fapi.client = fapiClient([
+ fapiSession({
+ id: 'sess_a',
+ user: fapiUser({
+ id: 'user_a',
+ passkeys: Array.from({ length: 10 }, (_, index) => fapiPasskey({ id: `pk_${index}` })),
+ }),
+ }),
+ ]);
+ authenticator();
+ const { clerk } = await renderWithClerk();
+ const current = clerk.user;
+ if (!current) {throw new Error('Missing user');}
+ await expect(current.createPasskey()).rejects.toMatchObject({ errors: [{ code: 'passkey_quota_exceeded' }] });
+ expect(fapi.client.sessions.find(session => session.id === 'sess_a')?.user.passkeys).toHaveLength(10);
+ });
+
+ it('rejects creation for a managed enterprise account', async () => {
+ const fapi = serveAccounts();
+ fapi.environment.user_settings.enterprise_sso.enabled = true;
+ fapi.client = fapiClient([
+ fapiSession({
+ id: 'sess_a',
+ user: fapiUser({ id: 'user_a', enterprise_accounts: [fapiEnterpriseAccount({ id: 'ea_1' })] }),
+ }),
+ ]);
+ authenticator();
+ const { clerk } = await renderWithClerk();
+ const current = clerk.user;
+ if (!current) {throw new Error('Missing user');}
+ await expect(current.createPasskey()).rejects.toMatchObject({
+ errors: [{ code: 'enterprise_sso_additional_identifications_disabled' }],
+ });
+ expect(fapi.client.sessions.find(session => session.id === 'sess_a')?.user.passkeys).toHaveLength(0);
+ });
+
+ it('returns verified registration metadata at the nine-key boundary', async () => {
+ const fapi = serveAccounts();
+ fapi.client = fapiClient([
+ fapiSession({
+ id: 'sess_a',
+ user: fapiUser({
+ id: 'user_a',
+ passkeys: Array.from({ length: 9 }, (_, index) => fapiPasskey({ id: `pk_${index}` })),
+ }),
+ }),
+ ]);
+ authenticator();
+ const { clerk } = await renderWithClerk();
+ const current = clerk.user;
+ if (!current) {throw new Error('Missing user');}
+ const passkey = await current.createPasskey();
+ expect(passkey.verification).toMatchObject({ strategy: 'passkey', status: 'verified' });
+ expect(passkey.lastUsedAt).toBeInstanceOf(Date);
+ expect(fapi.client.sessions.find(session => session.id === 'sess_a')?.user.passkeys).toHaveLength(10);
+ });
+
+ it.each([64, 65])('enforces the UTF-8 name boundary for %i four-byte characters', async count => {
+ serveAccounts();
+ const { clerk } = await renderWithClerk();
+ const passkey = clerk.user?.passkeys.find(candidate => candidate.id === 'pk_a');
+ if (!passkey) {throw new Error('Missing passkey');}
+ const name = '🔑'.repeat(count);
+ if (count === 64) {
+ const renamed = await passkey.update({ name });
+ expect(renamed.name).toBe(name);
+ } else {
+ await expect(passkey.update({ name })).rejects.toMatchObject({
+ errors: [{ code: 'form_param_max_length_exceeded' }],
+ });
+ expect(passkey.name).toBe('Alice laptop');
+ }
+ });
+});
+
+describe('Passkey identity and policy at action time', () => {
+ it('clears Add state when the same user changes sessions', async () => {
+ const fapi = serveAccounts();
+ const alice = fapi.client.sessions.find(session => session.id === 'sess_a')?.user;
+ if (!alice) {throw new Error('Missing user');}
+ fapi.client = fapiClient([...fapi.client.sessions, fapiSession({ id: 'sess_a2', user: alice })]);
+ const { clerk } = await renderWithClerk();
+ const creation = holdRequests('post', '/v1/me/passkeys');
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ await waitFor(() => expect(creation.requests).toHaveLength(1));
+ creation.fail('form_param_invalid', 'Old session error');
+ expect(await screen.findByRole('alert')).toHaveTextContent('Old session error');
+ await act(() => clerk.setActive({ session: 'sess_a2' }));
+ expect(clerk.session?.id).toBe('sess_a2');
+ expect(screen.queryByRole('alert')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Add passkey' })).toBeEnabled();
+ });
+
+ it('closes an old account rename dialog when the active user changes', async () => {
+ serveAccounts();
+ const { clerk } = await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Alice laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
+ await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible());
+ await act(() => clerk.setActive({ session: 'sess_b' }));
+ expect(await screen.findByText('Bob phone')).toBeVisible();
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ });
+
+ it('rejects a rename if passkeys become disabled before Save', async () => {
+ serveAccounts();
+ const { clerk } = await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Alice laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
+ const input = screen.getByRole('textbox', { name: 'Passkey name' });
+ await user.clear(input);
+ await user.type(input, 'Blocked rename');
+ const environment = clerk.__internal_environment;
+ if (!environment) {throw new Error('Missing environment');}
+ environment.userSettings.attributes.passkey.enabled = false;
+ const rename = holdRequests('post', '/v1/me/passkeys/pk_a');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ rename.release();
+ expect(await screen.findByRole('alert')).toBeVisible();
+ expect(rename.requests).toHaveLength(0);
+ expect(clerk.user?.passkeys.find(passkey => passkey.id === 'pk_a')?.name).toBe('Alice laptop');
+ });
+});
+
+
+describe('Held passkey creation ownership', () => {
+ it('retains the requesting user when creation completes after an account switch', async () => {
+ const fapi = serveAccounts();
+ authenticator();
+ const { clerk } = await renderWithClerk();
+ const creation = holdRequests('post', '/v1/me/passkeys');
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ await waitFor(() => expect(creation.requests).toHaveLength(1));
+ const request = creation.requests[0];
+ if (!request) {throw new Error('Missing creation request');}
+ expect(new URL(request.url).searchParams.get('_clerk_session_id')).toBe('sess_a');
+ await act(() => clerk.setActive({ session: 'sess_b' }));
+ creation.release();
+ await waitFor(() => expect(fapi.client.sessions.find(session => session.id === 'sess_a')?.user.passkeys.map(passkey => passkey.name)).toContain('New passkey'));
+ expect(fapi.client.sessions.find(session => session.id === 'sess_b')?.user.passkeys.map(passkey => passkey.name)).toEqual(['Bob phone']);
+ });
+});
+
+
+describe('Replacing instance policy while editing', () => {
+ it('rechecks the current environment when Save follows a policy replacement', async () => {
+ const fapi = serveAccounts();
+ const { clerk } = await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Alice laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
+ const input = screen.getByRole('textbox', { name: 'Passkey name' });
+ await user.clear(input);
+ await user.type(input, 'Blocked rename');
+ const nextEnvironment = fapiEnvironment();
+ nextEnvironment.user_settings.attributes.passkey.enabled = false;
+ fapi.environment = nextEnvironment;
+ await clerk.__internal_setEnvironment(nextEnvironment);
+ const rename = holdRequests('post', '/v1/me/passkeys/pk_a');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ rename.release();
+ expect(await screen.findByRole('alert')).toBeVisible();
+ expect(rename.requests).toHaveLength(0);
+ expect(clerk.user?.passkeys.find(passkey => passkey.id === 'pk_a')?.name).toBe('Alice laptop');
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
new file mode 100644
index 00000000000..a1ded2ac45f
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
@@ -0,0 +1,487 @@
+import type { PasskeyJSON } from '@clerk/shared/types';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiClient,
+ fapiEnterpriseAccount,
+ fapiEnvironment,
+ fapiPasskey,
+ fapiSession,
+ fapiUser,
+} from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { MosaicProvider } from '../../../mosaic-provider';
+import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
+import {
+ UserProfilePasskeysSection,
+ useUserProfilePasskeysSlot,
+} from '../user-profile-passkeys-section/user-profile-passkeys-section';
+import { useUserProfilePasskeysModel } from '../user-profile-passkeys-section/user-profile-passkeys-section.model';
+import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
+
+function ReadonlyPasskeys() {
+ const model = useUserProfilePasskeysModel();
+ return model.status === 'ready' ? : null;
+}
+
+function SecurityPanel() {
+ const passkeysSlot = useUserProfilePasskeysSlot();
+ return ;
+}
+
+function servePasskeys(passkeys: PasskeyJSON[] = [fapiPasskey({ id: 'pk_1' })]) {
+ const environment = fapiEnvironment();
+ environment.user_settings.attributes.passkey.enabled = true;
+ return serveFapi({
+ environment,
+ client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', passkeys }) })]),
+ });
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+function fakeAuthenticator() {
+ vi.spyOn(navigator, 'webdriver', 'get').mockReturnValue(false);
+ const credential = {
+ id: 'credential_1',
+ type: 'public-key',
+ rawId: new Uint8Array([1, 2, 3]).buffer,
+ authenticatorAttachment: 'platform',
+ response: {
+ clientDataJSON: new TextEncoder().encode('{}').buffer,
+ attestationObject: new Uint8Array([4, 5, 6]).buffer,
+ getTransports: () => ['internal'],
+ },
+ };
+ return vi.spyOn(navigator.credentials, 'create').mockResolvedValue(credential);
+}
+
+describe('Seeing passkeys', () => {
+ it('renders the readonly view contract when action callbacks are omitted', async () => {
+ servePasskeys([fapiPasskey({ id: 'pk_1', name: 'Laptop' }), fapiPasskey({ id: 'pk_2', name: 'Phone' })]);
+ await renderWithClerk();
+ expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible();
+ expect(screen.getByText('Laptop')).toBeVisible();
+ expect(screen.getByText('Phone')).toBeVisible();
+ expect(screen.queryByRole('button')).toBeNull();
+ });
+
+ it('renders within the authentication section supplied by its parent', async () => {
+ servePasskeys();
+ await renderWithClerk();
+
+ expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Passkeys');
+ expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible();
+ });
+
+ it('localizes the passkey section, row actions, and dialogs', async () => {
+ servePasskeys();
+ await renderWithClerk(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('group', { name: 'Clés d’accès' })).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Ajouter une clé' })).toBeVisible();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Gérer Laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Renommer' }));
+ const rename = screen.getByRole('dialog', { name: 'Renommer la clé' });
+ expect(rename).toHaveTextContent('Choisissez un nouveau nom.');
+ await waitFor(() => expect(within(rename).getByRole('textbox', { name: 'Nom de la clé' })).toBeVisible());
+ expect(within(rename).getByRole('button', { name: 'Enregistrer' })).toBeVisible();
+ await user.click(within(rename).getByRole('button', { name: 'Annuler' }));
+
+ await user.click(screen.getByRole('button', { name: 'Gérer Laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Supprimer la clé' }));
+ const removal = screen.getByRole('alertdialog', { name: 'Supprimer la clé' });
+ expect(removal).toHaveAccessibleDescription('Laptop sera supprimée de ce compte.');
+ await waitFor(() => expect(within(removal).getByRole('button', { name: 'Supprimer' })).toBeVisible());
+ });
+
+ it.each([
+ [-8, '8.9.2026'],
+ [-3, 'Letzten Sonntag um 12:30'],
+ [-1, 'Gestern um 12:30'],
+ [0, 'Heute um 12:30'],
+ [1, 'Morgen um 12:30'],
+ [3, 'Samstag um 12:30'],
+ [8, '24.9.2026'],
+ ])('localizes passkey dates %i days from today', async (offset, lastUsed) => {
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(2026, 8, 16, 12, 30));
+ servePasskeys([
+ fapiPasskey({
+ id: 'pk_1',
+ created_at: new Date(2026, 7, 20, 12, 30).getTime(),
+ last_used_at: new Date(2026, 8, 16 + offset, 12, 30).getTime(),
+ }),
+ ]);
+ await renderWithClerk(
+
+
+ ,
+ );
+ expect(screen.getByText(/Created:|Erstellt:/)).toHaveTextContent(
+ `Erstellt: 20.8.2026 · Zuletzt verwendet: ${lastUsed}`,
+ );
+ });
+
+ it('shows a fallback while Clerk loads and then shows existing passkeys', async () => {
+ servePasskeys();
+ const environment = holdRequests('get', '/v1/environment');
+ const rendering = renderWithClerk(Loading passkeys
} />);
+ expect(await screen.findByText('Loading passkeys')).toBeVisible();
+ environment.release();
+ await rendering;
+ expect(screen.queryByText('Loading passkeys')).toBeNull();
+ expect(screen.getByText('Laptop')).toBeVisible();
+ });
+
+ it.each(['signed out', 'disabled', 'managed by enterprise'] as const)('hides the section when %s', async state => {
+ const fapi = servePasskeys();
+ if (state === 'signed out') {
+ fapi.client = fapiClient();
+ } else if (state === 'disabled') {
+ fapi.environment.user_settings.attributes.passkey.enabled = false;
+ } else {
+ fapi.environment.user_settings.enterprise_sso.enabled = true;
+ const session = fapi.client.sessions[0];
+ if (session) {
+ session.user.enterprise_accounts = [fapiEnterpriseAccount({ id: 'enterprise_1' })];
+ }
+ }
+ await renderWithClerk();
+ expect(screen.queryByRole('group', { name: 'Passkeys' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Add passkey' })).toBeNull();
+ });
+});
+
+describe('Adding a passkey', () => {
+ it('holds Add pending, creates one credential, and displays the new passkey', async () => {
+ const fapi = servePasskeys([]);
+ const authenticator = fakeAuthenticator();
+ await renderWithClerk();
+ expect(screen.getByText('No passkeys added')).toBeVisible();
+ const creation = holdRequests('post', '/v1/me/passkeys');
+ const user = userEvent.setup();
+ const add = screen.getByRole('button', { name: 'Add passkey' });
+ await user.click(add);
+ await waitFor(() => expect(creation.requests).toHaveLength(1));
+ expect(add).toHaveTextContent(/^Add$/);
+ expect(add).toBeDisabled();
+ expect(add).toHaveAttribute('aria-busy', 'true');
+ await user.click(add);
+ expect(creation.requests).toHaveLength(1);
+ creation.release();
+ expect(await screen.findByText('New passkey')).toBeVisible();
+ expect(fapi.client.sessions[0]?.user.passkeys).toHaveLength(1);
+ expect(authenticator).toHaveBeenCalledOnce();
+ expect(add).toBeEnabled();
+ });
+
+ it.each(['session_reverification_required', 'form_param_invalid'])(
+ 'shows %s and allows a manual retry',
+ async code => {
+ const fapi = servePasskeys([]);
+ fakeAuthenticator();
+ await renderWithClerk();
+ const creation = holdRequests('post', '/v1/me/passkeys');
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ await waitFor(() => expect(creation.requests).toHaveLength(1));
+ creation.fail(code);
+ expect(await screen.findByRole('alert')).toHaveTextContent(code);
+ expect(screen.queryByRole('dialog')).toBeNull();
+ expect(creation.requests).toHaveLength(1);
+ const retried = serveFapi(fapi);
+ await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ expect(await screen.findByText('New passkey')).toBeVisible();
+ expect(retried.client.sessions[0]?.user.passkeys).toHaveLength(1);
+ expect(screen.queryByRole('alert')).toBeNull();
+ },
+ );
+
+ it('uses the localized fallback when FAPI rejects creation without a message', async () => {
+ servePasskeys([]);
+ fakeAuthenticator();
+ worker.use(
+ http.post(fapiUrl('/v1/me/passkeys'), () =>
+ HttpResponse.json(
+ {
+ errors: [{ code: 'unknown_passkey_failure', message: '', long_message: '' }],
+ },
+ { status: 400 },
+ ),
+ ),
+ );
+ await renderWithClerk(
+
+
+ ,
+ );
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Add passkey' }));
+ const alert = await screen.findByRole('alert');
+ expect(alert).toHaveTextContent('Impossible de créer la clé.');
+ expect(alert).toHaveAttribute('data-open');
+ expect(alert).toHaveAttribute('data-starting-style');
+ expect(screen.getByRole('button', { name: 'Add passkey' })).toBeEnabled();
+ });
+
+ it('shows an authenticator cancellation and allows another attempt', async () => {
+ servePasskeys([]);
+ fakeAuthenticator().mockRejectedValueOnce(new DOMException('Cancelled', 'NotAllowedError'));
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ expect(await screen.findByRole('alert')).toBeVisible();
+ expect(screen.getByText('No passkeys added')).toBeVisible();
+ await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ expect(await screen.findByText('New passkey')).toBeVisible();
+ });
+});
+
+describe('Renaming a passkey', () => {
+ it('validates a prefilled name, holds the dialog pending, and saves the raw name', async () => {
+ const fapi = servePasskeys();
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
+ const dialog = screen.getByRole('dialog');
+ const input = within(dialog).getByRole('textbox', { name: 'Passkey name' });
+ const save = within(dialog).getByRole('button', { name: 'Save' });
+ expect(input).toHaveValue('Laptop');
+ await waitFor(() => expect(input).toHaveFocus());
+ expect(save).toHaveAttribute('aria-disabled', 'true');
+ await user.clear(input);
+ await user.type(input, 'A');
+ expect(save).toHaveAttribute('aria-disabled', 'true');
+ await user.clear(input);
+ await user.type(input, ' Work laptop ');
+ const rename = holdRequests('post', '/v1/me/passkeys/pk_1');
+ await user.click(save);
+ await waitFor(() => expect(rename.requests).toHaveLength(1));
+ expect(input).toBeDisabled();
+ expect(save).toHaveAttribute('aria-busy', 'true');
+ await user.keyboard('{Escape}');
+ expect(dialog).toBeVisible();
+ rename.release();
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(screen.getByRole('button', { name: 'Manage Work laptop' })).toBeVisible();
+ expect(fapi.client.sessions[0]?.user.passkeys[0]?.name).toBe(' Work laptop ');
+ });
+
+ it('uses the localized API code and parameter for name errors without a server message', async () => {
+ servePasskeys();
+ worker.use(
+ http.post(fapiUrl('/v1/me/passkeys/pk_1'), () =>
+ HttpResponse.json(
+ {
+ errors: [{ code: 'form_param_invalid', message: '', meta: { param_name: 'name' } }],
+ },
+ { status: 400 },
+ ),
+ ),
+ );
+ await renderWithClerk(
+
+
+ ,
+ );
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
+ await user.type(screen.getByRole('textbox', { name: 'Passkey name' }), ' modifié');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent('Ce nom est invalide.');
+ expect(screen.getByRole('textbox', { name: 'Passkey name' })).toHaveValue('Laptop modifié');
+ });
+
+ it('preserves the draft after an API failure and retries with the edited name', async () => {
+ const fapi = servePasskeys();
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
+ const input = screen.getByRole('textbox', { name: 'Passkey name' });
+ await user.clear(input);
+ await user.type(input, 'Work laptop');
+ const rename = holdRequests('post', '/v1/me/passkeys/pk_1');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ await waitFor(() => expect(rename.requests).toHaveLength(1));
+ rename.fail();
+ expect(await screen.findByRole('alert')).toHaveTextContent('form_param_invalid');
+ expect(input).toHaveValue('Work laptop');
+ await user.type(input, ' updated');
+ const retried = serveFapi(fapi);
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(screen.getByText('Work laptop updated')).toBeVisible();
+ expect(retried.client.sessions[0]?.user.passkeys[0]?.name).toBe('Work laptop updated');
+ });
+});
+
+describe('Removing a passkey', () => {
+ it('confirms the literal name, cancels without a mutation, and returns focus', async () => {
+ const fapi = servePasskeys([fapiPasskey({ id: 'pk_1', name: '$& laptop' })]);
+ await renderWithClerk();
+ const user = userEvent.setup();
+ const trigger = screen.getByRole('button', { name: 'Manage $& laptop' });
+ await user.click(trigger);
+ await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
+ expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription('$& laptop will be removed from this account.');
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ await waitFor(() => expect(trigger).toHaveFocus());
+ expect(fapi.client.sessions[0]?.user.passkeys).toHaveLength(1);
+ await user.click(trigger);
+ await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
+ await user.click(screen.getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(fapi.client.sessions[0]?.user.passkeys).toHaveLength(0);
+ });
+
+ it.each([
+ ['Laptop', 'Phone'],
+ ['Phone', 'Laptop'],
+ ])('removes %s and focuses %s', async (removed, remaining) => {
+ const fapi = servePasskeys([
+ fapiPasskey({ id: 'pk_1', name: 'Laptop' }),
+ fapiPasskey({ id: 'pk_2', name: 'Phone' }),
+ ]);
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: `Manage ${removed}` }));
+ await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
+ await user.click(screen.getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ await waitFor(() => expect(screen.getByRole('button', { name: `Manage ${remaining}` })).toHaveFocus());
+ expect(fapi.client.sessions[0]?.user.passkeys.map(passkey => passkey.name)).toEqual([remaining]);
+ });
+
+ it('holds the final removal pending and focuses Add after success', async () => {
+ const fapi = servePasskeys();
+ await renderWithClerk();
+ const removal = holdRequests('post', '/v1/me/passkeys/pk_1');
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
+ await user.click(screen.getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(removal.requests).toHaveLength(1));
+ expect(screen.getByRole('button', { name: 'Remove' })).toHaveAttribute('aria-busy', 'true');
+ await user.keyboard('{Escape}');
+ expect(screen.getByRole('alertdialog')).toBeVisible();
+ removal.release();
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(screen.getByText('No passkeys added')).toBeVisible();
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Add passkey' })).toHaveFocus());
+ expect(fapi.client.sessions[0]?.user.passkeys).toEqual([]);
+ });
+
+ it('keeps confirmation open on verification-required errors and retries manually', async () => {
+ const fapi = servePasskeys();
+ await renderWithClerk();
+ const removal = holdRequests('post', '/v1/me/passkeys/pk_1');
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
+ await user.click(screen.getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(removal.requests).toHaveLength(1));
+ removal.fail('session_reverification_required');
+ expect(await screen.findByRole('alert')).toHaveTextContent('session_reverification_required');
+ expect(screen.getByRole('alertdialog')).toBeVisible();
+ expect(screen.queryByLabelText('Password')).toBeNull();
+ expect(removal.requests).toHaveLength(1);
+ const retried = serveFapi(fapi);
+ await user.click(screen.getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(retried.client.sessions[0]?.user.passkeys).toHaveLength(0);
+ });
+});
+
+describe('Satellite passkeys', () => {
+ it('keeps an empty section visible without Add', async () => {
+ servePasskeys([]);
+ const view = await renderWithClerk();
+ vi.spyOn(view.clerk, 'isSatellite', 'get').mockReturnValue(true);
+ view.rerender();
+ expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible();
+ expect(screen.getByText('No passkeys added')).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Add passkey' })).toBeNull();
+ expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible();
+ });
+
+ it('focuses the section after removing the final passkey without Add', async () => {
+ const fapi = servePasskeys();
+ const view = await renderWithClerk();
+ vi.spyOn(view.clerk, 'isSatellite', 'get').mockReturnValue(true);
+ view.rerender();
+ const removal = holdRequests('post', '/v1/me/passkeys/pk_1');
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
+ await user.click(screen.getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(removal.requests).toHaveLength(1));
+ expect(screen.getByRole('button', { name: 'Remove' })).toHaveAttribute('aria-busy', 'true');
+ removal.release();
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(screen.getByText('No passkeys added')).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Add passkey' })).toBeNull();
+ await waitFor(() => expect(screen.getByRole('group', { name: 'Passkeys' })).toHaveFocus());
+ expect(fapi.client.sessions[0]?.user.passkeys).toEqual([]);
+ });
+});
+
+describe('Passkey reverification', () => {
+ it.todo('completes reverification before creating, renaming, or removing a passkey');
+ it.todo('cancels reverification without changing passkeys and permits retry');
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx
deleted file mode 100644
index d610e9dc132..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx
+++ /dev/null
@@ -1,251 +0,0 @@
-import { act, render, screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { useState } from 'react';
-import { describe, expect, it, vi } from 'vitest';
-
-import { MosaicProvider } from '../../../mosaic-provider';
-import type { UserProfilePasskey, UserProfilePasskeysSectionViewProps } from '../user-profile-passkeys-section.view';
-import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
-
-const passkeys: UserProfilePasskey[] = [
- { id: 'laptop', name: 'MacBook' },
- { id: 'phone', name: 'iPhone' },
-];
-
-function renderView(overrides: Partial = {}) {
- const props: UserProfilePasskeysSectionViewProps = {
- passkeys,
- onAdd: vi.fn(),
- onRename: vi.fn(),
- onRemove: vi.fn(),
- ...overrides,
- };
- return render(
-
-
- ,
- );
-}
-
-describe('passkeys section', () => {
- it('shows existing passkeys without actions when their callbacks are unavailable', () => {
- renderView({ onAdd: undefined, onRename: undefined, onRemove: undefined });
-
- expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible();
- expect(screen.getByRole('heading', { name: 'Passkeys' })).toBeVisible();
- expect(screen.getByText('MacBook')).toBeVisible();
- expect(screen.getByText('iPhone')).toBeVisible();
- expect(screen.queryByRole('button')).not.toBeInTheDocument();
- });
-
- it('confirms the selected passkey and returns focus on cancellation', async () => {
- const user = userEvent.setup();
- const onRemove = vi.fn();
- render(
-
-
- ,
- );
-
- await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription('MacBook will be removed from this account.');
- expect(onRemove).not.toHaveBeenCalled();
- await user.click(screen.getByRole('button', { name: 'Cancel' }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- expect(screen.getByRole('button', { name: 'Manage MacBook' })).toHaveFocus();
-
- await user.click(screen.getByRole('button', { name: 'Manage iPhone' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- const dialog = screen.getByRole('alertdialog');
- expect(dialog).toHaveAccessibleDescription('iPhone will be removed from this account.');
- await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true }));
- expect(onRemove).toHaveBeenCalledExactlyOnceWith('phone');
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- });
- it.each([
- ['MacBook', 'iPhone'],
- ['iPhone', 'MacBook'],
- ])('returns focus from removed %s to remaining %s', async (removed, remaining) => {
- const user = userEvent.setup();
- function Example() {
- const [items, setItems] = useState(passkeys);
- return (
-
- setItems(current => current.filter(item => item.id !== id))}
- />
-
- );
- }
- render();
- await user.click(screen.getByRole('button', { name: `Manage ${removed}` }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
- await waitFor(() => expect(screen.getByRole('button', { name: `Manage ${remaining}` })).toHaveFocus());
- });
-
- it('renames through a prefilled form, preserves the draft after failure, and retries', async () => {
- const user = userEvent.setup();
- const onRename = vi.fn().mockRejectedValueOnce(new Error('Try again')).mockResolvedValueOnce(undefined);
- render(
-
-
- ,
- );
- await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
- await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
- const input = screen.getByRole('textbox', { name: 'Passkey name' });
- expect(input).toHaveValue('MacBook');
- await waitFor(() => expect(input).toHaveFocus());
- expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-disabled', 'true');
- await user.clear(input);
- await user.type(input, 'Work laptop');
- await user.click(screen.getByRole('button', { name: 'Save' }));
- expect(await screen.findByRole('alert')).toHaveTextContent('Try again');
- expect(input).toHaveValue('Work laptop');
- await user.type(input, ' updated');
- expect(screen.getByRole('alert')).toHaveTextContent('Try again');
- expect(input).toHaveValue('Work laptop updated');
- await user.click(screen.getByRole('button', { name: 'Save' }));
- expect(onRename).toHaveBeenLastCalledWith('laptop', 'Work laptop updated');
- await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
- });
-
- it('keeps an empty section visible when Add is unavailable', () => {
- renderView({ passkeys: [], onAdd: undefined });
-
- expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible();
- expect(screen.getByRole('heading', { name: 'Passkeys' })).toBeVisible();
- expect(screen.getByText('No passkeys added')).toBeVisible();
- expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument();
- });
-
- it('keeps Add available with an empty list and creation error', async () => {
- const user = userEvent.setup();
- const onAdd = vi.fn();
- renderView({ passkeys: [], onAdd, addError: 'Could not create passkey' });
-
- expect(screen.getByRole('alert')).toHaveTextContent('Could not create passkey');
- expect(screen.getByRole('alert')).toHaveAttribute('data-open');
- expect(screen.getByRole('alert')).toHaveAttribute('data-starting-style');
- const addButton = screen.getByRole('button', { name: 'Add passkey' });
- expect(addButton).toHaveTextContent(/^Add$/);
- await user.click(addButton);
- expect(onAdd).toHaveBeenCalledOnce();
- });
-
- it.each([true, false])('restores focus after removing the final passkey with Add available: %s', async hasAdd => {
- const user = userEvent.setup();
- let finish = () => {};
- const pending = new Promise(resolve => {
- finish = resolve;
- });
- function Example() {
- const [items, setItems] = useState([passkeys[0]]);
- return (
-
- setItems([passkeys[0]]) : undefined}
- onRemove={async () => {
- await pending;
- setItems([]);
- }}
- />
-
- );
- }
- render();
- await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
- expect(screen.getByRole('button', { name: 'Remove', exact: true })).toHaveAttribute('aria-busy', 'true');
- await act(async () => {
- finish();
- await pending;
- });
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- expect(screen.queryByText('MacBook')).not.toBeInTheDocument();
- if (hasAdd) {
- expect(screen.getByRole('button', { name: 'Add passkey' })).toHaveFocus();
- await user.click(screen.getByRole('button', { name: 'Add passkey' }));
- expect(screen.getByText('MacBook')).toBeVisible();
- } else {
- expect(screen.getByRole('group', { name: 'Passkeys' })).toHaveFocus();
- }
- });
-
- it('retries removal for the same passkey after a failure', async () => {
- const user = userEvent.setup();
- const onRemove = vi.fn().mockRejectedValueOnce(new Error('Removal failed')).mockResolvedValueOnce(undefined);
- render(
-
-
- ,
- );
- await user.click(screen.getByRole('button', { name: 'Manage iPhone' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
- expect(await screen.findByRole('alert')).toHaveTextContent('Removal failed');
- await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
- expect(onRemove.mock.calls).toEqual([['phone'], ['phone']]);
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- });
- it('preserves literal passkey names in menu labels and removal copy', async () => {
- const user = userEvent.setup();
- render(
-
-
- ,
- );
- await user.click(screen.getByRole('button', { name: 'Manage $& laptop' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription('$& laptop will be removed from this account.');
- });
-
- it('keeps rename pending until saving finishes', async () => {
- const user = userEvent.setup();
- let finish = () => {};
- const onRename = vi.fn(
- () =>
- new Promise(resolve => {
- finish = resolve;
- }),
- );
- render(
-
-
- ,
- );
- await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
- await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
- const input = screen.getByRole('textbox', { name: 'Passkey name' });
- await user.clear(input);
- await user.type(input, 'Work laptop');
- await user.click(screen.getByRole('button', { name: 'Save' }));
- expect(input).toBeDisabled();
- expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-busy', 'true');
- await user.keyboard('{Escape}');
- expect(screen.getByRole('dialog')).toBeInTheDocument();
- await act(() => finish());
- await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
- expect(onRename).toHaveBeenCalledExactlyOnceWith('laptop', 'Work laptop');
- });
-});
From f2caa66342dcfd612614aee0938a1b726911bfdf Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 10:41:52 -0600
Subject: [PATCH 04/10] chore(mosaic): keep one empty passkeys changeset
---
.changeset/tidy-passkey-dates.md | 5 -----
1 file changed, 5 deletions(-)
delete mode 100644 .changeset/tidy-passkey-dates.md
diff --git a/.changeset/tidy-passkey-dates.md b/.changeset/tidy-passkey-dates.md
deleted file mode 100644
index e006c85181a..00000000000
--- a/.changeset/tidy-passkey-dates.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@clerk/mosaic': patch
----
-
-Connect passkey management to the signed-in user, clear passkey dialogs and feedback when accounts or sessions change, and use the configured locale for passkey errors and dates. Omit the Authentication section when it has no visible methods.
From e8ad6d57e330da3dfc47bfd0dd1a1953d6ba983e Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 12:21:31 -0600
Subject: [PATCH 05/10] fix(mosaic): correct passkey lifecycle and validation
---
.changeset/passkey-review-remediation.md | 6 +
packages/clerk-js/src/core/clerk.ts | 4 +-
.../mosaic/src/__tests__/feature/fake-fapi.ts | 176 +-----------------
.../__tests__/feature/fake-fapi/passkeys.ts | 159 ++++++++++++++++
.../src/__tests__/feature/fake-fapi/shared.ts | 32 ++++
.../passkeys-contract.feature.test.tsx | 89 +++++++++
.../passkeys-policy.feature.test.tsx | 54 ++++++
...-profile-passkeys-cleanup.feature.test.tsx | 60 ++++++
...file-passkeys-composition.feature.test.tsx | 87 +++++++++
...ile-passkeys-interactions.feature.test.tsx | 108 +++++++++++
...rofile-passkeys-lifecycle.feature.test.tsx | 69 ++++---
...-profile-passkeys-section.feature.test.tsx | 10 +-
...ofile-passkeys-validation.feature.test.tsx | 83 +++++++++
.../user-profile-security-panel.view.test.tsx | 29 ---
.../user-profile-passkey-row.view.tsx | 10 +-
.../user-profile-passkeys-section.messages.ts | 1 +
.../user-profile-passkeys-section.view.tsx | 8 +-
...ser-profile-passkeys-section.model.test.ts | 20 ++
.../user-profile-passkeys-section.model.ts | 34 +++-
.../user-profile-passkeys-section.tsx | 3 +-
.../user-profile-passkeys-section.types.ts | 8 +
.../user-profile-rename-passkey.controller.ts | 110 +++--------
.../user-profile-rename-passkey.dialog.tsx | 54 +++---
.../src/hooks/use-mosaic-environment.ts | 16 +-
24 files changed, 878 insertions(+), 352 deletions(-)
create mode 100644 .changeset/passkey-review-remediation.md
create mode 100644 packages/mosaic/src/__tests__/feature/fake-fapi/passkeys.ts
create mode 100644 packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/passkeys-contract.feature.test.tsx
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/passkeys-policy.feature.test.tsx
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-cleanup.feature.test.tsx
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-interactions.feature.test.tsx
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-validation.feature.test.tsx
diff --git a/.changeset/passkey-review-remediation.md b/.changeset/passkey-review-remediation.md
new file mode 100644
index 00000000000..2c5de362c9e
--- /dev/null
+++ b/.changeset/passkey-review-remediation.md
@@ -0,0 +1,6 @@
+---
+'@clerk/clerk-js': patch
+'@clerk/mosaic': patch
+---
+
+Keep passkey sections tied to the active account when switching users, show passkey name errors on the input, and refresh Mosaic eligibility when instance settings change.
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index f5eaa05aa92..b3b16588a16 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -3236,6 +3236,7 @@ export class Clerk implements ClerkInterface {
public updateEnvironment(environment: EnvironmentResource): asserts this is { environment: EnvironmentResource } {
this.environment = environment;
+ this.#emit();
}
__internal_setCountry = (country: string | null) => {
@@ -3314,7 +3315,7 @@ export class Clerk implements ClerkInterface {
// TODO: Fix this properly
// eslint-disable-next-line @typescript-eslint/require-await
__internal_setEnvironment = async (env: EnvironmentJSON) => {
- this.environment = new Environment(env);
+ this.updateEnvironment(new Environment(env));
// TODO @nikos update
// if (Clerk.mountComponentRenderer) {
@@ -3730,6 +3731,7 @@ export class Clerk implements ClerkInterface {
});
eventBus.on(events.EnvironmentUpdate, () => {
+ this.#emit();
// Cache the environment snapshot for 24 hours
SafeLocalStorage.setItem(
CLERK_ENVIRONMENT_STORAGE_ENTRY,
diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
index e2ea07d8cdf..aad060ecf28 100644
--- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
@@ -6,15 +6,15 @@ import type {
OAuthProvider,
OrganizationMembershipJSON,
OrganizationSuggestionJSON,
- PasskeyJSON,
- SessionJSON,
UserJSON,
UserOrganizationInvitationJSON,
} from '@clerk/shared/types';
-import { http, HttpResponse, type JsonBodyType } from 'msw';
+import { http, HttpResponse } from 'msw';
import { setupWorker } from 'msw/browser';
import { enterpriseHandlers, type FakeEnterpriseLinking } from './fake-fapi/enterprise';
+import { type FakePasskeysSeed, passkeyHandlers } from './fake-fapi/passkeys';
+import { envelope, error, findSession, missing, updateUser } from './fake-fapi/shared';
import {
createVerificationState,
type FakeVerificationSeed,
@@ -30,7 +30,6 @@ import {
fapiMembership,
fapiOrganization,
fapiPage,
- fapiPasskey,
fapiToken,
fapiVerification,
} from './fapi';
@@ -56,6 +55,7 @@ export interface FakeFapiState {
export type FakeFapiSeed = Partial> & {
verification?: FakeVerificationSeed;
enterpriseLinking?: Partial;
+ passkeys?: FakePasskeysSeed;
};
const unhandled: string[] = [];
@@ -77,10 +77,6 @@ export function takeUnhandledRequests(): string[] {
return unhandled.splice(0);
}
-function envelope(response: JsonBodyType, client: ClientJSON | null) {
- return HttpResponse.json({ response, client });
-}
-
function page(items: T[], url: URL) {
const offset = Number(url.searchParams.get('offset') ?? 0);
const limit = Number(url.searchParams.get('limit') ?? items.length);
@@ -92,36 +88,11 @@ function withStatus(items: T[], url: URL): T[] {
return statuses.length ? items.filter(item => statuses.includes(item.status)) : items;
}
-function findSession(state: FakeFapiState, id: unknown): SessionJSON | undefined {
- return state.client.sessions.find(session => session.id === id);
-}
-
-function error(code: string, status = 400) {
- return HttpResponse.json({ errors: [{ code, message: code, long_message: code }] }, { status });
-}
-
function activeUser(state: FakeFapiState): UserJSON | undefined {
return findSession(state, state.client.last_active_session_id)?.user;
}
-
-function requestUser(state: FakeFapiState, request: Request): UserJSON | undefined {
- const sessionId = new URL(request.url).searchParams.get('_clerk_session_id') ?? state.client.last_active_session_id;
- return findSession(state, sessionId)?.user;
-}
-
-function updateUser(state: FakeFapiState, user: UserJSON): void {
- state.client = {
- ...state.client,
- sessions: state.client.sessions.map(session => (session.user.id === user.id ? { ...session, user } : session)),
- };
-}
-
-function missing() {
- return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
-}
-
export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
- const { verification, enterpriseLinking, ...rest } = seed;
+ const { verification, enterpriseLinking, passkeys, ...rest } = seed;
const state: FakeFapiState = {
environment: fapiEnvironment(),
client: fapiClient(),
@@ -142,12 +113,10 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
},
};
- const pendingPasskeys = new Map();
- let nextPasskeyId = 1;
-
worker.use(
...verificationHandlers(state, fapiUrl),
...enterpriseHandlers(state, fapiUrl),
+ ...passkeyHandlers(state, fapiUrl, passkeys),
http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)),
http.get(fapiUrl('/v1/client'), () => envelope(state.client, null)),
http.get(fapiUrl('/v1/me'), () => {
@@ -278,139 +247,6 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
state.client = { ...state.client, sessions: [], last_active_session_id: null };
return envelope(state.client, state.client);
}),
- http.post(fapiUrl('/v1/me/passkeys'), ({ request }) => {
- const user = requestUser(state, request);
- if (!user) {
- return missing();
- }
- if (!state.environment.user_settings.attributes.passkey.enabled) {
- return error('feature_not_enabled', 403);
- }
- if (
- state.environment.user_settings.enterprise_sso.enabled &&
- user.enterprise_accounts.some(
- account =>
- account.enterprise_connection?.active && account.enterprise_connection.disable_additional_identifications,
- )
- ) {
- return error('enterprise_sso_additional_identifications_disabled', 422);
- }
- if (user.passkeys.length >= 10) {
- return error('passkey_quota_exceeded', 403);
- }
- const now = Date.now();
- for (const [id, pending] of pendingPasskeys) {
- if (pending.userId === user.id && pending.expiresAt <= now) {
- pendingPasskeys.delete(id);
- }
- }
- const expiresAt = now + 60000;
- const passkey = fapiPasskey({
- id: `passkey_${nextPasskeyId++}`,
- name: null,
- verification: {
- id: 'verification_passkey',
- object: 'verification',
- status: 'unverified',
- verified_at_client: '',
- strategy: 'passkey',
- attempts: 0,
- expire_at: expiresAt,
- error: { code: '', message: '' },
- nonce: JSON.stringify({
- challenge: 'Y2hhbGxlbmdl',
- rp: { name: 'Acme', id: 'localhost' },
- user: { id: 'dXNlcg', name: 'user@example.com', displayName: 'Test user' },
- pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
- }),
- },
- });
- pendingPasskeys.set(passkey.id, { userId: user.id, passkey, expiresAt });
- return envelope(passkey, state.client);
- }),
- http.post(fapiUrl('/v1/me/passkeys/:id/attempt_verification'), async ({ params, request }) => {
- const pending = typeof params.id === 'string' ? pendingPasskeys.get(params.id) : undefined;
- const user = state.client.sessions.find(session => session.user.id === pending?.userId)?.user;
- if (!requestUser(state, request) || !user || !pending) {
- return missing();
- }
- const { passkey } = pending;
- const verification = passkey.verification;
- if (!verification || pending.expiresAt <= Date.now()) {
- return error('verification_expired', 400);
- }
- const body = new URLSearchParams(await request.text());
- if (body.get('strategy') !== 'passkey' || !body.get('public_key_credential')) {
- return HttpResponse.json(
- { errors: [{ code: 'form_param_missing', message: 'Passkey credential required.' }] },
- { status: 400 },
- );
- }
- const verified = {
- ...passkey,
- name: passkey.name ?? 'New passkey',
- last_used_at: Date.now(),
- verification: {
- ...verification,
- status: 'verified' as const,
- attempts: verification.attempts + 1,
- verified_at_client: state.client.id,
- nonce: undefined,
- error: undefined,
- },
- };
- pendingPasskeys.delete(passkey.id);
- updateUser(state, { ...user, passkeys: [...user.passkeys, verified] });
- return envelope(verified, state.client);
- }),
- http.post(fapiUrl('/v1/me/passkeys/:id'), async ({ params, request }) => {
- const user = requestUser(state, request);
- if (!user || typeof params.id !== 'string') {
- return missing();
- }
- const pending = pendingPasskeys.get(params.id);
- const passkey =
- user.passkeys.find(candidate => candidate.id === params.id) ??
- (pending?.userId === user.id ? pending.passkey : undefined);
- if (!passkey) {
- return missing();
- }
- const method = new URL(request.url).searchParams.get('_method');
- if (method === 'DELETE') {
- pendingPasskeys.delete(passkey.id);
- updateUser(state, { ...user, passkeys: user.passkeys.filter(candidate => candidate.id !== passkey.id) });
- return envelope({ object: 'passkey', id: passkey.id, deleted: true }, state.client);
- }
- if (method === 'PATCH') {
- const body = new URLSearchParams(await request.text());
- const name = body.get('name');
- if (name !== null && new TextEncoder().encode(name).length > 256) {
- return HttpResponse.json(
- {
- errors: [
- {
- code: 'form_param_max_length_exceeded',
- message: 'Passkey name is too long.',
- meta: { param_name: 'name' },
- },
- ],
- },
- { status: 422 },
- );
- }
- const renamed = { ...passkey, name: name ?? passkey.name };
- if (pending?.userId === user.id) {
- pendingPasskeys.set(passkey.id, { ...pending, passkey: renamed });
- return envelope(renamed, state.client);
- }
- updateUser(state, {
- ...user,
- passkeys: user.passkeys.map(candidate => (candidate.id === passkey.id ? renamed : candidate)),
- });
- return envelope(renamed, state.client);
- }
- return missing();
- }),
http.get(fapiUrl('/v1/me/organization_memberships'), ({ request }) =>
envelope(page(state.memberships, new URL(request.url)), null),
),
diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/passkeys.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/passkeys.ts
new file mode 100644
index 00000000000..efceddf7144
--- /dev/null
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi/passkeys.ts
@@ -0,0 +1,159 @@
+import type { PasskeyJSON } from '@clerk/shared/types';
+import { http, HttpResponse } from 'msw';
+
+import type { FakeFapiState } from '../fake-fapi';
+import { fapiPasskey } from '../fapi';
+import { envelope, error, missing, requestUser, updateUser } from './shared';
+
+export interface FakePasskeysSeed {
+ name?: string;
+ authenticatorName?: string;
+}
+
+export function passkeyHandlers(
+ state: Pick,
+ fapiUrl: (path: string) => string,
+ seed: FakePasskeysSeed = {},
+) {
+ const pendingPasskeys = new Map();
+ let nextPasskeyId = 1;
+
+ return [
+ http.post(fapiUrl('/v1/me/passkeys'), ({ request }) => {
+ const user = requestUser(state, request);
+ if (!user) {
+ return missing();
+ }
+ if (!state.environment.user_settings.attributes.passkey.enabled) {
+ return error('feature_not_enabled', 403);
+ }
+ if (
+ state.environment.user_settings.enterprise_sso.enabled &&
+ user.enterprise_accounts.some(
+ account =>
+ account.enterprise_connection?.active && account.enterprise_connection.disable_additional_identifications,
+ )
+ ) {
+ return error('enterprise_sso_additional_identifications_disabled', 422);
+ }
+ if (user.passkeys.length >= 10) {
+ return error('passkey_quota_exceeded', 403);
+ }
+ const now = Date.now();
+ for (const [id, pending] of pendingPasskeys) {
+ if (pending.userId === user.id && pending.expiresAt <= now) {
+ pendingPasskeys.delete(id);
+ }
+ }
+ const expiresAt = now + 10 * 60_000;
+ const passkey = fapiPasskey({
+ id: `passkey_${nextPasskeyId++}`,
+ name: seed.name ?? 'Chrome on macOS',
+ verification: {
+ id: 'verification_passkey',
+ object: 'verification',
+ status: 'unverified',
+ verified_at_client: '',
+ strategy: 'passkey',
+ attempts: 0,
+ expire_at: expiresAt,
+ error: { code: '', message: '' },
+ nonce: JSON.stringify({
+ challenge: 'Y2hhbGxlbmdl',
+ rp: { name: 'Acme', id: 'localhost' },
+ user: { id: 'dXNlcg', name: 'user@example.com', displayName: 'Test user' },
+ pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
+ }),
+ },
+ });
+ pendingPasskeys.set(passkey.id, { userId: user.id, passkey, expiresAt });
+ return envelope(passkey, state.client);
+ }),
+ http.post(fapiUrl('/v1/me/passkeys/:id/attempt_verification'), async ({ params, request }) => {
+ const pending = typeof params.id === 'string' ? pendingPasskeys.get(params.id) : undefined;
+ const user = requestUser(state, request);
+ if (!user || !pending) {
+ return missing();
+ }
+ if (pending.userId !== user.id) {
+ return error('resource_forbidden', 403);
+ }
+ const { passkey } = pending;
+ const verification = passkey.verification;
+ if (!verification || pending.expiresAt <= Date.now()) {
+ return error('verification_expired', 400);
+ }
+ const body = new URLSearchParams(await request.text());
+ if (body.get('strategy') !== 'passkey' || !body.get('public_key_credential')) {
+ return HttpResponse.json(
+ { errors: [{ code: 'form_param_missing', message: 'Passkey credential required.' }] },
+ { status: 400 },
+ );
+ }
+ const verified = {
+ ...passkey,
+ name: seed.authenticatorName || passkey.name,
+ last_used_at: Date.now(),
+ verification: {
+ ...verification,
+ status: 'verified' as const,
+ attempts: verification.attempts + 1,
+ verified_at_client: state.client.id,
+ nonce: undefined,
+ error: undefined,
+ },
+ };
+ pendingPasskeys.delete(passkey.id);
+ updateUser(state, { ...user, passkeys: [...user.passkeys, verified] });
+ return envelope(verified, state.client);
+ }),
+ http.post(fapiUrl('/v1/me/passkeys/:id'), async ({ params, request }) => {
+ const user = requestUser(state, request);
+ if (!user || typeof params.id !== 'string') {
+ return missing();
+ }
+ const pending = pendingPasskeys.get(params.id);
+ const passkey =
+ user.passkeys.find(candidate => candidate.id === params.id) ??
+ (pending?.userId === user.id ? pending.passkey : undefined);
+ if (!passkey) {
+ return missing();
+ }
+ const method = new URL(request.url).searchParams.get('_method');
+ if (method === 'DELETE') {
+ pendingPasskeys.delete(passkey.id);
+ updateUser(state, { ...user, passkeys: user.passkeys.filter(candidate => candidate.id !== passkey.id) });
+ return envelope({ object: 'passkey', id: passkey.id, deleted: true }, state.client);
+ }
+ if (method === 'PATCH') {
+ const body = new URLSearchParams(await request.text());
+ const name = body.get('name');
+ if (name !== null && new TextEncoder().encode(name).length > 256) {
+ return HttpResponse.json(
+ {
+ errors: [
+ {
+ code: 'form_param_max_length_exceeded',
+ message: 'Passkey name is too long.',
+ meta: { param_name: 'name' },
+ },
+ ],
+ },
+ { status: 422 },
+ );
+ }
+ const renamed = { ...passkey, name: name ?? passkey.name };
+ if (pending?.userId === user.id) {
+ pendingPasskeys.set(passkey.id, { ...pending, passkey: renamed });
+ return envelope(renamed, state.client);
+ }
+ updateUser(state, {
+ ...user,
+ passkeys: user.passkeys.map(candidate => (candidate.id === passkey.id ? renamed : candidate)),
+ });
+ return envelope(renamed, state.client);
+ }
+ return missing();
+ }),
+ ];
+}
diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts
new file mode 100644
index 00000000000..05381d18a99
--- /dev/null
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts
@@ -0,0 +1,32 @@
+import type { ClientJSON, SessionJSON, UserJSON } from '@clerk/shared/types';
+import { HttpResponse, type JsonBodyType } from 'msw';
+
+type ClientState = { client: ClientJSON };
+
+export function envelope(response: JsonBodyType, client: ClientJSON | null) {
+ return HttpResponse.json({ response, client });
+}
+
+export function findSession(state: ClientState, id: unknown): SessionJSON | undefined {
+ return state.client.sessions.find(session => session.id === id);
+}
+
+export function error(code: string, status = 400) {
+ return HttpResponse.json({ errors: [{ code, message: code, long_message: code }] }, { status });
+}
+
+export function requestUser(state: ClientState, request: Request): UserJSON | undefined {
+ const sessionId = new URL(request.url).searchParams.get('_clerk_session_id') ?? state.client.last_active_session_id;
+ return findSession(state, sessionId)?.user;
+}
+
+export function updateUser(state: ClientState, user: UserJSON): void {
+ state.client = {
+ ...state.client,
+ sessions: state.client.sessions.map(session => (session.user.id === user.id ? { ...session, user } : session)),
+ };
+}
+
+export function missing() {
+ return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
+}
diff --git a/packages/mosaic/src/features/user-profile/__tests__/passkeys-contract.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/passkeys-contract.feature.test.tsx
new file mode 100644
index 00000000000..0bb10f03036
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/passkeys-contract.feature.test.tsx
@@ -0,0 +1,89 @@
+import { act, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
+import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { UserProfilePasskeysSection } from '../user-profile-passkeys-section/user-profile-passkeys-section';
+
+function serveRegistration(passkeys = { name: 'Chrome on macOS', authenticatorName: '' }) {
+ const environment = fapiEnvironment();
+ environment.user_settings.attributes.passkey.enabled = true;
+ vi.spyOn(navigator, 'webdriver', 'get').mockReturnValue(false);
+ vi.spyOn(navigator.credentials, 'create').mockResolvedValue({
+ id: 'credential_1',
+ type: 'public-key',
+ rawId: new Uint8Array([1, 2, 3]).buffer,
+ authenticatorAttachment: 'platform',
+ response: {
+ clientDataJSON: new TextEncoder().encode('{}').buffer,
+ attestationObject: new Uint8Array([4, 5, 6]).buffer,
+ getTransports: () => ['internal'],
+ },
+ });
+ const seed = {
+ environment,
+ passkeys,
+ client: fapiClient([
+ fapiSession({ id: 'sess_a', user: fapiUser({ id: 'user_a' }) }),
+ fapiSession({ id: 'sess_b', user: fapiUser({ id: 'user_b' }) }),
+ ]),
+ };
+ return serveFapi(seed);
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('Registering passkeys against the backend contract', () => {
+ it('rejects another user verifying a pending registration without changing either account', async () => {
+ const fapi = serveRegistration();
+ const { clerk } = await renderWithClerk();
+ const alice = clerk.user;
+ if (!alice) {
+ throw new Error('Missing Alice');
+ }
+ const creation = holdRequests('post', '/v1/me/passkeys');
+ const outcome = alice.createPasskey().then(
+ result => result,
+ (error: unknown) => error,
+ );
+ await waitFor(() => expect(creation.requests).toHaveLength(1));
+ await act(() => clerk.setActive({ session: 'sess_b' }));
+ creation.release();
+ expect(await outcome).toMatchObject({ status: 403, errors: [{ code: 'resource_forbidden' }] });
+ expect(fapi.client.sessions.map(session => session.user.passkeys)).toEqual([[], []]);
+ expect(screen.queryByText('Chrome on macOS')).toBeNull();
+ });
+
+ it.each([
+ { minutes: 2, expired: false },
+ { minutes: 10, expired: true },
+ ])('handles verification after $minutes minutes', async ({ minutes, expired }) => {
+ const fapi = serveRegistration();
+ await renderWithClerk();
+ const verification = holdRequests('post', '/v1/me/passkeys/passkey_1/attempt_verification');
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Add passkey' }));
+ await waitFor(() => expect(verification.requests).toHaveLength(1));
+ vi.spyOn(Date, 'now').mockReturnValue(Date.now() + minutes * 60_000);
+ verification.release();
+ if (expired) {
+ expect(await screen.findByRole('alert')).toBeVisible();
+ expect(fapi.client.sessions[0]?.user.passkeys).toEqual([]);
+ } else {
+ expect(await screen.findByText('Chrome on macOS')).toBeVisible();
+ expect(screen.queryByRole('alert')).toBeNull();
+ }
+ });
+
+ it.each([
+ { name: 'Firefox on Windows', authenticatorName: '', expected: 'Firefox on Windows' },
+ { name: 'Chrome on macOS', authenticatorName: 'iCloud Keychain', expected: 'iCloud Keychain' },
+ ])('shows $expected after registration', async ({ name, authenticatorName, expected }) => {
+ const fapi = serveRegistration({ name, authenticatorName });
+ await renderWithClerk();
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Add passkey' }));
+ expect(await screen.findByText(expected)).toBeVisible();
+ expect(fapi.client.sessions[0]?.user.passkeys[0]?.name).toBe(expected);
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/passkeys-policy.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/passkeys-policy.feature.test.tsx
new file mode 100644
index 00000000000..1ebfd035cff
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/passkeys-policy.feature.test.tsx
@@ -0,0 +1,54 @@
+import { act, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { serveFapi } from '../../../__tests__/feature/fake-fapi';
+import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { UserProfilePasskeysSection } from '../user-profile-passkeys-section/user-profile-passkeys-section';
+
+function servePasskeys() {
+ vi.spyOn(navigator, 'webdriver', 'get').mockReturnValue(false);
+ const environment = fapiEnvironment();
+ environment.user_settings.attributes.passkey.enabled = true;
+ return serveFapi({
+ environment,
+ client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]),
+ });
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('Updating the rendered passkey policy', () => {
+ it('removes passkeys after the environment is replaced', async () => {
+ const fapi = servePasskeys();
+ const { clerk } = await renderWithClerk();
+ expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Add passkey' })).toBeVisible();
+
+ const environment = fapiEnvironment();
+ environment.user_settings.attributes.passkey.enabled = false;
+ fapi.environment = environment;
+ await act(() => clerk.__internal_setEnvironment(environment));
+
+ await waitFor(() => expect(screen.queryByRole('group', { name: 'Passkeys' })).toBeNull());
+ expect(screen.queryByRole('button', { name: 'Add passkey' })).toBeNull();
+ });
+
+ it('removes passkeys after the existing environment fetches disabled policy', async () => {
+ const fapi = servePasskeys();
+ const { clerk } = await renderWithClerk();
+ expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Add passkey' })).toBeVisible();
+ const environment = clerk.__internal_environment;
+ if (!environment) {
+ throw new Error('Missing loaded environment');
+ }
+
+ fapi.environment.user_settings.attributes.passkey.enabled = false;
+ await act(() => environment.fetch());
+
+ expect(clerk.__internal_environment).toBe(environment);
+ await waitFor(() => expect(screen.queryByRole('group', { name: 'Passkeys' })).toBeNull());
+ expect(screen.queryByRole('button', { name: 'Add passkey' })).toBeNull();
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-cleanup.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-cleanup.feature.test.tsx
new file mode 100644
index 00000000000..0cde55dc3f4
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-cleanup.feature.test.tsx
@@ -0,0 +1,60 @@
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, expect, it, vi } from 'vitest';
+
+import { fapiUrl, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
+import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { MosaicProvider } from '../../../mosaic-provider';
+import { UserProfilePasskeysSection } from '../user-profile-passkeys-section/user-profile-passkeys-section';
+
+afterEach(() => vi.restoreAllMocks());
+
+function cancelledRegistration() {
+ vi.spyOn(navigator, 'webdriver', 'get').mockReturnValue(false);
+ const environment = fapiEnvironment();
+ environment.user_settings.attributes.passkey.enabled = true;
+ const state = serveFapi({
+ environment,
+ client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', passkeys: [] }) })]),
+ });
+ vi.spyOn(navigator.credentials, 'create').mockRejectedValue(new DOMException('Cancelled', 'NotAllowedError'));
+ return state;
+}
+
+async function pendingResourceStatus() {
+ const response = await fetch(fapiUrl('/v1/me/passkeys/passkey_1/attempt_verification'), {
+ method: 'POST',
+ body: new URLSearchParams({ strategy: 'passkey', public_key_credential: '{}' }),
+ });
+ return response.status;
+}
+
+it('removes the pending registration after cancellation, rather than merely hiding its row', async () => {
+ cancelledRegistration();
+ await renderWithClerk();
+ const cleanup = holdRequests('post', '/v1/me/passkeys/passkey_1');
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Add passkey' }));
+ await waitFor(() => expect(cleanup.requests).toHaveLength(1));
+ expect(new URL(cleanup.requests[0]?.url ?? '').searchParams.get('_method')).toBe('DELETE');
+ cleanup.release();
+ expect(await screen.findByRole('alert')).toBeVisible();
+ expect(await pendingResourceStatus()).toBe(404);
+});
+
+it('retains the localized cancellation error when pending cleanup fails', async () => {
+ cancelledRegistration();
+ await renderWithClerk(
+
+
+ ,
+ );
+ const cleanup = holdRequests('post', '/v1/me/passkeys/passkey_1');
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Add passkey' }));
+ await waitFor(() => expect(cleanup.requests).toHaveLength(1));
+ cleanup.fail('session_reverification_required', 'Cleanup requires reverification');
+ expect(await screen.findByRole('alert')).toHaveTextContent('Création annulée.');
+ expect(screen.getByRole('button', { name: 'Add passkey' })).toBeEnabled();
+});
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
new file mode 100644
index 00000000000..925c8ed9977
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx
@@ -0,0 +1,87 @@
+import { act, screen, within } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+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 { useUserProfilePasskeysSlot } from '../user-profile-passkeys-section/user-profile-passkeys-section';
+import { useUserProfilePasswordSlot } from '../user-profile-password-section/user-profile-password-section';
+import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
+
+afterEach(() => vi.restoreAllMocks());
+
+function serveAccounts() {
+ vi.spyOn(navigator, 'webdriver', 'get').mockReturnValue(false);
+ const environment = fapiEnvironment();
+ environment.user_settings.attributes.passkey.enabled = true;
+ return serveFapi({
+ environment,
+ client: fapiClient([
+ fapiSession({
+ id: 'sess_a',
+ user: fapiUser({ id: 'user_a', passkeys: [fapiPasskey({ id: 'pk_a', name: 'Alice laptop' })] }),
+ }),
+ fapiSession({
+ id: 'sess_b',
+ user: fapiUser({ id: 'user_b', passkeys: [fapiPasskey({ id: 'pk_b', name: 'Bob phone' })] }),
+ }),
+ ]),
+ });
+}
+
+function SecurityHost() {
+ const passwordSlot = useUserProfilePasswordSlot();
+ const passkeysSlot = useUserProfilePasskeysSlot();
+ return (
+
+ );
+}
+
+function ProfileHost() {
+ const passwordSlot = useUserProfilePasswordSlot();
+ const passkeysSlot = useUserProfilePasskeysSlot();
+ return (
+ {}}
+ pages={{ account: {}, security: { passwordSlot, passkeysSlot, mfaMethods: [], devices: [] } }}
+ />
+ );
+}
+
+describe('Composing connected authentication sections', () => {
+ it.each([
+ { name: 'Security', Host: SecurityHost },
+ { name: 'UserProfile', Host: ProfileHost },
+ ])('keeps one current-account section of each kind after a user switch in $name', async ({ Host }) => {
+ serveAccounts();
+ const { clerk } = await renderWithClerk();
+ expect(screen.getAllByRole('group', { name: 'Password' })).toHaveLength(1);
+ expect(screen.getAllByRole('group', { name: 'Passkeys' })).toHaveLength(1);
+ await act(() => clerk.setActive({ session: 'sess_b' }));
+ expect(await screen.findByText('Bob phone')).toBeVisible();
+ expect(screen.getAllByRole('group', { name: 'Password' })).toHaveLength(1);
+ expect(screen.getAllByRole('group', { name: 'Passkeys' })).toHaveLength(1);
+ expect(screen.queryByText('Alice laptop')).toBeNull();
+ });
+
+ it('places Password and Passkeys before MFA and devices', async () => {
+ serveAccounts();
+ await renderWithClerk();
+ const authentication = screen.getByRole('region', { name: 'Authentication' });
+ const password = within(authentication).getByText('Password');
+ const passkeys = within(authentication).getByText('Passkeys');
+ const mfa = within(authentication).getByText('2-step verification');
+ expect(password.compareDocumentPosition(passkeys) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(passkeys.compareDocumentPosition(mfa) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(
+ mfa.compareDocumentPosition(screen.getByText('Active devices')) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-interactions.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-interactions.feature.test.tsx
new file mode 100644
index 00000000000..43cfb6b133e
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-interactions.feature.test.tsx
@@ -0,0 +1,108 @@
+import { act, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
+import { fapiClient, fapiEnvironment, fapiPasskey, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { MosaicProvider } from '../../../mosaic-provider';
+import { UserProfilePasskeysSection } from '../user-profile-passkeys-section/user-profile-passkeys-section';
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+function accounts() {
+ vi.spyOn(navigator, 'webdriver', 'get').mockReturnValue(false);
+ const environment = fapiEnvironment();
+ environment.user_settings.attributes.passkey.enabled = true;
+ return serveFapi({
+ environment,
+ client: fapiClient([
+ fapiSession({
+ id: 'sess_a',
+ user: fapiUser({ id: 'user_a', passkeys: [fapiPasskey({ id: 'pk_a', name: 'Alice laptop' })] }),
+ }),
+ fapiSession({
+ id: 'sess_b',
+ user: fapiUser({ id: 'user_b', passkeys: [fapiPasskey({ id: 'pk_b', name: 'Bob phone' })] }),
+ }),
+ ]),
+ });
+}
+
+async function openRename() {
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Alice laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
+ const input = screen.getByRole('textbox', { name: 'Passkey name' });
+ await waitFor(() => expect(input).toHaveFocus());
+ return { user, input };
+}
+
+describe('Passkey interactions across resource changes', () => {
+ it('refreshes the saved name when the same row is renamed twice', async () => {
+ accounts();
+ await renderWithClerk();
+ const { user, input } = await openRename();
+ await user.clear(input);
+ await user.type(input, 'Alice work laptop');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ await user.click(screen.getByRole('button', { name: 'Manage Alice work laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
+ expect(screen.getByRole('textbox', { name: 'Passkey name' })).toHaveValue('Alice work laptop');
+ expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('drops a held rename failure after switching users', async () => {
+ accounts();
+ const { clerk } = await renderWithClerk();
+ const { user, input } = await openRename();
+ await user.type(input, ' edited');
+ const held = holdRequests('post', '/v1/me/passkeys/pk_a');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ await waitFor(() => expect(held.requests).toHaveLength(1));
+ await act(() => clerk.setActive({ session: 'sess_b' }));
+ held.fail('form_param_invalid', 'Alice delayed rename error');
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(screen.getByText('Bob phone')).toBeVisible();
+ expect(screen.queryByRole('alert')).toBeNull();
+ });
+
+ it('reports a removed stale row through the localized model error', async () => {
+ const fapi = accounts();
+ await renderWithClerk(
+
+
+ ,
+ );
+ const { user, input } = await openRename();
+ fapi.client.sessions = fapi.client.sessions.map(session =>
+ session.id === 'sess_a' ? { ...session, user: { ...session.user, passkeys: [] } } : session,
+ );
+ await user.type(input, ' edited');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent('Cette clé a été supprimée.');
+ expect(input).toHaveValue('Alice laptop edited');
+ });
+
+ it('deduplicates removal keyboard actions while the request is held', async () => {
+ accounts();
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Alice laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
+ const held = holdRequests('post', '/v1/me/passkeys/pk_a');
+ const remove = screen.getByRole('button', { name: 'Remove' });
+ await user.click(remove);
+ await waitFor(() => expect(held.requests).toHaveLength(1));
+ await user.keyboard('{Enter}{Enter}{Escape}');
+ expect(held.requests).toHaveLength(1);
+ expect(screen.getByRole('alertdialog')).toBeVisible();
+ held.release();
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(screen.getByText('No passkeys added')).toBeVisible();
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
index 1cc2432238e..c63f1e191c6 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
@@ -138,7 +138,7 @@ describe('Changing the active passkey account', () => {
await waitFor(() =>
expect(
fapi.client.sessions.find(session => session.id === 'sess_a')?.user.passkeys.map(passkey => passkey.name),
- ).toContain('New passkey'),
+ ).toContain('Chrome on macOS'),
);
expect(
fapi.client.sessions.find(session => session.id === 'sess_b')?.user.passkeys.map(passkey => passkey.name),
@@ -204,7 +204,9 @@ describe('Submitting passkey actions', () => {
const { clerk } = await renderWithClerk();
const user = clerk.user;
expect(user).toBeDefined();
- if (!user) {throw new Error('Missing user');}
+ if (!user) {
+ throw new Error('Missing user');
+ }
await expect(user.createPasskey()).rejects.toBeDefined();
});
@@ -242,7 +244,9 @@ describe('Passkey backend contract', () => {
authenticator();
const { clerk } = await renderWithClerk();
const current = clerk.user;
- if (!current) {throw new Error('Missing user');}
+ if (!current) {
+ throw new Error('Missing user');
+ }
await expect(current.createPasskey()).rejects.toMatchObject({ errors: [{ code: 'passkey_quota_exceeded' }] });
expect(fapi.client.sessions.find(session => session.id === 'sess_a')?.user.passkeys).toHaveLength(10);
});
@@ -259,7 +263,9 @@ describe('Passkey backend contract', () => {
authenticator();
const { clerk } = await renderWithClerk();
const current = clerk.user;
- if (!current) {throw new Error('Missing user');}
+ if (!current) {
+ throw new Error('Missing user');
+ }
await expect(current.createPasskey()).rejects.toMatchObject({
errors: [{ code: 'enterprise_sso_additional_identifications_disabled' }],
});
@@ -280,7 +286,9 @@ describe('Passkey backend contract', () => {
authenticator();
const { clerk } = await renderWithClerk();
const current = clerk.user;
- if (!current) {throw new Error('Missing user');}
+ if (!current) {
+ throw new Error('Missing user');
+ }
const passkey = await current.createPasskey();
expect(passkey.verification).toMatchObject({ strategy: 'passkey', status: 'verified' });
expect(passkey.lastUsedAt).toBeInstanceOf(Date);
@@ -291,7 +299,9 @@ describe('Passkey backend contract', () => {
serveAccounts();
const { clerk } = await renderWithClerk();
const passkey = clerk.user?.passkeys.find(candidate => candidate.id === 'pk_a');
- if (!passkey) {throw new Error('Missing passkey');}
+ if (!passkey) {
+ throw new Error('Missing passkey');
+ }
const name = '🔑'.repeat(count);
if (count === 64) {
const renamed = await passkey.update({ name });
@@ -309,7 +319,9 @@ describe('Passkey identity and policy at action time', () => {
it('clears Add state when the same user changes sessions', async () => {
const fapi = serveAccounts();
const alice = fapi.client.sessions.find(session => session.id === 'sess_a')?.user;
- if (!alice) {throw new Error('Missing user');}
+ if (!alice) {
+ throw new Error('Missing user');
+ }
fapi.client = fapiClient([...fapi.client.sessions, fapiSession({ id: 'sess_a2', user: alice })]);
const { clerk } = await renderWithClerk();
const creation = holdRequests('post', '/v1/me/passkeys');
@@ -346,7 +358,9 @@ describe('Passkey identity and policy at action time', () => {
await user.clear(input);
await user.type(input, 'Blocked rename');
const environment = clerk.__internal_environment;
- if (!environment) {throw new Error('Missing environment');}
+ if (!environment) {
+ throw new Error('Missing environment');
+ }
environment.userSettings.attributes.passkey.enabled = false;
const rename = holdRequests('post', '/v1/me/passkeys/pk_a');
await user.click(screen.getByRole('button', { name: 'Save' }));
@@ -357,29 +371,38 @@ describe('Passkey identity and policy at action time', () => {
});
});
-
describe('Held passkey creation ownership', () => {
- it('retains the requesting user when creation completes after an account switch', async () => {
+ it('rejects verification issued by a different account after a held creation', async () => {
const fapi = serveAccounts();
authenticator();
const { clerk } = await renderWithClerk();
const creation = holdRequests('post', '/v1/me/passkeys');
- const user = userEvent.setup();
- await user.click(screen.getByRole('button', { name: 'Add passkey' }));
+ const verification = holdRequests('post', '/v1/me/passkeys/passkey_1/attempt_verification');
+ const alice = clerk.user;
+ if (!alice) {
+ throw new Error('Missing user');
+ }
+ const registration = alice.createPasskey();
+ const rejected = expect(registration).rejects.toMatchObject({ errors: [{ code: 'resource_forbidden' }] });
await waitFor(() => expect(creation.requests).toHaveLength(1));
- const request = creation.requests[0];
- if (!request) {throw new Error('Missing creation request');}
- expect(new URL(request.url).searchParams.get('_clerk_session_id')).toBe('sess_a');
+ expect(new URL(creation.requests[0]?.url ?? '').searchParams.get('_clerk_session_id')).toBe('sess_a');
await act(() => clerk.setActive({ session: 'sess_b' }));
creation.release();
- await waitFor(() => expect(fapi.client.sessions.find(session => session.id === 'sess_a')?.user.passkeys.map(passkey => passkey.name)).toContain('New passkey'));
- expect(fapi.client.sessions.find(session => session.id === 'sess_b')?.user.passkeys.map(passkey => passkey.name)).toEqual(['Bob phone']);
+ await waitFor(() => expect(verification.requests).toHaveLength(1));
+ expect(new URL(verification.requests[0]?.url ?? '').searchParams.get('_clerk_session_id')).toBe('sess_b');
+ verification.release();
+ await rejected;
+ expect(
+ fapi.client.sessions.find(session => session.id === 'sess_a')?.user.passkeys.map(passkey => passkey.name),
+ ).toEqual(['Alice laptop']);
+ expect(
+ fapi.client.sessions.find(session => session.id === 'sess_b')?.user.passkeys.map(passkey => passkey.name),
+ ).toEqual(['Bob phone']);
});
});
-
describe('Replacing instance policy while editing', () => {
- it('rechecks the current environment when Save follows a policy replacement', async () => {
+ it('closes editing without a mutation when policy replacement disables passkeys', async () => {
const fapi = serveAccounts();
const { clerk } = await renderWithClerk();
const user = userEvent.setup();
@@ -391,12 +414,12 @@ describe('Replacing instance policy while editing', () => {
const nextEnvironment = fapiEnvironment();
nextEnvironment.user_settings.attributes.passkey.enabled = false;
fapi.environment = nextEnvironment;
- await clerk.__internal_setEnvironment(nextEnvironment);
const rename = holdRequests('post', '/v1/me/passkeys/pk_a');
- await user.click(screen.getByRole('button', { name: 'Save' }));
- rename.release();
- expect(await screen.findByRole('alert')).toBeVisible();
+ await act(() => clerk.__internal_setEnvironment(nextEnvironment));
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(screen.queryByRole('group', { name: 'Passkeys' })).toBeNull();
expect(rename.requests).toHaveLength(0);
+ rename.release();
expect(clerk.user?.passkeys.find(passkey => passkey.id === 'pk_a')?.name).toBe('Alice laptop');
});
});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
index a1ded2ac45f..15ab40dc09f 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
@@ -220,7 +220,7 @@ describe('Adding a passkey', () => {
await user.click(add);
expect(creation.requests).toHaveLength(1);
creation.release();
- expect(await screen.findByText('New passkey')).toBeVisible();
+ expect(await screen.findByText('Chrome on macOS')).toBeVisible();
expect(fapi.client.sessions[0]?.user.passkeys).toHaveLength(1);
expect(authenticator).toHaveBeenCalledOnce();
expect(add).toBeEnabled();
@@ -242,7 +242,7 @@ describe('Adding a passkey', () => {
expect(creation.requests).toHaveLength(1);
const retried = serveFapi(fapi);
await user.click(screen.getByRole('button', { name: 'Add passkey' }));
- expect(await screen.findByText('New passkey')).toBeVisible();
+ expect(await screen.findByText('Chrome on macOS')).toBeVisible();
expect(retried.client.sessions[0]?.user.passkeys).toHaveLength(1);
expect(screen.queryByRole('alert')).toBeNull();
},
@@ -285,7 +285,7 @@ describe('Adding a passkey', () => {
expect(await screen.findByRole('alert')).toBeVisible();
expect(screen.getByText('No passkeys added')).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Add passkey' }));
- expect(await screen.findByText('New passkey')).toBeVisible();
+ expect(await screen.findByText('Chrome on macOS')).toBeVisible();
});
});
@@ -342,7 +342,9 @@ describe('Renaming a passkey', () => {
await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
await user.type(screen.getByRole('textbox', { name: 'Passkey name' }), ' modifié');
await user.click(screen.getByRole('button', { name: 'Save' }));
- expect(await screen.findByRole('alert')).toHaveTextContent('Ce nom est invalide.');
+ await waitFor(() => expect(screen.getByText('Ce nom est invalide.')).toBeVisible());
+ expect(screen.getByRole('textbox', { name: 'Passkey name' })).toHaveAttribute('aria-invalid', 'true');
+ expect(screen.getByRole('textbox', { name: 'Passkey name' })).toHaveAccessibleDescription('Ce nom est invalide.');
expect(screen.getByRole('textbox', { name: 'Passkey name' })).toHaveValue('Laptop modifié');
});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-validation.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-validation.feature.test.tsx
new file mode 100644
index 00000000000..a4deb7533ea
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-validation.feature.test.tsx
@@ -0,0 +1,83 @@
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { describe, expect, it } from 'vitest';
+
+import { fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
+import { fapiClient, fapiEnvironment, fapiPasskey, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { MosaicProvider } from '../../../mosaic-provider';
+import { UserProfilePasskeysSection } from '../user-profile-passkeys-section/user-profile-passkeys-section';
+
+function servePasskeys() {
+ const environment = fapiEnvironment();
+ environment.user_settings.attributes.passkey.enabled = true;
+ return serveFapi({
+ environment,
+ client: fapiClient([
+ fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', passkeys: [fapiPasskey({ id: 'pk_1' })] }) }),
+ ]),
+ });
+}
+
+describe('Validating a passkey name', () => {
+ it('marks localized server name errors on the field and clears them when the draft changes', async () => {
+ servePasskeys();
+ worker.use(
+ http.post(fapiUrl('/v1/me/passkeys/pk_1'), () =>
+ HttpResponse.json(
+ { errors: [{ code: 'form_param_invalid', message: '', meta: { param_name: 'name' } }] },
+ { status: 400 },
+ ),
+ ),
+ );
+ await renderWithClerk(
+
+
+ ,
+ );
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
+ const input = screen.getByRole('textbox', { name: 'Passkey name' });
+ await user.type(input, ' modifié');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ await waitFor(() => expect(screen.getByText('Ce nom est invalide.')).toBeVisible());
+ expect(input).toHaveAttribute('aria-invalid', 'true');
+ expect(input).toHaveAccessibleDescription('Ce nom est invalide.');
+ expect(input).toHaveValue('Laptop modifié');
+ await user.type(input, ' encore');
+ expect(input).not.toHaveAttribute('aria-invalid', 'true');
+ await waitFor(() => expect(screen.queryByText('Ce nom est invalide.')).toBeNull());
+ });
+
+ it('rejects names over 256 UTF-8 bytes before submission and saves a name at the limit', async () => {
+ const fapi = servePasskeys();
+ let requests = 0;
+ worker.use(
+ http.post(fapiUrl('/v1/me/passkeys/pk_1'), () => {
+ requests += 1;
+ }),
+ );
+ await renderWithClerk();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Laptop' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
+ const input = screen.getByRole('textbox', { name: 'Passkey name' });
+ const save = screen.getByRole('button', { name: 'Save' });
+ await user.clear(input);
+ await user.type(input, '😀'.repeat(65));
+ await user.tab();
+ expect(input).toHaveAttribute('aria-invalid', 'true');
+ expect(save).toHaveAttribute('aria-disabled', 'true');
+ await user.click(save);
+ expect(requests).toBe(0);
+ await user.clear(input);
+ await user.type(input, '😀'.repeat(64));
+ expect(input).not.toHaveAttribute('aria-invalid', 'true');
+ await user.click(save);
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(requests).toBe(1);
+ expect(fapi.client.sessions[0]?.user.passkeys[0]?.name).toBe('😀'.repeat(64));
+ });
+});
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 61768becf26..0484d27b828 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
@@ -108,31 +108,17 @@ describe('UserProfileSecurityPanelView', () => {
});
it('forwards security actions', async () => {
- const onAddPasskey = vi.fn();
- const onRenamePasskey = vi.fn(() => Promise.resolve());
- const onRemovePasskey = vi.fn();
const onSignOutDevice = vi.fn();
const onSignOutAllOtherDevices = vi.fn();
const onDeleteAccount = vi.fn(() => Promise.resolve());
const user = userEvent.setup();
renderView({
- passkeysSlot: {
- content: (
-
- ),
- },
onSignOutDevice,
onSignOutAllOtherDevices,
deleteAccountSlot: ,
});
- await user.click(screen.getByRole('button', { name: 'Add passkey' }));
const signOutAll = screen.getByRole('button', { name: 'Sign out of all devices' });
expect(signOutAll).toHaveAttribute('data-variant', 'outline');
expect(screen.getByRole('group', { name: 'Active devices' }).querySelector('.cl-section-header')).toContainElement(
@@ -142,18 +128,6 @@ describe('UserProfileSecurityPanelView', () => {
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- await user.click(screen.getByRole('button', { name: 'Manage Passkey' }));
- await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
- const passkeyName = screen.getByRole('textbox', { name: 'Passkey name' });
- await user.clear(passkeyName);
- await user.type(passkeyName, 'Work laptop');
- await user.click(screen.getByRole('button', { name: 'Save' }));
- await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
- await user.click(screen.getByRole('button', { name: 'Manage Passkey' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove', exact: true }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
-
const activeDevices = screen.getByRole('group', { name: 'Active devices' });
await user.click(within(activeDevices).getByRole('button', { name: 'Manage Safari on iOS' }));
await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
@@ -166,9 +140,6 @@ describe('UserProfileSecurityPanelView', () => {
await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account');
await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' }));
- expect(onAddPasskey).toHaveBeenCalledOnce();
- expect(onRenamePasskey).toHaveBeenCalledWith('passkey_1', 'Work laptop');
- expect(onRemovePasskey).toHaveBeenCalledWith('passkey_1');
expect(onSignOutDevice).toHaveBeenCalledWith('mobile');
expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce();
expect(onDeleteAccount).toHaveBeenCalledOnce();
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx
index 0c75e25b59c..e9cdf81b294 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx
@@ -9,6 +9,7 @@ import { Section } from '../../components/section';
import { fill, useMessages } from '../../localization';
import { styles } from './user-profile-passkeys-section.styles';
import type { UserProfilePasskey } from './user-profile-passkeys-section.view';
+import type { UserProfilePasskeyNameValidator } from './user-profile-passkeys-section/user-profile-passkeys-section.types';
import { useUserProfileRenamePasskeyController } from './user-profile-rename-passkey.controller';
import { UserProfileRenamePasskeyDialog } from './user-profile-rename-passkey.dialog';
@@ -16,16 +17,23 @@ export function UserProfilePasskeyRowView({
passkey,
triggerRef,
onRename,
+ validateName,
onRemove,
}: {
passkey: UserProfilePasskey;
triggerRef?: Ref;
onRename?: (id: string, name: string) => void | Promise;
+ validateName?: UserProfilePasskeyNameValidator;
onRemove?: () => void;
}) {
const m = useMessages('userProfilePasskeys');
const renameDialog = useMemo(() => Dialog.createHandle(), []);
- const controller = useUserProfileRenamePasskeyController({ id: passkey.id, name: passkey.name, onRename });
+ const controller = useUserProfileRenamePasskeyController({
+ id: passkey.id,
+ name: passkey.name,
+ onRename,
+ validateName,
+ });
const description =
passkey.createdAtLabel && passkey.lastUsedAtLabel
? fill(m.details, { createdAt: passkey.createdAtLabel, lastUsedAt: passkey.lastUsedAtLabel })
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts
index 8f0a96feeae..7502840cd63 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.messages.ts
@@ -18,6 +18,7 @@ export const userProfilePasskeysMessages = {
renameTitle: 'Rename passkey',
renameDescription: 'You can change the passkey name to make it easier to find.',
nameLabel: 'Passkey name',
+ nameTooLongError: 'The passkey name must be 256 bytes or fewer.',
save: 'Save',
cancel: 'Cancel',
unavailableError: 'This passkey is no longer available.',
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx
index 03c5e18494b..6e6938af683 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx
@@ -9,7 +9,10 @@ import { useListRemovalFocus } from '../../hooks/use-list-removal-focus';
import { fill, useMessages } from '../../localization';
import { UserProfilePasskeyRowView } from './user-profile-passkey-row.view';
import { styles } from './user-profile-passkeys-section.styles';
-import type { UserProfilePasskey } from './user-profile-passkeys-section/user-profile-passkeys-section.types';
+import type {
+ UserProfilePasskey,
+ UserProfilePasskeyNameValidator,
+} from './user-profile-passkeys-section/user-profile-passkeys-section.types';
export type { UserProfilePasskey } from './user-profile-passkeys-section/user-profile-passkeys-section.types';
@@ -19,6 +22,7 @@ export interface UserProfilePasskeysSectionViewProps {
isAdding?: boolean;
addError?: string;
onRename?: (id: string, name: string) => void | Promise;
+ validateName?: UserProfilePasskeyNameValidator;
onRemove?: (id: string) => void | Promise;
}
@@ -28,6 +32,7 @@ export function UserProfilePasskeysSectionView({
isAdding,
addError,
onRename,
+ validateName,
onRemove,
}: UserProfilePasskeysSectionViewProps) {
const m = useMessages('userProfilePasskeys');
@@ -85,6 +90,7 @@ export function UserProfilePasskeysSectionView({
passkey={passkey}
triggerRef={removalFocus.registerTrigger(passkey.id)}
onRename={onRename}
+ validateName={validateName}
onRemove={onRemove ? () => removePasskey.open(passkey) : undefined}
/>
))
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.test.ts
index 51ade15b7f0..ada5000f1ff 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.test.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.test.ts
@@ -8,6 +8,26 @@ const passkeys = [
];
describe('projectPasskeys', () => {
+ it.each([
+ { enabled: false, allowIdentificationCreation: true, isSatellite: false },
+ { enabled: true, allowIdentificationCreation: false, isSatellite: false },
+ { enabled: false, allowIdentificationCreation: false, isSatellite: true },
+ { enabled: false, allowIdentificationCreation: true, isSatellite: true },
+ { enabled: true, allowIdentificationCreation: false, isSatellite: true },
+ ])('hides unavailable passkeys for %j', policy => {
+ expect(projectPasskeys({ passkeys, ...policy })).toEqual({ status: 'hidden' });
+ });
+
+ it('keeps an empty eligible main-app section actionable', () => {
+ expect(
+ projectPasskeys({ passkeys: [], enabled: true, allowIdentificationCreation: true, isSatellite: false }),
+ ).toEqual({
+ status: 'ready',
+ passkeys: [],
+ canAdd: true,
+ });
+ });
+
it('preserves order, names, and timestamps, and hides only Add on a satellite', () => {
expect(projectPasskeys({ passkeys, enabled: true, allowIdentificationCreation: true, isSatellite: true })).toEqual({
status: 'ready',
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts
index 6049bfe6f4d..1c59ebd982d 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts
@@ -3,9 +3,14 @@ import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/erro
import { useClerk, useSession, useUser } from '@clerk/shared/react';
import type { EnvironmentResource, PasskeyResource, UserResource } from '@clerk/shared/types';
+import { FormSubmitError } from '../../../components/form';
import { getMosaicEnvironment, useMosaicEnvironment } from '../../../hooks/use-mosaic-environment';
import { fill, useErrorText, useLocale, useMessages } from '../../../localization';
-import type { UserProfilePasskey } from './user-profile-passkeys-section.types';
+import type {
+ UserProfilePasskey,
+ UserProfilePasskeyNameValidator,
+ UserProfileRenamePasskeyValues,
+} from './user-profile-passkeys-section.types';
type Passkey = Pick;
@@ -54,6 +59,7 @@ export type UserProfilePasskeysModel =
passkeys: UserProfilePasskey[];
onAdd?: () => Promise;
onRename: (id: string, name: string) => Promise;
+ validateName: UserProfilePasskeyNameValidator;
onRemove: (id: string) => Promise;
};
@@ -82,6 +88,8 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
const locale = useLocale();
const messages = useMessages('userProfilePasskeys');
const errorText = useErrorText();
+ const validateName: UserProfilePasskeyNameValidator = name =>
+ new TextEncoder().encode(name).length > 256 ? { type: 'error', message: messages.nameTooLongError } : undefined;
if (!isUserLoaded || !isSessionLoaded || !environment) {
return { status: 'loading' };
@@ -149,6 +157,7 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
status: 'ready',
userId,
sessionId,
+ validateName,
passkeys: projection.passkeys.map(passkey => ({
id: passkey.id,
name: passkey.name,
@@ -175,10 +184,31 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
if (!passkey) {
throw new Error(messages.unavailableError);
}
+ const feedback = validateName(name);
+ if (feedback) {
+ throw new FormSubmitError({ fields: { name: feedback.message } });
+ }
try {
await passkey.update({ name });
} catch (error) {
- throw actionError(error);
+ if (!isClerkAPIResponseError(error)) {
+ throw new FormSubmitError({ message: actionError(error).message });
+ }
+ const fields: { name?: string } = {};
+ let message: string | undefined;
+ for (const item of error.errors) {
+ const text = errorText({
+ code: item.code,
+ paramName: item.meta?.paramName,
+ message: item.longMessage || item.message || messages.saveError,
+ });
+ if (item.meta?.paramName === 'name') {
+ fields.name ??= text;
+ } else {
+ message ??= text;
+ }
+ }
+ throw new FormSubmitError({ message, fields });
}
},
onRemove: async id => {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.tsx
index f643ac9d7b6..b969ae8d541 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.tsx
@@ -27,7 +27,7 @@ export function useUserProfilePasskeysSlot({
return {
content: (
),
@@ -41,6 +41,7 @@ function PasskeysEditor({ model }: { model: Extract
);
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.types.ts
index 017b6971f6b..69a3105e71a 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.types.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.types.ts
@@ -1,5 +1,13 @@
import type { ReactNode } from 'react';
+import type { FieldFeedback } from '../../../components/form';
+
+export type UserProfilePasskeyNameValidator = (name: string) => FieldFeedback | undefined;
+
+export interface UserProfileRenamePasskeyValues {
+ name: string;
+}
+
export interface UserProfilePasskey {
id: string;
name: string;
diff --git a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts
index ac112faecc1..80b0e0e4587 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts
@@ -1,105 +1,49 @@
-import { useMessages } from '../../localization';
-import { setup } from '../../machine/setup';
-import { useMachine } from '../../machine/use-machine';
+import { useState } from 'react';
-interface UserProfileRenamePasskeyContext {
- passkeyId: string;
- savedName: string;
- name: string;
- onRename: ((id: string, name: string) => void | Promise) | undefined;
- error: string | undefined;
- fallbackErrorMessage: string;
-}
-
-type UserProfileRenamePasskeyEvent =
- | { type: 'OPEN' }
- | { type: 'TYPE'; value: string }
- | { type: 'SAVE' }
- | { type: 'CANCEL' };
-
-const { createMachine, assign, fromPromise } = setup();
-
-function isSaveable(context: UserProfileRenamePasskeyContext): boolean {
- return Boolean(context.onRename) && context.name.length > 1 && context.name !== context.savedName;
-}
-
-const userProfileRenamePasskeyMachine = createMachine({
- id: 'renamePasskey',
- initial: 'idle',
- context: {
- passkeyId: '',
- savedName: '',
- name: '',
- onRename: undefined,
- error: undefined,
- fallbackErrorMessage: '',
- },
- states: {
- idle: {
- on: {
- OPEN: {
- target: 'editing',
- actions: assign(context => ({ name: context.savedName, error: undefined })),
- },
- },
- },
- editing: {
- on: {
- TYPE: { actions: assign((_, event) => ({ name: event.value })) },
- SAVE: { target: 'saving', guard: isSaveable, actions: assign(() => ({ error: undefined })) },
- CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) },
- },
- },
- saving: {
- invoke: fromPromise(async context => context.onRename?.(context.passkeyId, context.name), {
- onDone: { target: 'idle' },
- onError: {
- target: 'editing',
- actions: assign((context, event) => ({
- error:
- event.error instanceof Error && event.error.message ? event.error.message : context.fallbackErrorMessage,
- })),
- },
- }),
- },
- },
-});
+import type { UseFormResult } from '../../components/form';
+import { useForm } from '../../components/form';
+import type {
+ UserProfilePasskeyNameValidator,
+ UserProfileRenamePasskeyValues,
+} from './user-profile-passkeys-section/user-profile-passkeys-section.types';
interface UserProfileRenamePasskeyControllerOptions {
id: string;
name: string;
onRename?: (id: string, name: string) => void | Promise;
+ validateName?: UserProfilePasskeyNameValidator;
}
interface UserProfileRenamePasskeyController {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
- name: string;
- onNameChange: (name: string) => void;
- onSubmit: () => void;
- canSave: boolean;
- isSaving: boolean;
- error: string | undefined;
+ form: UseFormResult;
}
export function useUserProfileRenamePasskeyController({
id,
name,
onRename,
+ validateName,
}: UserProfileRenamePasskeyControllerOptions): UserProfileRenamePasskeyController {
- const messages = useMessages('userProfilePasskeys');
- const [snapshot, send] = useMachine(userProfileRenamePasskeyMachine, {
- context: { passkeyId: id, savedName: name, onRename, fallbackErrorMessage: messages.saveError },
+ const [isOpen, setIsOpen] = useState(false);
+ const form = useForm({
+ initialValues: { name },
+ fields: { name: { validate: validateName } },
+ canSubmit: values => Boolean(onRename) && values.name.length > 1 && values.name !== name,
+ onSubmit: async values => {
+ await onRename?.(id, values.name);
+ setIsOpen(false);
+ },
});
- return {
- isOpen: snapshot.value === 'editing' || snapshot.value === 'saving',
- onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }),
- name: snapshot.context.name,
- onNameChange: value => send({ type: 'TYPE', value }),
- onSubmit: () => send({ type: 'SAVE' }),
- canSave: isSaveable(snapshot.context),
- isSaving: snapshot.value === 'saving',
- error: snapshot.context.error,
+ const onOpenChange = (open: boolean) => {
+ if (form.isSubmitting) {
+ return;
+ }
+ form.reset({ name });
+ setIsOpen(open);
};
+
+ return { isOpen, onOpenChange, form };
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx
index bb3451ddc7d..f3d1d20aa21 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.dialog.tsx
@@ -1,39 +1,34 @@
-import { useId, useRef } from 'react';
+import { useMergeRefs } from '@floating-ui/react';
+import { useRef } from 'react';
import { Button, SubmitButton } from '../../components/button';
import { Card } from '../../components/card';
import type { DialogHandle } from '../../components/dialog';
import { Dialog } from '../../components/dialog';
import { Field } from '../../components/field';
+import type { UseFormResult } from '../../components/form';
import { Input } from '../../components/input';
import { useMessages } from '../../localization';
+import type { UserProfileRenamePasskeyValues } from './user-profile-passkeys-section/user-profile-passkeys-section.types';
export interface UserProfileRenamePasskeyDialogProps {
handle: DialogHandle;
open: boolean;
onOpenChange: (open: boolean) => void;
- name: string;
- onNameChange: (name: string) => void;
- canSave: boolean;
- isSaving?: boolean;
- error?: string;
- onSubmit: () => void;
+ form: UseFormResult;
}
export function UserProfileRenamePasskeyDialog({
handle,
open,
onOpenChange,
- name,
- onNameChange,
- canSave,
- isSaving = false,
- error,
- onSubmit,
+ form,
}: UserProfileRenamePasskeyDialogProps) {
const m = useMessages('userProfilePasskeys');
- const formId = useId();
const inputRef = useRef(null);
+ const { ref, ...control } = form.register('name');
+ const mergedRef = useMergeRefs([ref, inputRef]);
+ const feedback = form.fields.name.feedback;
return (
- {error}
+ {form.error}
{
- event.preventDefault();
- if (canSave && !isSaving) {
- onSubmit();
- }
- }}
+ id={form.id}
+ onSubmit={form.handleSubmit}
/>
}
>
{m.nameLabel}
onNameChange(event.target.value)}
+ {...control}
+ />
+
@@ -87,17 +81,17 @@ export function UserProfileRenamePasskeyDialog({
}
/>
{m.save}
diff --git a/packages/mosaic/src/hooks/use-mosaic-environment.ts b/packages/mosaic/src/hooks/use-mosaic-environment.ts
index 6b66808336c..870569dea31 100644
--- a/packages/mosaic/src/hooks/use-mosaic-environment.ts
+++ b/packages/mosaic/src/hooks/use-mosaic-environment.ts
@@ -1,5 +1,6 @@
import { useClerk } from '@clerk/shared/react';
import type { EnvironmentResource, LoadedClerk } from '@clerk/shared/types';
+import { useEffect, useReducer } from 'react';
/**
* The single seam through which Mosaic reads the active Clerk environment.
@@ -9,17 +10,18 @@ import type { EnvironmentResource, LoadedClerk } from '@clerk/shared/types';
* the environment is `clerk.__internal_environment`. Quarantining that one private
* access here keeps the `@ts-expect-error` out of every caller.
*
- * This is a one-shot read off the Clerk singleton, NOT a reactive subscription: unlike
- * clerk-js's `EnvironmentProvider`, `useClerk()` does not re-render when the environment
- * mutates. So only read fields that are set once at hydration and never change at runtime
- * (e.g. static `displayConfig` URLs). Anything that can mutate post-hydration
- * (`authConfig.claimedAt`, live config toggles) will render stale through this hook and
- * needs a real reactive subscription instead (see `use-revalidate-environment.ts`).
- *
* Returns `undefined` until the environment hydrates; callers should handle that.
*/
export function useMosaicEnvironment(): EnvironmentResource | undefined {
const clerk = useClerk();
+ const [, notify] = useReducer((revision: number) => revision + 1, 0);
+
+ useEffect(() => {
+ const unsubscribe = clerk.addListener(notify, { skipInitialEmit: true });
+ notify();
+ return unsubscribe;
+ }, [clerk]);
+
return getMosaicEnvironment(clerk);
}
From 0b65ba999ef4d9a065d4f8ed97ad33bf31c78c59 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 13:24:06 -0600
Subject: [PATCH 06/10] test(mosaic): repair CI fixtures and cover removal
focus cleanup
---
...rofile-connected-accounts.feature.test.tsx | 4 +-
...rofile-delete-section.integration.test.tsx | 23 ++--
...ile-passkeys-interactions.feature.test.tsx | 2 +-
...-profile-passkeys-section.feature.test.tsx | 4 +-
...ser-profile-password-section.model.test.ts | 1 +
.../use-list-removal-focus.feature.test.tsx | 115 ++++++++++++++++++
6 files changed, 138 insertions(+), 11 deletions(-)
create mode 100644 packages/mosaic/src/hooks/use-list-removal-focus.feature.test.tsx
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx
index 3080ab7ffdb..1da732402a0 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx
@@ -486,7 +486,9 @@ describe('connected accounts', () => {
await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
await waitFor(() => expect(request.requests).toHaveLength(1));
request.fail('last_identification', 'You cannot remove your last sign-in method.');
- expect(await within(dialog).findByRole('alert')).toHaveTextContent('You cannot remove your last sign-in method.');
+ await waitFor(() =>
+ expect(within(dialog).getByRole('alert')).toHaveTextContent('You cannot remove your last sign-in method.'),
+ );
serveFapi(signedIn([google]));
await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx
index 0648d0403fa..8b520134647 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx
@@ -13,6 +13,21 @@ let signedInSessions: { user?: { id: string } }[];
let deleteUser: ReturnType;
let setActive: ReturnType;
+const clerk = {
+ get setActive() {
+ return setActive;
+ },
+ client: {
+ get signedInSessions() {
+ return signedInSessions;
+ },
+ },
+ addListener: vi.fn(() => vi.fn()),
+ buildAfterSignOutUrl: () => '/signed-out',
+ buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left',
+ __internal_getOption: () => undefined,
+};
+
vi.mock('@clerk/shared/react', async importOriginal => {
const actual = await importOriginal();
return {
@@ -23,13 +38,7 @@ vi.mock('@clerk/shared/react', async importOriginal => {
user: isLoaded ? { id: 'user_1', deleteSelfEnabled, delete: deleteUser } : undefined,
}),
useSession: () => ({ session: { id: 'sess_1' } }),
- useClerk: () => ({
- setActive,
- client: { signedInSessions },
- buildAfterSignOutUrl: () => '/signed-out',
- buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left',
- __internal_getOption: () => undefined,
- }),
+ useClerk: () => clerk,
useReverification: (fetcher: () => Promise) => fetcher,
};
});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-interactions.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-interactions.feature.test.tsx
index 43cfb6b133e..b34adeb3006 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-interactions.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-interactions.feature.test.tsx
@@ -84,7 +84,7 @@ describe('Passkey interactions across resource changes', () => {
);
await user.type(input, ' edited');
await user.click(screen.getByRole('button', { name: 'Save' }));
- expect(await screen.findByRole('alert')).toHaveTextContent('Cette clé a été supprimée.');
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Cette clé a été supprimée.'));
expect(input).toHaveValue('Alice laptop edited');
});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
index 15ab40dc09f..660c00b9d1b 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
@@ -361,7 +361,7 @@ describe('Renaming a passkey', () => {
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(rename.requests).toHaveLength(1));
rename.fail();
- expect(await screen.findByRole('alert')).toHaveTextContent('form_param_invalid');
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('form_param_invalid'));
expect(input).toHaveValue('Work laptop');
await user.type(input, ' updated');
const retried = serveFapi(fapi);
@@ -439,7 +439,7 @@ describe('Removing a passkey', () => {
await user.click(screen.getByRole('button', { name: 'Remove' }));
await waitFor(() => expect(removal.requests).toHaveLength(1));
removal.fail('session_reverification_required');
- expect(await screen.findByRole('alert')).toHaveTextContent('session_reverification_required');
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('session_reverification_required'));
expect(screen.getByRole('alertdialog')).toBeVisible();
expect(screen.queryByLabelText('Password')).toBeNull();
expect(removal.requests).toHaveLength(1);
diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts
index bca8f2fc05b..6f5c1f5bb87 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts
@@ -37,6 +37,7 @@ let session: TestSession | null;
let environment: ReturnType;
const clerk = {
+ addListener: vi.fn(() => vi.fn()),
get user() {
return user;
},
diff --git a/packages/mosaic/src/hooks/use-list-removal-focus.feature.test.tsx b/packages/mosaic/src/hooks/use-list-removal-focus.feature.test.tsx
new file mode 100644
index 00000000000..e43bcbd2f72
--- /dev/null
+++ b/packages/mosaic/src/hooks/use-list-removal-focus.feature.test.tsx
@@ -0,0 +1,115 @@
+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 { Confirmation } from '../blocks/confirmation';
+import { MosaicProvider } from '../mosaic-provider';
+import { useListRemovalFocus } from './use-list-removal-focus';
+
+function CaptureRemovalTarget({
+ finalFocus,
+ onTarget,
+}: {
+ finalFocus: () => HTMLElement | null;
+ onTarget: (target: HTMLElement) => void;
+}) {
+ useLayoutEffect(() => {
+ return () => {
+ const target = finalFocus();
+ if (target) {
+ onTarget(target);
+ }
+ };
+ }, [finalFocus, onTarget]);
+ return null;
+}
+
+function RemovableList({ onRestoreTarget }: { onRestoreTarget?: (target: HTMLElement) => void }) {
+ const [ids, setIds] = useState(['Laptop', 'Phone']);
+ const [selected, setSelected] = useState();
+ const add = useRef(null);
+ const removalFocus = useListRemovalFocus({
+ ids,
+ onRemove: () => Promise.resolve(),
+ fallback: () => add.current,
+ });
+
+ return (
+ <>
+
+ {ids.map(id => (
+
+ ))}
+ {onRestoreTarget ? (
+
+ ) : null}
+ {
+ if (!open) {
+ setSelected(undefined);
+ }
+ }}
+ title='Remove device'
+ description={selected}
+ actionLabel='Remove'
+ finalFocus={removalFocus.finalFocus}
+ onConfirm={async () => {
+ if (selected === undefined) {
+ return;
+ }
+ await removalFocus.remove(selected);
+ setIds(current => current.filter(id => id !== selected));
+ setSelected(undefined);
+ }}
+ />
+ >
+ );
+}
+
+it.each([
+ ['Phone', 'Laptop'],
+ ['Laptop', 'Phone'],
+])('removes %s and focuses its surviving neighbor %s', async (removed, remaining) => {
+ render(
+
+
+ ,
+ );
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: `Manage ${removed}` }));
+ await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(screen.queryByRole('button', { name: `Manage ${removed}` })).toBeNull();
+ await waitFor(() => expect(screen.getByRole('button', { name: `Manage ${remaining}` })).toHaveFocus());
+});
+
+it('retains surviving row targets during layout-effect cleanup', async () => {
+ const targets: HTMLElement[] = [];
+ render(
+
+ targets.push(target)} />
+ ,
+ );
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Phone' }));
+ await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(targets).toEqual([screen.getByRole('button', { name: 'Manage Laptop' })]);
+});
From beb092dd64b796f40eecaf54fcbd644e839de135 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 13:24:15 -0600
Subject: [PATCH 07/10] fix(mosaic): preserve removal targets during focus
restoration
---
.changeset/passkey-review-remediation.md | 2 ++
.../src/hooks/use-list-removal-focus.ts | 31 ++++++++++++++-----
2 files changed, 26 insertions(+), 7 deletions(-)
diff --git a/.changeset/passkey-review-remediation.md b/.changeset/passkey-review-remediation.md
index 2c5de362c9e..d593bed3f1d 100644
--- a/.changeset/passkey-review-remediation.md
+++ b/.changeset/passkey-review-remediation.md
@@ -4,3 +4,5 @@
---
Keep passkey sections tied to the active account when switching users, show passkey name errors on the input, and refresh Mosaic eligibility when instance settings change.
+
+Restore focus to a remaining item after removing an item from a Mosaic profile list.
diff --git a/packages/mosaic/src/hooks/use-list-removal-focus.ts b/packages/mosaic/src/hooks/use-list-removal-focus.ts
index 1c38c6ae607..d68a3b366e1 100644
--- a/packages/mosaic/src/hooks/use-list-removal-focus.ts
+++ b/packages/mosaic/src/hooks/use-list-removal-focus.ts
@@ -1,5 +1,10 @@
import { useRef } from 'react';
+interface RemovalTrigger {
+ element: HTMLButtonElement | null;
+ ref: (element: HTMLButtonElement | null) => void;
+}
+
export function useListRemovalFocus({
ids,
onRemove,
@@ -9,15 +14,27 @@ export function useListRemovalFocus({
onRemove?: (id: string) => void | Promise;
fallback: () => HTMLElement | null;
}) {
- const triggers = useRef(new Map());
+ const triggers = useRef(new Map());
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 existing = triggers.current.get(id);
+ if (existing) {
+ return existing.ref;
}
+ const trigger: RemovalTrigger = {
+ element: null,
+ ref: element => {
+ trigger.element = element;
+ if (element === null) {
+ triggers.current.delete(id);
+ } else {
+ triggers.current.set(id, trigger);
+ }
+ },
+ };
+ triggers.current.set(id, trigger);
+ return trigger.ref;
};
const remove = async (id: string) => {
@@ -37,7 +54,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 cb2ea2676cad9a9dbc6151f8e56ee43df65c15c3 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 14:09:37 -0600
Subject: [PATCH 08/10] revert(mosaic): drop shared focus and environment
changes
---
.changeset/passkey-review-remediation.md | 5 +-
packages/clerk-js/src/core/clerk.ts | 4 +-
.../passkeys-policy.feature.test.tsx | 54 --------
...ser-profile-account-section.model.test.tsx | 5 +-
...rofile-delete-section.integration.test.tsx | 23 ++--
...rofile-passkeys-lifecycle.feature.test.tsx | 12 +-
...-profile-passkeys-section.feature.test.tsx | 11 +-
...ser-profile-password-section.model.test.ts | 1 -
.../use-list-removal-focus.feature.test.tsx | 115 ------------------
.../src/hooks/use-list-removal-focus.ts | 31 ++---
.../src/hooks/use-mosaic-environment.ts | 16 ++-
11 files changed, 42 insertions(+), 235 deletions(-)
delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/passkeys-policy.feature.test.tsx
delete mode 100644 packages/mosaic/src/hooks/use-list-removal-focus.feature.test.tsx
diff --git a/.changeset/passkey-review-remediation.md b/.changeset/passkey-review-remediation.md
index d593bed3f1d..d16c8291df9 100644
--- a/.changeset/passkey-review-remediation.md
+++ b/.changeset/passkey-review-remediation.md
@@ -1,8 +1,5 @@
---
-'@clerk/clerk-js': patch
'@clerk/mosaic': patch
---
-Keep passkey sections tied to the active account when switching users, show passkey name errors on the input, and refresh Mosaic eligibility when instance settings change.
-
-Restore focus to a remaining item after removing an item from a Mosaic profile list.
+Keep passkey sections tied to the active account when switching users and show passkey name errors on the input.
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index b3b16588a16..f5eaa05aa92 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -3236,7 +3236,6 @@ export class Clerk implements ClerkInterface {
public updateEnvironment(environment: EnvironmentResource): asserts this is { environment: EnvironmentResource } {
this.environment = environment;
- this.#emit();
}
__internal_setCountry = (country: string | null) => {
@@ -3315,7 +3314,7 @@ export class Clerk implements ClerkInterface {
// TODO: Fix this properly
// eslint-disable-next-line @typescript-eslint/require-await
__internal_setEnvironment = async (env: EnvironmentJSON) => {
- this.updateEnvironment(new Environment(env));
+ this.environment = new Environment(env);
// TODO @nikos update
// if (Clerk.mountComponentRenderer) {
@@ -3731,7 +3730,6 @@ export class Clerk implements ClerkInterface {
});
eventBus.on(events.EnvironmentUpdate, () => {
- this.#emit();
// Cache the environment snapshot for 24 hours
SafeLocalStorage.setItem(
CLERK_ENVIRONMENT_STORAGE_ENTRY,
diff --git a/packages/mosaic/src/features/user-profile/__tests__/passkeys-policy.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/passkeys-policy.feature.test.tsx
deleted file mode 100644
index 1ebfd035cff..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/passkeys-policy.feature.test.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { act, screen, waitFor } from '@testing-library/react';
-import { afterEach, describe, expect, it, vi } from 'vitest';
-
-import { serveFapi } from '../../../__tests__/feature/fake-fapi';
-import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
-import { renderWithClerk } from '../../../__tests__/feature/render';
-import { UserProfilePasskeysSection } from '../user-profile-passkeys-section/user-profile-passkeys-section';
-
-function servePasskeys() {
- vi.spyOn(navigator, 'webdriver', 'get').mockReturnValue(false);
- const environment = fapiEnvironment();
- environment.user_settings.attributes.passkey.enabled = true;
- return serveFapi({
- environment,
- client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]),
- });
-}
-
-afterEach(() => vi.restoreAllMocks());
-
-describe('Updating the rendered passkey policy', () => {
- it('removes passkeys after the environment is replaced', async () => {
- const fapi = servePasskeys();
- const { clerk } = await renderWithClerk();
- expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible();
- expect(screen.getByRole('button', { name: 'Add passkey' })).toBeVisible();
-
- const environment = fapiEnvironment();
- environment.user_settings.attributes.passkey.enabled = false;
- fapi.environment = environment;
- await act(() => clerk.__internal_setEnvironment(environment));
-
- await waitFor(() => expect(screen.queryByRole('group', { name: 'Passkeys' })).toBeNull());
- expect(screen.queryByRole('button', { name: 'Add passkey' })).toBeNull();
- });
-
- it('removes passkeys after the existing environment fetches disabled policy', async () => {
- const fapi = servePasskeys();
- const { clerk } = await renderWithClerk();
- expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible();
- expect(screen.getByRole('button', { name: 'Add passkey' })).toBeVisible();
- const environment = clerk.__internal_environment;
- if (!environment) {
- throw new Error('Missing loaded environment');
- }
-
- fapi.environment.user_settings.attributes.passkey.enabled = false;
- await act(() => environment.fetch());
-
- expect(clerk.__internal_environment).toBe(environment);
- await waitFor(() => expect(screen.queryByRole('group', { name: 'Passkeys' })).toBeNull());
- expect(screen.queryByRole('button', { name: 'Add passkey' })).toBeNull();
- });
-});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx
index 5485c1364b7..b8ad9ce4d13 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx
@@ -280,8 +280,9 @@ describe('useUserProfileAccountSectionModel', () => {
});
it('rethrows a failure that is not from Clerk', async () => {
- user?.update.mockRejectedValue(new TypeError('boom'));
- await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).rejects.toThrow('boom');
+ const error = new TypeError('boom');
+ user?.update.mockRejectedValue(error);
+ await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).rejects.toBe(error);
});
describe('username', () => {
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx
index 8b520134647..0648d0403fa 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-delete-section.integration.test.tsx
@@ -13,21 +13,6 @@ let signedInSessions: { user?: { id: string } }[];
let deleteUser: ReturnType;
let setActive: ReturnType;
-const clerk = {
- get setActive() {
- return setActive;
- },
- client: {
- get signedInSessions() {
- return signedInSessions;
- },
- },
- addListener: vi.fn(() => vi.fn()),
- buildAfterSignOutUrl: () => '/signed-out',
- buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left',
- __internal_getOption: () => undefined,
-};
-
vi.mock('@clerk/shared/react', async importOriginal => {
const actual = await importOriginal();
return {
@@ -38,7 +23,13 @@ vi.mock('@clerk/shared/react', async importOriginal => {
user: isLoaded ? { id: 'user_1', deleteSelfEnabled, delete: deleteUser } : undefined,
}),
useSession: () => ({ session: { id: 'sess_1' } }),
- useClerk: () => clerk,
+ useClerk: () => ({
+ setActive,
+ client: { signedInSessions },
+ buildAfterSignOutUrl: () => '/signed-out',
+ buildAfterMultiSessionSingleSignOutUrl: () => '/one-session-left',
+ __internal_getOption: () => undefined,
+ }),
useReverification: (fetcher: () => Promise) => fetcher,
};
});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
index c63f1e191c6..f0b8bca4bea 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
@@ -402,7 +402,7 @@ describe('Held passkey creation ownership', () => {
});
describe('Replacing instance policy while editing', () => {
- it('closes editing without a mutation when policy replacement disables passkeys', async () => {
+ it('rejects a rename after policy replacement disables passkeys', async () => {
const fapi = serveAccounts();
const { clerk } = await renderWithClerk();
const user = userEvent.setup();
@@ -416,10 +416,14 @@ describe('Replacing instance policy while editing', () => {
fapi.environment = nextEnvironment;
const rename = holdRequests('post', '/v1/me/passkeys/pk_a');
await act(() => clerk.__internal_setEnvironment(nextEnvironment));
- await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
- expect(screen.queryByRole('group', { name: 'Passkeys' })).toBeNull();
- expect(rename.requests).toHaveLength(0);
+ expect(input).toHaveValue('Blocked rename');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
rename.release();
+ await waitFor(() =>
+ expect(screen.getByRole('alert')).toHaveTextContent(/^Something went wrong\. Please try again\.$/),
+ );
+ expect(input).toHaveValue('Blocked rename');
+ expect(rename.requests).toHaveLength(0);
expect(clerk.user?.passkeys.find(passkey => passkey.id === 'pk_a')?.name).toBe('Alice laptop');
});
});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
index 660c00b9d1b..4655d1b8927 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
@@ -269,10 +269,10 @@ describe('Adding a passkey', () => {
,
);
await userEvent.setup().click(screen.getByRole('button', { name: 'Add passkey' }));
- const alert = await screen.findByRole('alert');
- expect(alert).toHaveTextContent('Impossible de créer la clé.');
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Impossible de créer la clé.'));
+ const alert = screen.getByRole('alert');
expect(alert).toHaveAttribute('data-open');
- expect(alert).toHaveAttribute('data-starting-style');
+ await waitFor(() => expect(alert).toBeVisible());
expect(screen.getByRole('button', { name: 'Add passkey' })).toBeEnabled();
});
@@ -487,3 +487,8 @@ describe('Passkey reverification', () => {
it.todo('completes reverification before creating, renaming, or removing a passkey');
it.todo('cancels reverification without changing passkeys and permits retry');
});
+
+describe('Passkey follow-ups', () => {
+ it.todo('rerenders passkey eligibility when a replaced or fetched environment changes policy');
+ it.todo('focuses a surviving passkey row during React cleanup after removal');
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts
index 6f5c1f5bb87..bca8f2fc05b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts
@@ -37,7 +37,6 @@ let session: TestSession | null;
let environment: ReturnType;
const clerk = {
- addListener: vi.fn(() => vi.fn()),
get user() {
return user;
},
diff --git a/packages/mosaic/src/hooks/use-list-removal-focus.feature.test.tsx b/packages/mosaic/src/hooks/use-list-removal-focus.feature.test.tsx
deleted file mode 100644
index e43bcbd2f72..00000000000
--- a/packages/mosaic/src/hooks/use-list-removal-focus.feature.test.tsx
+++ /dev/null
@@ -1,115 +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 { Confirmation } from '../blocks/confirmation';
-import { MosaicProvider } from '../mosaic-provider';
-import { useListRemovalFocus } from './use-list-removal-focus';
-
-function CaptureRemovalTarget({
- finalFocus,
- onTarget,
-}: {
- finalFocus: () => HTMLElement | null;
- onTarget: (target: HTMLElement) => void;
-}) {
- useLayoutEffect(() => {
- return () => {
- const target = finalFocus();
- if (target) {
- onTarget(target);
- }
- };
- }, [finalFocus, onTarget]);
- return null;
-}
-
-function RemovableList({ onRestoreTarget }: { onRestoreTarget?: (target: HTMLElement) => void }) {
- const [ids, setIds] = useState(['Laptop', 'Phone']);
- const [selected, setSelected] = useState();
- const add = useRef(null);
- const removalFocus = useListRemovalFocus({
- ids,
- onRemove: () => Promise.resolve(),
- fallback: () => add.current,
- });
-
- return (
- <>
-
- {ids.map(id => (
-
- ))}
- {onRestoreTarget ? (
-
- ) : null}
- {
- if (!open) {
- setSelected(undefined);
- }
- }}
- title='Remove device'
- description={selected}
- actionLabel='Remove'
- finalFocus={removalFocus.finalFocus}
- onConfirm={async () => {
- if (selected === undefined) {
- return;
- }
- await removalFocus.remove(selected);
- setIds(current => current.filter(id => id !== selected));
- setSelected(undefined);
- }}
- />
- >
- );
-}
-
-it.each([
- ['Phone', 'Laptop'],
- ['Laptop', 'Phone'],
-])('removes %s and focuses its surviving neighbor %s', async (removed, remaining) => {
- render(
-
-
- ,
- );
- const user = userEvent.setup();
- await user.click(screen.getByRole('button', { name: `Manage ${removed}` }));
- await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
- expect(screen.queryByRole('button', { name: `Manage ${removed}` })).toBeNull();
- await waitFor(() => expect(screen.getByRole('button', { name: `Manage ${remaining}` })).toHaveFocus());
-});
-
-it('retains surviving row targets during layout-effect cleanup', async () => {
- const targets: HTMLElement[] = [];
- render(
-
- targets.push(target)} />
- ,
- );
- const user = userEvent.setup();
- await user.click(screen.getByRole('button', { name: 'Manage Phone' }));
- await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
- expect(targets).toEqual([screen.getByRole('button', { name: 'Manage Laptop' })]);
-});
diff --git a/packages/mosaic/src/hooks/use-list-removal-focus.ts b/packages/mosaic/src/hooks/use-list-removal-focus.ts
index d68a3b366e1..1c38c6ae607 100644
--- a/packages/mosaic/src/hooks/use-list-removal-focus.ts
+++ b/packages/mosaic/src/hooks/use-list-removal-focus.ts
@@ -1,10 +1,5 @@
import { useRef } from 'react';
-interface RemovalTrigger {
- element: HTMLButtonElement | null;
- ref: (element: HTMLButtonElement | null) => void;
-}
-
export function useListRemovalFocus({
ids,
onRemove,
@@ -14,27 +9,15 @@ export function useListRemovalFocus({
onRemove?: (id: string) => void | Promise;
fallback: () => HTMLElement | null;
}) {
- const triggers = useRef(new Map());
+ const triggers = useRef(new Map());
const removed = useRef<{ id: string; index: number } | undefined>(undefined);
- const registerTrigger = (id: string) => {
- const existing = triggers.current.get(id);
- if (existing) {
- return existing.ref;
+ const registerTrigger = (id: string) => (element: HTMLButtonElement | null) => {
+ if (element) {
+ triggers.current.set(id, element);
+ } else {
+ triggers.current.delete(id);
}
- const trigger: RemovalTrigger = {
- element: null,
- ref: element => {
- trigger.element = element;
- if (element === null) {
- triggers.current.delete(id);
- } else {
- triggers.current.set(id, trigger);
- }
- },
- };
- triggers.current.set(id, trigger);
- return trigger.ref;
};
const remove = async (id: string) => {
@@ -54,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 };
diff --git a/packages/mosaic/src/hooks/use-mosaic-environment.ts b/packages/mosaic/src/hooks/use-mosaic-environment.ts
index 870569dea31..6b66808336c 100644
--- a/packages/mosaic/src/hooks/use-mosaic-environment.ts
+++ b/packages/mosaic/src/hooks/use-mosaic-environment.ts
@@ -1,6 +1,5 @@
import { useClerk } from '@clerk/shared/react';
import type { EnvironmentResource, LoadedClerk } from '@clerk/shared/types';
-import { useEffect, useReducer } from 'react';
/**
* The single seam through which Mosaic reads the active Clerk environment.
@@ -10,18 +9,17 @@ import { useEffect, useReducer } from 'react';
* the environment is `clerk.__internal_environment`. Quarantining that one private
* access here keeps the `@ts-expect-error` out of every caller.
*
+ * This is a one-shot read off the Clerk singleton, NOT a reactive subscription: unlike
+ * clerk-js's `EnvironmentProvider`, `useClerk()` does not re-render when the environment
+ * mutates. So only read fields that are set once at hydration and never change at runtime
+ * (e.g. static `displayConfig` URLs). Anything that can mutate post-hydration
+ * (`authConfig.claimedAt`, live config toggles) will render stale through this hook and
+ * needs a real reactive subscription instead (see `use-revalidate-environment.ts`).
+ *
* Returns `undefined` until the environment hydrates; callers should handle that.
*/
export function useMosaicEnvironment(): EnvironmentResource | undefined {
const clerk = useClerk();
- const [, notify] = useReducer((revision: number) => revision + 1, 0);
-
- useEffect(() => {
- const unsubscribe = clerk.addListener(notify, { skipInitialEmit: true });
- notify();
- return unsubscribe;
- }, [clerk]);
-
return getMosaicEnvironment(clerk);
}
From 6bdbedad073aa16f98065db89b505b28cf3c0c0f Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 15:12:41 -0600
Subject: [PATCH 09/10] fix(mosaic): preserve localized passkey feedback
---
.changeset/passkey-review-remediation.md | 5 -----
...profile-passkeys-lifecycle.feature.test.tsx | 18 ++++++++++++++----
...r-profile-passkeys-section.feature.test.tsx | 1 +
.../user-profile-passkeys-section.view.tsx | 1 +
.../user-profile-passkeys-section.model.ts | 4 ++--
5 files changed, 18 insertions(+), 11 deletions(-)
delete mode 100644 .changeset/passkey-review-remediation.md
diff --git a/.changeset/passkey-review-remediation.md b/.changeset/passkey-review-remediation.md
deleted file mode 100644
index d16c8291df9..00000000000
--- a/.changeset/passkey-review-remediation.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@clerk/mosaic': patch
----
-
-Keep passkey sections tied to the active account when switching users and show passkey name errors on the input.
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
index f0b8bca4bea..91de338addb 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
@@ -404,7 +404,19 @@ describe('Held passkey creation ownership', () => {
describe('Replacing instance policy while editing', () => {
it('rejects a rename after policy replacement disables passkeys', async () => {
const fapi = serveAccounts();
- const { clerk } = await renderWithClerk();
+ const { clerk } = await renderWithClerk(
+
+
+ ,
+ );
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'Manage Alice laptop' }));
await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
@@ -419,9 +431,7 @@ describe('Replacing instance policy while editing', () => {
expect(input).toHaveValue('Blocked rename');
await user.click(screen.getByRole('button', { name: 'Save' }));
rename.release();
- await waitFor(() =>
- expect(screen.getByRole('alert')).toHaveTextContent(/^Something went wrong\. Please try again\.$/),
- );
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Ce compte ne permet plus cette action.'));
expect(input).toHaveValue('Blocked rename');
expect(rename.requests).toHaveLength(0);
expect(clerk.user?.passkeys.find(passkey => passkey.id === 'pk_a')?.name).toBe('Alice laptop');
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
index 4655d1b8927..3eb9a235bfe 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
@@ -126,6 +126,7 @@ describe('Seeing passkeys', () => {
const removal = screen.getByRole('alertdialog', { name: 'Supprimer la clé' });
expect(removal).toHaveAccessibleDescription('Laptop sera supprimée de ce compte.');
await waitFor(() => expect(within(removal).getByRole('button', { name: 'Supprimer' })).toBeVisible());
+ expect(within(removal).getByRole('button', { name: 'Annuler' })).toBeVisible();
});
it.each([
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx
index 6e6938af683..ff4ccc39fc1 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx
@@ -110,6 +110,7 @@ export function UserProfilePasskeysSectionView({
title={m.removeTitle}
description={passkey => fill(m.removeDescription, { name: passkey.name })}
actionLabel={m.remove}
+ cancelLabel={m.cancel}
finalFocus={removalFocus.finalFocus}
onConfirm={passkey => removalFocus.remove(passkey.id)}
/>
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts
index 1c59ebd982d..f9b1a0cfd2b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts
@@ -115,7 +115,7 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
clerk.session?.id !== sessionId ||
getPasskeysProjection(current, currentEnvironment, clerk.isSatellite).status === 'hidden'
) {
- throw new Error(messages.accountUnavailableError);
+ throw new FormSubmitError({ message: messages.accountUnavailableError });
}
return current;
};
@@ -182,7 +182,7 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
onRename: async (id, name) => {
const passkey = currentUser().passkeys.find(candidate => candidate.id === id);
if (!passkey) {
- throw new Error(messages.unavailableError);
+ throw new FormSubmitError({ message: messages.unavailableError });
}
const feedback = validateName(name);
if (feedback) {
From da8aab26a79e8d57a2d17bce207447e8283cf5e1 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 15:36:51 -0600
Subject: [PATCH 10/10] test(mosaic): record duplicate passkey message gap
---
.../__tests__/user-profile-passkeys-section.feature.test.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
index 3eb9a235bfe..eefdd7ee9fa 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
@@ -490,6 +490,7 @@ describe('Passkey reverification', () => {
});
describe('Passkey follow-ups', () => {
+ it.todo('shows a localized duplicate-passkey message instead of the raw SDK error for passkey_already_exists');
it.todo('rerenders passkey eligibility when a replaced or fetched environment changes policy');
it.todo('focuses a surviving passkey row during React cleanup after removal');
});