From ce4451425ece59cdb517761694e55ba1efbb5513 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 09:38:08 -0600
Subject: [PATCH 01/16] 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 bea691e3880..96ce6420946 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,
@@ -163,6 +170,9 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
},
};
+ const pendingPasskeys = new Map();
+ let nextPasskeyId = 1;
+
worker.use(
...verificationHandlers(state, fapiUrl),
...enterpriseHandlers(state, fapiUrl),
@@ -296,6 +306,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 96f7b35f58a406da5323a0b70a4a343612c43327 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 09:39:05 -0600
Subject: [PATCH 02/16] 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-passkeys-section.messages.ts | 11 +
.../user-profile-passkeys-section.view.tsx | 12 +-
...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-security-panel.view.tsx | 29 +--
.../src/hooks/use-mosaic-environment.ts | 6 +-
.../src/app/(clerk)/live/passkeys/page.tsx | 34 +++
packages/swingset/src/lib/live-navigation.ts | 1 +
.../src/stories/fixtures/user-profile.tsx | 7 +-
14 files changed, 450 insertions(+), 57 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 8c23fb41c58..dec8eb33d09 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 { UserProfileDangerSectionView } from '../user-profile-danger-section/user-profile-danger-section.view';
+import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view';
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' },
@@ -125,9 +128,16 @@ describe('UserProfileSecurityPanelView', () => {
const user = userEvent.setup();
renderView({
- onAddPasskey,
- onRenamePasskey,
- onRemovePasskey,
+ passkeysSlot: {
+ content: (
+
+ ),
+ },
onSignOutDevice,
onSignOutAllOtherDevices,
dangerSlot: ,
@@ -177,10 +187,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'],
});
@@ -203,10 +219,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();
@@ -217,7 +230,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();
@@ -232,7 +245,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' }));
@@ -241,8 +264,14 @@ describe('UserProfileSecurityPanelView', () => {
rerender(
+ ),
+ }}
/>
,
);
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 97937f7aba5..6705f15cc21 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,17 +9,14 @@ 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';
-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;
@@ -28,6 +25,7 @@ export interface UserProfilePasskeysSectionViewProps {
export function UserProfilePasskeysSectionView({
passkeys,
onAdd,
+ isAdding,
addError,
onRename,
onRemove,
@@ -66,6 +64,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-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx
index a0ac3448a51..7439a98b8ca 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,22 +11,17 @@ 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';
export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey };
export interface UserProfileSecurityPanelViewProps extends Omit {
passwordSlot?: ReactNode;
- 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;
@@ -37,16 +32,11 @@ export interface UserProfileSecurityPanelViewProps extends Omit}>
@@ -65,15 +54,7 @@ export function UserProfileSecurityPanelView({
{hasAuthentication ? (
{passwordSlot}
- {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 9b97aa31ea3..f58a02ff76d 100644
--- a/packages/swingset/src/lib/live-navigation.ts
+++ b/packages/swingset/src/lib/live-navigation.ts
@@ -17,6 +17,7 @@ const liveRoutes = new Map([
['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/user-profile/user-profile-enterprise-accounts-section', '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/live/enterprise-accounts'],
['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/user-profile/user-profile-danger-section', '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/live/user-danger'],
['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/organization-profile/organization-profile-danger-section', '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/live/organization-danger'],
+ ['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/user-profile/user-profile-passkeys-section', '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/live/passkeys'],
['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/reverification/reverification', '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/live/reverification'],
]);
diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx
index 5498973e112..ab7218239ea 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 { UserProfileDangerSectionView } from '@clerk/mosaic/features/user-profile/user-profile-danger-section/user-profile-danger-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: ,
- passkeys: passkeys.passkeys,
- addPasskeyError: passkeys.addError,
- onRenamePasskey: passkeys.onRename,
+ passkeysSlot: { content: },
...mfa.security,
devices: activeDevices.devices,
- onAddPasskey: passkeys.onAdd,
dangerSlot: ,
- onRemovePasskey: passkeys.onRemove,
onSignOutAllOtherDevices: activeDevices.onSignOutAllOtherDevices,
onSignOutDevice: activeDevices.onSignOutDevice,
},
From 1be46f133cd8287eba2c9d40c5188f040de4fdeb Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 09:40:22 -0600
Subject: [PATCH 03/16] 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 | 277 ----------
3 files changed, 889 insertions(+), 277 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', '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/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 00aeffb265c..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx
+++ /dev/null
@@ -1,277 +0,0 @@
-import { act, render, screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { useState } from 'react';
-import { afterEach, describe, expect, it, vi } from 'vitest';
-
-import { clerkApiError } from '../../../__tests__/clerk-errors';
-import type { MosaicLocalization } from '../../../localization';
-import { MosaicProvider } from '../../../mosaic-provider';
-import type { UserProfilePasskey, UserProfilePasskeysSectionViewProps } from '../user-profile-passkeys-section.view';
-import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
-
-afterEach(() => {
- vi.restoreAllMocks();
-});
-
-const passkeys: UserProfilePasskey[] = [
- { id: 'laptop', name: 'MacBook' },
- { id: 'phone', name: 'iPhone' },
-];
-
-function renderView(overrides: Partial = {}, localization?: MosaicLocalization) {
- 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(clerkApiError('passkey_rename_failed', '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' }));
- await waitFor(() => expect(screen.getByRole('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('shows the rename fallback, never the message, when renaming fails unexpectedly', async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- const user = userEvent.setup();
- renderView(
- { onRename: vi.fn().mockRejectedValue(new Error('Cannot read properties of undefined')) },
- { overrides: { 'userProfilePasskeys.saveError': 'Could not rename this passkey.' } },
- );
- await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
- await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
- await user.type(screen.getByRole('textbox', { name: 'Passkey name' }), ' Pro');
- await user.click(screen.getByRole('button', { name: 'Save' }));
- expect(await screen.findByRole('alert')).toHaveTextContent('Could not rename this passkey.');
- });
-
- 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(clerkApiError('passkey_not_found', '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 }));
- await waitFor(() => expect(screen.getByRole('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 19fd269dd69e615e9134bc01939f2cdc94afe834 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 10:41:52 -0600
Subject: [PATCH 04/16] 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 96283a7db181b6a39419d20c8496b8bca26e5442 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 12:21:31 -0600
Subject: [PATCH 05/16] 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 | 167 +-----------------
.../__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 | 108 +++--------
.../user-profile-rename-passkey.dialog.tsx | 54 +++---
.../src/hooks/use-mosaic-environment.ts | 16 +-
24 files changed, 878 insertions(+), 341 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 e66740ffdfd..c1e2b750ddd 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -3245,6 +3245,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) => {
@@ -3323,7 +3324,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) {
@@ -3739,6 +3740,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 96ce6420946..7b65fd462fb 100644
--- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
@@ -6,15 +6,16 @@ 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 +31,6 @@ import {
fapiMembership,
fapiOrganization,
fapiPage,
- fapiPasskey,
fapiToken,
fapiVerification,
} from './fapi';
@@ -56,6 +56,7 @@ export interface FakeFapiState {
export type FakeFapiSeed = Partial> & {
verification?: FakeVerificationSeed;
enterpriseLinking?: Partial;
+ passkeys?: FakePasskeysSeed;
};
const unhandled: string[] = [];
@@ -77,10 +78,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,14 +89,6 @@ 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;
}
@@ -109,12 +98,6 @@ function requestUser(state: FakeFapiState, request: Request): UserJSON | undefin
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 withoutOrganization(session: SessionJSON, organizationId: string): SessionJSON {
return {
@@ -144,12 +127,9 @@ function deleteOrganization(state: FakeFapiState, organizationId: string): void
};
}
-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(),
@@ -170,12 +150,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('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/v1/environment'), () => HttpResponse.json(state.environment)),
http.get(fapiUrl('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/v1/client'), () => envelope(state.client, null)),
http.get(fapiUrl('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/v1/me'), () => {
@@ -306,139 +284,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 dec8eb33d09..4212eab42ca 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
@@ -119,31 +119,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,
dangerSlot: ,
});
- 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(
@@ -153,18 +139,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' }));
@@ -177,9 +151,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 6705f15cc21..3c84e9255e1 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');
@@ -87,6 +92,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 773e2ff4c17..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,103 +1,49 @@
-import type { ErrorDescription } from '../../localization';
-import { useErrorText, useMessages } from '../../localization';
-import { setup } from '../../machine/setup';
-import { useMachine } from '../../machine/use-machine';
-import { toLocalizableError } from '../../utils/errors';
+import { useState } from 'react';
-interface UserProfileRenamePasskeyContext {
- passkeyId: string;
- savedName: string;
- name: string;
- onRename: ((id: string, name: string) => void | Promise) | undefined;
- error: ErrorDescription | undefined;
-}
-
-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,
- },
- 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((_, event) => ({ error: toLocalizableError(event.error) })),
- },
- }),
- },
- },
-});
+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 m = useMessages('userProfilePasskeys');
- const errorText = useErrorText();
- const [snapshot, send] = useMachine(userProfileRenamePasskeyMachine, {
- context: { passkeyId: id, savedName: name, onRename },
+ 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 ? errorText(snapshot.context.error, m.saveError) : undefined,
+ 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 7fbfb4b952c4a494824da92db5dfa8e1110d557d Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 13:24:06 -0600
Subject: [PATCH 06/16] test(mosaic): repair CI fixtures and cover removal
focus cleanup
---
...rofile-danger-section.integration.test.tsx | 23 ++--
...ile-passkeys-interactions.feature.test.tsx | 2 +-
...-profile-passkeys-section.feature.test.tsx | 4 +-
.../use-list-removal-focus.feature.test.tsx | 115 ++++++++++++++++++
4 files changed, 134 insertions(+), 10 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-danger-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-danger-section.integration.test.tsx
index fe64d963b70..7a7141c842f 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-danger-section.integration.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-danger-section.integration.test.tsx
@@ -14,6 +14,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 {
@@ -24,13 +39,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/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 16a4cb1b0a84a2c104cbd53b025471d77d4ed9bf Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 13:24:15 -0600
Subject: [PATCH 07/16] 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 98617d429f4e9b88487019c6046aa5eb9c1bea38 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 14:09:37 -0600
Subject: [PATCH 08/16] 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-danger-section.integration.test.tsx | 23 ++--
...rofile-passkeys-lifecycle.feature.test.tsx | 12 +-
...-profile-passkeys-section.feature.test.tsx | 11 +-
.../use-list-removal-focus.feature.test.tsx | 115 ------------------
.../src/hooks/use-list-removal-focus.ts | 31 ++---
.../src/hooks/use-mosaic-environment.ts | 16 ++-
10 files changed, 42 insertions(+), 234 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 c1e2b750ddd..e66740ffdfd 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -3245,7 +3245,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) => {
@@ -3324,7 +3323,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) {
@@ -3740,7 +3739,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 9adba0054e8..24c7f314a94 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-danger-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-danger-section.integration.test.tsx
index 7a7141c842f..fe64d963b70 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-danger-section.integration.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-danger-section.integration.test.tsx
@@ -14,21 +14,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 {
@@ -39,7 +24,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/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 270662dd91f7ea10b5c90f0de6acd83bb1ccfd0a Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 15:12:41 -0600
Subject: [PATCH 09/16] 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 3c84e9255e1..5a0c24d00b1 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
@@ -112,6 +112,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 7af3c08186834ddec121d5e6667884e65f81cabb Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 15:36:51 -0600
Subject: [PATCH 10/16] 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');
});
From 84954e3a7140372b0426d21d900a10dfa267161a Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 5 Oct 2026 22:17:27 -0600
Subject: [PATCH 11/16] refactor(mosaic): simplify passkeys composition and
controller props
---
.changeset/passkeys-section-node.md | 2 +
.../mosaic/src/__tests__/feature/fake-fapi.ts | 7 ---
...file-passkeys-composition.feature.test.tsx | 26 ++++++++--
...rofile-passkeys-lifecycle.feature.test.tsx | 5 +-
...-profile-passkeys-section.feature.test.tsx | 12 +----
.../user-profile-security-panel.view.test.tsx | 50 ++++++++-----------
...ser-profile-passkeys-section.controller.ts | 18 ++++++-
.../user-profile-passkeys-section.tsx | 37 +++++---------
.../user-profile-passkeys-section.types.ts | 6 ---
.../user-profile-security-panel.view.tsx | 5 +-
.../src/stories/fixtures/user-profile.tsx | 2 +-
11 files changed, 83 insertions(+), 87 deletions(-)
create mode 100644 .changeset/passkeys-section-node.md
diff --git a/.changeset/passkeys-section-node.md b/.changeset/passkeys-section-node.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/passkeys-section-node.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 7b65fd462fb..d6f0cb70c51 100644
--- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
@@ -93,12 +93,6 @@ 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 withoutOrganization(session: SessionJSON, organizationId: string): SessionJSON {
return {
...session,
@@ -127,7 +121,6 @@ function deleteOrganization(state: FakeFapiState, organizationId: string): void
};
}
-
export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
const { verification, enterpriseLinking, passkeys, ...rest } = seed;
const state: FakeFapiState = {
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx
index 925c8ed9977..27611e00097 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx
@@ -5,7 +5,9 @@ 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 { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
+import { passkeysSectionNode } from '../user-profile-passkeys-section/user-profile-passkeys-section';
+import { useUserProfilePasskeysModel } from '../user-profile-passkeys-section/user-profile-passkeys-section.model';
import { useUserProfilePasswordSlot } from '../user-profile-password-section/user-profile-password-section';
import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
@@ -32,7 +34,7 @@ function serveAccounts() {
function SecurityHost() {
const passwordSlot = useUserProfilePasswordSlot();
- const passkeysSlot = useUserProfilePasskeysSlot();
+ const passkeysSlot = passkeysSectionNode(useUserProfilePasskeysModel());
return (
{
+ it('renders a plain passkeys section node inside Authentication', async () => {
+ serveAccounts();
+ await renderWithClerk(
+
+ }
+ />,
+ );
+
+ const authentication = screen.getByRole('region', { name: 'Authentication' });
+ expect(within(authentication).getByText('No passkeys added')).toBeVisible();
+ expect(within(authentication).getByRole('button', { name: 'Add passkey' })).toBeVisible();
+ });
+
it.each([
{ name: 'Security', Host: SecurityHost },
{ name: 'UserProfile', Host: ProfileHost },
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 91de338addb..ca6f33fc96c 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
@@ -14,9 +14,10 @@ import {
import { renderWithClerk } from '../../../__tests__/feature/render';
import { MosaicProvider } from '../../../mosaic-provider';
import {
+ passkeysSectionNode,
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,
type UserProfileSecurityPanelViewProps,
@@ -25,7 +26,7 @@ import {
afterEach(() => vi.restoreAllMocks());
function SecurityHost(props: Pick = {}) {
- const passkeysSlot = useUserProfilePasskeysSlot();
+ const passkeysSlot = passkeysSectionNode(useUserProfilePasskeysModel());
return (
;
}
@@ -73,14 +73,6 @@ describe('Seeing passkeys', () => {
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(
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 4212eab42ca..e3cef9e08d3 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
@@ -25,7 +25,7 @@ const passkeys = [
];
const props: UserProfileSecurityPanelViewProps = {
- passkeysSlot: { content: },
+ passkeysSlot: ,
mfaMethods: [
{ id: 'sms_1', type: 'sms', description: '+1 801-888-8181' },
{ id: 'totp_1', type: 'authenticator' },
@@ -156,25 +156,21 @@ describe('UserProfileSecurityPanelView', () => {
expect(onDeleteAccount).toHaveBeenCalledOnce();
});
- it('keeps supported empty authentication methods actionable', () => {
+ it('keeps supported empty MFA methods and devices actionable', () => {
renderView({
- passkeysSlot: {
- content: (
-
- ),
- },
+ passkeysSlot: (
+
+ ),
mfaMethods: [],
devices: [],
onAddMfaMethod: vi.fn(),
addableMfaMethods: ['sms', 'authenticator'],
});
- expect(screen.getByText('No passkeys added')).toBeInTheDocument();
expect(screen.getByText('No verification methods added')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Add passkey' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add verification method' })).toBeInTheDocument();
expect(screen.getByText('No current device available')).toBeInTheDocument();
});
@@ -201,7 +197,7 @@ describe('UserProfileSecurityPanelView', () => {
});
it('keeps the passkeys card in the authentication section when passkeys are empty and Add is unavailable', () => {
- renderView({ passkeysSlot: { content: } });
+ renderView({ passkeysSlot: });
const section = screen.getByRole('region', { name: 'Authentication' });
expect(within(section).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeVisible();
@@ -218,14 +214,12 @@ describe('UserProfileSecurityPanelView', () => {
});
const { rerender } = renderView({
mfaMethods: undefined,
- passkeysSlot: {
- content: (
-
- ),
- },
+ passkeysSlot: (
+
+ ),
});
await user.click(screen.getByRole('button', { name: 'Manage Passkey' }));
@@ -235,14 +229,12 @@ describe('UserProfileSecurityPanelView', () => {
rerender(
- ),
- }}
+ passkeysSlot={
+
+ }
/>
,
);
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts
index fee34e04de6..577860e4de8 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts
@@ -1,6 +1,12 @@
import { useMessages } from '../../../localization';
import { setup } from '../../../machine/setup';
import { useMachine } from '../../../machine/use-machine';
+import type { UserProfilePasskeysModel } from './user-profile-passkeys-section.model';
+
+type PasskeysControllerInput = Pick<
+ Extract,
+ 'passkeys' | 'onAdd' | 'onRename' | 'validateName' | 'onRemove'
+>;
interface Context {
run: () => Promise;
@@ -44,12 +50,22 @@ const machine = createMachine({
},
});
-export function useUserProfilePasskeysSectionController({ onAdd }: { onAdd?: () => Promise }) {
+export function useUserProfilePasskeysSectionController({
+ passkeys,
+ onAdd,
+ onRename,
+ validateName,
+ onRemove,
+}: PasskeysControllerInput) {
const messages = useMessages('userProfilePasskeys');
const [snapshot, send] = useMachine(machine);
return {
+ passkeys,
isAdding: snapshot.value === 'creating',
addError: snapshot.context.error,
onAdd: onAdd ? () => send({ type: 'ADD', run: onAdd, fallbackErrorMessage: messages.saveError }) : undefined,
+ onRename,
+ validateName,
+ onRemove,
};
}
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 b969ae8d541..8d7cd116e88 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
@@ -4,45 +4,32 @@ import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section
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;
+ const model = useUserProfilePasskeysModel();
+ return passkeysSectionNode(model, props.fallback);
}
-export function useUserProfilePasskeysSlot({
- fallback = null,
-}: UserProfilePasskeysSectionProps = {}): UserProfilePasskeysSlot | null {
- const model = useUserProfilePasskeysModel();
+export function passkeysSectionNode(model: UserProfilePasskeysModel, fallback: ReactNode = null): ReactNode {
if (model.status === 'loading') {
- return fallback ? { content: fallback } : null;
+ return fallback || null;
}
if (model.status === 'hidden') {
return null;
}
- return {
- content: (
-
- ),
- };
-}
-
-function PasskeysEditor({ model }: { model: Extract }) {
- const controller = useUserProfilePasskeysSectionController({ onAdd: model.onAdd });
return (
-
);
}
+
+function PasskeysEditor({ model }: { model: Extract }) {
+ const controller = useUserProfilePasskeysSectionController(model);
+ 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
index 69a3105e71a..05fb89570c2 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,3 @@
-import type { ReactNode } from 'react';
-
import type { FieldFeedback } from '../../../components/form';
export type UserProfilePasskeyNameValidator = (name: string) => FieldFeedback | undefined;
@@ -14,7 +12,3 @@ export interface UserProfilePasskey {
createdAtLabel?: string;
lastUsedAtLabel?: string;
}
-
-export interface UserProfilePasskeysSlot {
- content: ReactNode;
-}
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 7439a98b8ca..3fa5419e926 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,13 +11,12 @@ 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 type { UserProfilePasskeysSlot } from './user-profile-passkeys-section/user-profile-passkeys-section.types';
export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey };
export interface UserProfileSecurityPanelViewProps extends Omit {
passwordSlot?: ReactNode;
- passkeysSlot?: UserProfilePasskeysSlot | null;
+ passkeysSlot?: ReactNode;
mfaMethods?: UserProfileMfaMethod[];
addableMfaMethods?: readonly UserProfileMfaAddableMethod[];
mfaAddControl?: ReactNode;
@@ -54,7 +53,7 @@ export function UserProfileSecurityPanelView({
{hasAuthentication ? (
{passwordSlot}
- {passkeysSlot?.content}
+ {passkeysSlot}
{mfaMethods !== undefined ? (
,
- passkeysSlot: { content: },
+ passkeysSlot: ,
...mfa.security,
devices: activeDevices.devices,
dangerSlot: ,
From 884599072beed71b877916128ca77ab8fb7508f9 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Tue, 6 Oct 2026 10:11:43 -0600
Subject: [PATCH 12/16] fix(mosaic): preserve structured passkey action errors
---
...rofile-passkeys-lifecycle.feature.test.tsx | 29 ++++++++
...-profile-passkeys-section.feature.test.tsx | 14 +++-
...ofile-passkeys-validation.feature.test.tsx | 29 +++++++-
...ser-profile-passkeys-section.controller.ts | 22 +++---
.../user-profile-passkeys-section.model.ts | 69 ++++---------------
5 files changed, 90 insertions(+), 73 deletions(-)
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 ca6f33fc96c..54f90101ac5 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
@@ -69,6 +69,35 @@ function authenticator() {
});
}
+describe('Passkey action errors', () => {
+ it('hides an unknown Add failure and updates the fallback when localization changes', async () => {
+ serveAccounts();
+ const view = await renderWithClerk(
+
+
+ ,
+ );
+ const current = view.clerk.user;
+ if (!current) {
+ throw new Error('Expected the signed-in user');
+ }
+ vi.spyOn(current, 'createPasskey').mockRejectedValue(new Error('Private authenticator details'));
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Add passkey' }));
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not add this passkey.'));
+ expect(screen.queryByText('Private authenticator details')).toBeNull();
+ view.rerender(
+
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Impossible de créer cette clé.'));
+ });
+});
+
describe('Changing the active passkey account', () => {
it('clears the previous account Add error after a user switch', async () => {
serveAccounts();
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 5d5d37cd87e..e18106e3702 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
@@ -422,9 +422,17 @@ describe('Removing a passkey', () => {
expect(fapi.client.sessions[0]?.user.passkeys).toEqual([]);
});
- it('keeps confirmation open on verification-required errors and retries manually', async () => {
+ it('keeps confirmation open on localized verification-required errors and retries manually', async () => {
const fapi = servePasskeys();
- await renderWithClerk();
+ await renderWithClerk(
+
+
+ ,
+ );
const removal = holdRequests('post', '/v1/me/passkeys/pk_1');
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'Manage Laptop' }));
@@ -432,7 +440,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');
- await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('session_reverification_required'));
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Veuillez confirmer votre identité.'));
expect(screen.getByRole('alertdialog')).toBeVisible();
expect(screen.queryByLabelText('Password')).toBeNull();
expect(removal.requests).toHaveLength(1);
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
index a4deb7533ea..b6d0a5b78ed 100644
--- 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
@@ -1,7 +1,7 @@
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 { afterEach, describe, expect, it, vi } from 'vitest';
import { fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
import { fapiClient, fapiEnvironment, fapiPasskey, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
@@ -9,6 +9,8 @@ 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 servePasskeys() {
const environment = fapiEnvironment();
environment.user_settings.attributes.passkey.enabled = true;
@@ -21,6 +23,31 @@ function servePasskeys() {
}
describe('Validating a passkey name', () => {
+ it('uses the localized Form fallback for an unknown rename failure and preserves the draft', async () => {
+ servePasskeys();
+ const { clerk } = await renderWithClerk(
+
+
+ ,
+ );
+ const passkey = clerk.user?.passkeys[0];
+ if (!passkey) {
+ throw new Error('Expected the signed-in passkey');
+ }
+ vi.spyOn(passkey, 'update').mockRejectedValue(new Error('Private native failure'));
+ 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, ' renamed');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Impossible de sauvegarder.'));
+ expect(screen.queryByText('Private native failure')).toBeNull();
+ expect(input).toHaveValue('Laptop renamed');
+ expect(screen.getByRole('dialog')).toBeVisible();
+ expect(input).not.toHaveAttribute('aria-invalid', 'true');
+ });
+
it('marks localized server name errors on the field and clears them when the draft changes', async () => {
servePasskeys();
worker.use(
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts
index 577860e4de8..c570ba01eee 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts
@@ -1,6 +1,8 @@
-import { useMessages } from '../../../localization';
+import type { ErrorDescription } from '../../../localization';
+import { useErrorText, useMessages } from '../../../localization';
import { setup } from '../../../machine/setup';
import { useMachine } from '../../../machine/use-machine';
+import { toLocalizableError } from '../../../utils/errors';
import type { UserProfilePasskeysModel } from './user-profile-passkeys-section.model';
type PasskeysControllerInput = Pick<
@@ -10,18 +12,17 @@ type PasskeysControllerInput = Pick<
interface Context {
run: () => Promise;
- error: string | undefined;
- fallbackErrorMessage: string;
+ error: ErrorDescription | undefined;
}
-type Event = { type: 'ADD'; run: () => Promise; fallbackErrorMessage: string };
+type Event = { type: 'ADD'; run: () => Promise };
const { createMachine, assign, fromPromise } = setup();
const machine = createMachine({
id: 'createPasskey',
initial: 'idle',
- context: { run: () => Promise.resolve(), error: undefined, fallbackErrorMessage: '' },
+ context: { run: () => Promise.resolve(), error: undefined },
states: {
idle: {
on: {
@@ -30,7 +31,6 @@ const machine = createMachine({
actions: assign((_, event) => ({
run: event.run,
error: undefined,
- fallbackErrorMessage: event.fallbackErrorMessage,
})),
},
},
@@ -40,10 +40,7 @@ const machine = createMachine({
onDone: { target: 'idle' },
onError: {
target: 'idle',
- actions: assign((context, event) => ({
- error:
- event.error instanceof Error && event.error.message ? event.error.message : context.fallbackErrorMessage,
- })),
+ actions: assign((_, event) => ({ error: toLocalizableError(event.error) })),
},
}),
},
@@ -58,12 +55,13 @@ export function useUserProfilePasskeysSectionController({
onRemove,
}: PasskeysControllerInput) {
const messages = useMessages('userProfilePasskeys');
+ const errorText = useErrorText();
const [snapshot, send] = useMachine(machine);
return {
passkeys,
isAdding: snapshot.value === 'creating',
- addError: snapshot.context.error,
- onAdd: onAdd ? () => send({ type: 'ADD', run: onAdd, fallbackErrorMessage: messages.saveError }) : undefined,
+ addError: snapshot.context.error ? errorText(snapshot.context.error, messages.saveError) : undefined,
+ onAdd: onAdd ? () => send({ type: 'ADD', run: onAdd }) : undefined,
onRename,
validateName,
onRemove,
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 f9b1a0cfd2b..7650fc2aac2 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
@@ -1,11 +1,11 @@
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 { FormSubmitError } from '../../../components/form';
import { getMosaicEnvironment, useMosaicEnvironment } from '../../../hooks/use-mosaic-environment';
-import { fill, useErrorText, useLocale, useMessages } from '../../../localization';
+import { fill, useLocale, useMessages } from '../../../localization';
+import { save, SaveError } from '../../../utils/errors';
import type {
UserProfilePasskey,
UserProfilePasskeyNameValidator,
@@ -87,7 +87,6 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
const environment = useMosaicEnvironment();
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;
@@ -115,30 +114,13 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
clerk.session?.id !== sessionId ||
getPasskeysProjection(current, currentEnvironment, clerk.isSatellite).status === 'hidden'
) {
- throw new FormSubmitError({ message: messages.accountUnavailableError });
+ throw new SaveError({
+ global: { code: 'passkey_account_unavailable', message: 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) {
@@ -170,13 +152,11 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
? async () => {
const current = currentUser();
if (clerk.isSatellite) {
- throw new Error(messages.accountUnavailableError);
- }
- try {
- await current.createPasskey();
- } catch (error) {
- throw actionError(error);
+ throw new SaveError({
+ global: { code: 'passkey_account_unavailable', message: messages.accountUnavailableError },
+ });
}
+ await save(() => current.createPasskey());
}
: undefined,
onRename: async (id, name) => {
@@ -188,39 +168,14 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
if (feedback) {
throw new FormSubmitError({ fields: { name: feedback.message } });
}
- try {
- await passkey.update({ name });
- } catch (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 });
- }
+ await save(() => passkey.update({ name }), ['name']);
},
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);
+ throw new SaveError({ global: { code: 'passkey_unavailable', message: messages.unavailableError } });
}
+ await save(() => passkey.delete());
},
};
}
From 73064da8cb4ab099c1feba564b86a6ffbe876a33 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Tue, 6 Oct 2026 11:16:40 -0600
Subject: [PATCH 13/16] chore: keep one empty changeset
---
.changeset/tall-pandas-wait.md | 2 --
1 file changed, 2 deletions(-)
delete mode 100644 .changeset/tall-pandas-wait.md
diff --git a/.changeset/tall-pandas-wait.md b/.changeset/tall-pandas-wait.md
deleted file mode 100644
index a845151cc84..00000000000
--- a/.changeset/tall-pandas-wait.md
+++ /dev/null
@@ -1,2 +0,0 @@
----
----
From 190e5d0f3e79f00feea43c41d5035a05b5170529 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 13:29:00 -0600
Subject: [PATCH 14/16] refactor(mosaic): use pending action for passkey
creation
---
...ofile-passkeys-section.controller.test.tsx | 77 +++++++++++++++++++
...ser-profile-passkeys-section.controller.ts | 53 ++-----------
.../__tests__/use-pending-action.test.tsx | 20 +++++
.../mosaic/src/hooks/use-pending-action.ts | 4 +-
4 files changed, 105 insertions(+), 49 deletions(-)
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.test.tsx
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.test.tsx
new file mode 100644
index 00000000000..fead8651fd4
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.test.tsx
@@ -0,0 +1,77 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { deferred } from '../../../__tests__/async';
+import { clerkApiError } from '../../../__tests__/clerk-errors';
+import { MosaicProvider } from '../../../mosaic-provider';
+import { useUserProfilePasskeysSectionController } from './user-profile-passkeys-section.controller';
+
+function renderController(onAdd: () => Promise) {
+ return renderHook(
+ () =>
+ useUserProfilePasskeysSectionController({
+ passkeys: [],
+ onAdd,
+ onRename: async () => {},
+ validateName: () => undefined,
+ onRemove: async () => {},
+ }),
+ {
+ wrapper: ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ ),
+ },
+ );
+}
+
+describe('useUserProfilePasskeysSectionController', () => {
+ it('keeps one Add action pending and clears its error on retry', async () => {
+ const pending = deferred();
+ const onAdd = vi.fn(() => pending.promise);
+ const { result } = renderController(onAdd);
+
+ act(() => {
+ result.current.onAdd?.();
+ result.current.onAdd?.();
+ });
+
+ expect(onAdd).toHaveBeenCalledOnce();
+ expect(result.current.isAdding).toBe(true);
+
+ await act(async () => {
+ pending.reject(clerkApiError('action_blocked', 'Raw server sentence.'));
+ await pending.promise.catch(() => {});
+ });
+
+ await waitFor(() => expect(result.current.addError).toBe('Action refusée.'));
+ expect(result.current.isAdding).toBe(false);
+
+ const retry = deferred();
+ onAdd.mockImplementation(() => retry.promise);
+ act(() => result.current.onAdd?.());
+
+ expect(result.current.addError).toBeUndefined();
+ expect(result.current.isAdding).toBe(true);
+ await act(async () => {
+ retry.resolve();
+ await retry.promise;
+ });
+ await waitFor(() => expect(result.current.isAdding).toBe(false));
+ expect(result.current.addError).toBeUndefined();
+ expect(onAdd).toHaveBeenCalledTimes(2);
+ });
+
+ it('shows a localized error when Add throws before returning a promise', async () => {
+ const { result } = renderController(() => {
+ throw clerkApiError('action_blocked', 'Raw server sentence.');
+ });
+
+ act(() => result.current.onAdd?.());
+
+ await waitFor(() => expect(result.current.addError).toBe('Action refusée.'));
+ expect(result.current.isAdding).toBe(false);
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts
index c570ba01eee..524fb7fbf32 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts
@@ -1,8 +1,5 @@
-import type { ErrorDescription } from '../../../localization';
-import { useErrorText, useMessages } from '../../../localization';
-import { setup } from '../../../machine/setup';
-import { useMachine } from '../../../machine/use-machine';
-import { toLocalizableError } from '../../../utils/errors';
+import { usePendingAction } from '../../../hooks/use-pending-action';
+import { useMessages } from '../../../localization';
import type { UserProfilePasskeysModel } from './user-profile-passkeys-section.model';
type PasskeysControllerInput = Pick<
@@ -10,43 +7,6 @@ type PasskeysControllerInput = Pick<
'passkeys' | 'onAdd' | 'onRename' | 'validateName' | 'onRemove'
>;
-interface Context {
- run: () => Promise;
- error: ErrorDescription | undefined;
-}
-
-type Event = { type: 'ADD'; run: () => Promise };
-
-const { createMachine, assign, fromPromise } = setup();
-
-const machine = createMachine({
- id: 'createPasskey',
- initial: 'idle',
- context: { run: () => Promise.resolve(), error: undefined },
- states: {
- idle: {
- on: {
- ADD: {
- target: 'creating',
- actions: assign((_, event) => ({
- run: event.run,
- error: undefined,
- })),
- },
- },
- },
- creating: {
- invoke: fromPromise(context => context.run(), {
- onDone: { target: 'idle' },
- onError: {
- target: 'idle',
- actions: assign((_, event) => ({ error: toLocalizableError(event.error) })),
- },
- }),
- },
- },
-});
-
export function useUserProfilePasskeysSectionController({
passkeys,
onAdd,
@@ -55,13 +15,12 @@ export function useUserProfilePasskeysSectionController({
onRemove,
}: PasskeysControllerInput) {
const messages = useMessages('userProfilePasskeys');
- const errorText = useErrorText();
- const [snapshot, send] = useMachine(machine);
+ const add = usePendingAction<'add'>({ errorFallback: messages.saveError });
return {
passkeys,
- isAdding: snapshot.value === 'creating',
- addError: snapshot.context.error ? errorText(snapshot.context.error, messages.saveError) : undefined,
- onAdd: onAdd ? () => send({ type: 'ADD', run: onAdd }) : undefined,
+ isAdding: add.isPending,
+ addError: add.error,
+ onAdd: onAdd ? () => void add.run('add', onAdd) : undefined,
onRename,
validateName,
onRemove,
diff --git a/packages/mosaic/src/hooks/__tests__/use-pending-action.test.tsx b/packages/mosaic/src/hooks/__tests__/use-pending-action.test.tsx
index cc6e7283c74..e4febb92444 100644
--- a/packages/mosaic/src/hooks/__tests__/use-pending-action.test.tsx
+++ b/packages/mosaic/src/hooks/__tests__/use-pending-action.test.tsx
@@ -132,6 +132,26 @@ describe('usePendingAction', () => {
expect(result.current.error).toBe('Unable to sign out.');
});
+ it.each([
+ [undefined, 'Impossible de créer cette clé.'],
+ ['Action-specific fallback.', 'Action-specific fallback.'],
+ ])('updates the hook fallback after failure while preserving a run override of %s', async (runFallback, expected) => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result, rerender } = renderHook(({ fallback }) => usePendingAction({ errorFallback: fallback }), {
+ initialProps: { fallback: 'Could not add this passkey.' },
+ });
+
+ await act(() =>
+ result.current.run('add', () => Promise.reject(new Error('Private authenticator details')), {
+ errorFallback: runFallback,
+ }),
+ );
+
+ expect(result.current.error).toBe(runFallback ?? 'Could not add this passkey.');
+ rerender({ fallback: 'Impossible de créer cette clé.' });
+ expect(result.current.error).toBe(expected);
+ });
+
it('prefers the fallback given to the run over the one given to the hook', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => usePendingAction({ errorFallback: 'Something went wrong.' }));
diff --git a/packages/mosaic/src/hooks/use-pending-action.ts b/packages/mosaic/src/hooks/use-pending-action.ts
index 289413d32a9..9ca44597fcb 100644
--- a/packages/mosaic/src/hooks/use-pending-action.ts
+++ b/packages/mosaic/src/hooks/use-pending-action.ts
@@ -47,7 +47,7 @@ export function usePendingAction({
await action();
return true;
} catch (cause) {
- setFailure({ key, error: toLocalizableError(cause), errorFallback: options.errorFallback ?? errorFallback });
+ setFailure({ key, error: toLocalizableError(cause), errorFallback: options.errorFallback });
return false;
} finally {
running.current = false;
@@ -59,7 +59,7 @@ export function usePendingAction({
run,
pendingKey,
isPending: pendingKey !== undefined,
- error: failure ? errorText(failure.error, failure.errorFallback) : undefined,
+ error: failure ? errorText(failure.error, failure.errorFallback ?? errorFallback) : undefined,
errorKey: failure?.key,
reset: () => setFailure(undefined),
};
From f1fd73e65b50529afff7a4feb2bb7ebca3b4d67b Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 13:30:04 -0600
Subject: [PATCH 15/16] chore(mosaic): track passkey useNow follow-up
---
.../user-profile-passkeys-section.model.ts | 2 ++
1 file changed, 2 insertions(+)
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 7650fc2aac2..8c20d21ac01 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
@@ -122,6 +122,8 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
};
function formatPasskeyDate(date: Date): string {
+ // TODO: Read relativeTo from useNow({ updateInterval: 60_000 }) once
+ // https://github.com/clerk/javascript/pull/10098 merges.
const relative = formatRelative({ date, relativeTo: new Date() });
if (!relative) {
return '';
From af42c2b1cc40b9afee7063b75a198ee6e0de99e7 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 7 Oct 2026 14:20:15 -0600
Subject: [PATCH 16/16] test(mosaic): align authentication composition with
latest main
---
.../user-profile-passkeys-composition.feature.test.tsx | 7 ++++---
.../__tests__/user-profile-security-panel.view.test.tsx | 2 +-
.../user-profile-security-panel.feature.test.tsx | 3 ++-
3 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx
index 27611e00097..f14a095dd5a 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx
@@ -8,7 +8,8 @@ import { UserProfileView } from '../user-profile.view';
import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
import { passkeysSectionNode } from '../user-profile-passkeys-section/user-profile-passkeys-section';
import { useUserProfilePasskeysModel } from '../user-profile-passkeys-section/user-profile-passkeys-section.model';
-import { useUserProfilePasswordSlot } from '../user-profile-password-section/user-profile-password-section';
+import { renderPasswordSection } from '../user-profile-password-section/user-profile-password-section';
+import { useUserProfilePasswordModel } from '../user-profile-password-section/user-profile-password-section.model';
import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
afterEach(() => vi.restoreAllMocks());
@@ -33,7 +34,7 @@ function serveAccounts() {
}
function SecurityHost() {
- const passwordSlot = useUserProfilePasswordSlot();
+ const passwordSlot = renderPasswordSection(useUserProfilePasswordModel(), null);
const passkeysSlot = passkeysSectionNode(useUserProfilePasskeysModel());
return (
{
it('renders supplied password content without a separate visibility flag', () => {
renderView({
passwordSlot: Password
,
- passkeys: undefined,
+ passkeysSlot: undefined,
mfaMethods: undefined,
});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.feature.test.tsx
index aa2a7ddcfc4..fd636f0b5a2 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.feature.test.tsx
@@ -11,6 +11,7 @@ import {
fapiUser,
} from '../../__tests__/feature/fapi';
import { renderWithClerk } from '../../__tests__/feature/render';
+import { UserProfilePasskeysSectionView } from './user-profile-passkeys-section.view';
import { UserProfileSecurityPanel } from './user-profile-security-panel';
const email = fapiEmailAddress({ id: 'idn_1', email_address: 'person@example.com' });
@@ -66,7 +67,7 @@ describe('UserProfileSecurityPanel', () => {
const environment = fapiEnvironment();
environment.user_settings.attributes.password.enabled = false;
serveFapi({ environment, client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]) });
- await renderWithClerk();
+ await renderWithClerk(} />);
expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Passkeys');
expect(screen.queryByText('Password')).toBeNull();