diff --git a/.changeset/tall-pandas-wait.md b/.changeset/tall-pandas-wait.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/tall-pandas-wait.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
index 8dae98cfedd..aad060ecf28 100644
--- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
@@ -6,14 +6,15 @@ import type {
OAuthProvider,
OrganizationMembershipJSON,
OrganizationSuggestionJSON,
- 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,
@@ -54,6 +55,7 @@ export interface FakeFapiState {
export type FakeFapiSeed = Partial> & {
verification?: FakeVerificationSeed;
enterpriseLinking?: Partial;
+ passkeys?: FakePasskeysSeed;
};
const unhandled: string[] = [];
@@ -75,10 +77,6 @@ export function takeUnhandledRequests(): string[] {
return unhandled.splice(0);
}
-function envelope(response: JsonBodyType, client: ClientJSON | null) {
- return HttpResponse.json({ response, client });
-}
-
function page(items: T[], url: URL) {
const offset = Number(url.searchParams.get('offset') ?? 0);
const limit = Number(url.searchParams.get('limit') ?? items.length);
@@ -90,31 +88,11 @@ function withStatus(items: T[], url: URL): T[] {
return statuses.length ? items.filter(item => statuses.includes(item.status)) : items;
}
-function findSession(state: FakeFapiState, id: unknown): SessionJSON | undefined {
- return state.client.sessions.find(session => session.id === id);
-}
-
-function error(code: string, status = 400) {
- return HttpResponse.json({ errors: [{ code, message: code, long_message: code }] }, { status });
-}
-
function activeUser(state: FakeFapiState): UserJSON | undefined {
return findSession(state, state.client.last_active_session_id)?.user;
}
-
-function updateUser(state: FakeFapiState, user: UserJSON): void {
- state.client = {
- ...state.client,
- sessions: state.client.sessions.map(session => (session.user.id === user.id ? { ...session, user } : session)),
- };
-}
-
-function missing() {
- return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
-}
-
export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
- const { verification, enterpriseLinking, ...rest } = seed;
+ const { verification, enterpriseLinking, passkeys, ...rest } = seed;
const state: FakeFapiState = {
environment: fapiEnvironment(),
client: fapiClient(),
@@ -138,6 +116,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
worker.use(
...verificationHandlers(state, fapiUrl),
...enterpriseHandlers(state, fapiUrl),
+ ...passkeyHandlers(state, fapiUrl, passkeys),
http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)),
http.get(fapiUrl('/v1/client'), () => envelope(state.client, null)),
http.get(fapiUrl('/v1/me'), () => {
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/__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,
+ };
+}
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__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx
index 5485c1364b7..b8ad9ce4d13 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx
@@ -280,8 +280,9 @@ describe('useUserProfileAccountSectionModel', () => {
});
it('rethrows a failure that is not from Clerk', async () => {
- user?.update.mockRejectedValue(new TypeError('boom'));
- await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).rejects.toThrow('boom');
+ const error = new TypeError('boom');
+ user?.update.mockRejectedValue(error);
+ await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).rejects.toBe(error);
});
describe('username', () => {
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx
index 3080ab7ffdb..1da732402a0 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx
@@ -486,7 +486,9 @@ describe('connected accounts', () => {
await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
await waitFor(() => expect(request.requests).toHaveLength(1));
request.fail('last_identification', 'You cannot remove your last sign-in method.');
- expect(await within(dialog).findByRole('alert')).toHaveTextContent('You cannot remove your last sign-in method.');
+ await waitFor(() =>
+ expect(within(dialog).getByRole('alert')).toHaveTextContent('You cannot remove your last sign-in method.'),
+ );
serveFapi(signedIn([google]));
await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-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..b34adeb3006
--- /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' }));
+ await waitFor(() => expect(screen.getByRole('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
new file mode 100644
index 00000000000..91de338addb
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx
@@ -0,0 +1,439 @@
+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('Chrome on macOS'),
+ );
+ 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('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 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));
+ 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(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('rejects a rename after policy replacement disables passkeys', 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;
+ const rename = holdRequests('post', '/v1/me/passkeys/pk_a');
+ await act(() => clerk.__internal_setEnvironment(nextEnvironment));
+ expect(input).toHaveValue('Blocked rename');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+ rename.release();
+ 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
new file mode 100644
index 00000000000..eefdd7ee9fa
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.feature.test.tsx
@@ -0,0 +1,496 @@
+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());
+ expect(within(removal).getByRole('button', { name: 'Annuler' })).toBeVisible();
+ });
+
+ it.each([
+ [-8, '8.9.2026'],
+ [-3, 'Letzten Sonntag um 12:30'],
+ [-1, 'Gestern um 12:30'],
+ [0, 'Heute um 12:30'],
+ [1, 'Morgen um 12:30'],
+ [3, 'Samstag um 12:30'],
+ [8, '24.9.2026'],
+ ])('localizes passkey dates %i days from today', async (offset, lastUsed) => {
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(2026, 8, 16, 12, 30));
+ servePasskeys([
+ fapiPasskey({
+ id: 'pk_1',
+ created_at: new Date(2026, 7, 20, 12, 30).getTime(),
+ last_used_at: new Date(2026, 8, 16 + offset, 12, 30).getTime(),
+ }),
+ ]);
+ await renderWithClerk(
+
+
+ ,
+ );
+ expect(screen.getByText(/Created:|Erstellt:/)).toHaveTextContent(
+ `Erstellt: 20.8.2026 · Zuletzt verwendet: ${lastUsed}`,
+ );
+ });
+
+ it('shows a fallback while Clerk loads and then shows existing passkeys', async () => {
+ servePasskeys();
+ const environment = holdRequests('get', '/v1/environment');
+ const rendering = renderWithClerk(Loading passkeys
} />);
+ expect(await screen.findByText('Loading passkeys')).toBeVisible();
+ environment.release();
+ await rendering;
+ expect(screen.queryByText('Loading passkeys')).toBeNull();
+ expect(screen.getByText('Laptop')).toBeVisible();
+ });
+
+ it.each(['signed out', 'disabled', 'managed by enterprise'] as const)('hides the section when %s', async state => {
+ const fapi = servePasskeys();
+ if (state === 'signed out') {
+ fapi.client = fapiClient();
+ } else if (state === 'disabled') {
+ fapi.environment.user_settings.attributes.passkey.enabled = false;
+ } else {
+ fapi.environment.user_settings.enterprise_sso.enabled = true;
+ const session = fapi.client.sessions[0];
+ if (session) {
+ session.user.enterprise_accounts = [fapiEnterpriseAccount({ id: 'enterprise_1' })];
+ }
+ }
+ await renderWithClerk();
+ expect(screen.queryByRole('group', { name: 'Passkeys' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Add passkey' })).toBeNull();
+ });
+});
+
+describe('Adding a passkey', () => {
+ it('holds Add pending, creates one credential, and displays the new passkey', async () => {
+ const fapi = servePasskeys([]);
+ const authenticator = fakeAuthenticator();
+ await renderWithClerk();
+ expect(screen.getByText('No passkeys added')).toBeVisible();
+ const creation = holdRequests('post', '/v1/me/passkeys');
+ const user = userEvent.setup();
+ const add = screen.getByRole('button', { name: 'Add passkey' });
+ await user.click(add);
+ await waitFor(() => expect(creation.requests).toHaveLength(1));
+ expect(add).toHaveTextContent(/^Add$/);
+ expect(add).toBeDisabled();
+ expect(add).toHaveAttribute('aria-busy', 'true');
+ await user.click(add);
+ expect(creation.requests).toHaveLength(1);
+ creation.release();
+ expect(await screen.findByText('Chrome on macOS')).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('Chrome on macOS')).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' }));
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Impossible de créer la clé.'));
+ const alert = screen.getByRole('alert');
+ expect(alert).toHaveAttribute('data-open');
+ await waitFor(() => expect(alert).toBeVisible());
+ 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('Chrome on macOS')).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' }));
+ 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é');
+ });
+
+ 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();
+ await waitFor(() => expect(screen.getByRole('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');
+ 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);
+ 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');
+});
+
+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');
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx
deleted file mode 100644
index d610e9dc132..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx
+++ /dev/null
@@ -1,251 +0,0 @@
-import { act, render, screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { useState } from 'react';
-import { describe, expect, it, vi } from 'vitest';
-
-import { MosaicProvider } from '../../../mosaic-provider';
-import type { UserProfilePasskey, UserProfilePasskeysSectionViewProps } from '../user-profile-passkeys-section.view';
-import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
-
-const passkeys: UserProfilePasskey[] = [
- { id: 'laptop', name: 'MacBook' },
- { id: 'phone', name: 'iPhone' },
-];
-
-function renderView(overrides: Partial = {}) {
- const props: UserProfilePasskeysSectionViewProps = {
- passkeys,
- onAdd: vi.fn(),
- onRename: vi.fn(),
- onRemove: vi.fn(),
- ...overrides,
- };
- return render(
-
-
- ,
- );
-}
-
-describe('passkeys section', () => {
- it('shows existing passkeys without actions when their callbacks are unavailable', () => {
- renderView({ onAdd: undefined, onRename: undefined, onRemove: undefined });
-
- expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible();
- expect(screen.getByRole('heading', { name: 'Passkeys' })).toBeVisible();
- expect(screen.getByText('MacBook')).toBeVisible();
- expect(screen.getByText('iPhone')).toBeVisible();
- expect(screen.queryByRole('button')).not.toBeInTheDocument();
- });
-
- it('confirms the selected passkey and returns focus on cancellation', async () => {
- const user = userEvent.setup();
- const onRemove = vi.fn();
- render(
-
-
- ,
- );
-
- await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription('MacBook will be removed from this account.');
- expect(onRemove).not.toHaveBeenCalled();
- await user.click(screen.getByRole('button', { name: 'Cancel' }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- expect(screen.getByRole('button', { name: 'Manage MacBook' })).toHaveFocus();
-
- await user.click(screen.getByRole('button', { name: 'Manage iPhone' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- const dialog = screen.getByRole('alertdialog');
- expect(dialog).toHaveAccessibleDescription('iPhone will be removed from this account.');
- await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true }));
- expect(onRemove).toHaveBeenCalledExactlyOnceWith('phone');
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- });
- it.each([
- ['MacBook', 'iPhone'],
- ['iPhone', 'MacBook'],
- ])('returns focus from removed %s to remaining %s', async (removed, remaining) => {
- const user = userEvent.setup();
- function Example() {
- const [items, setItems] = useState(passkeys);
- return (
-
- setItems(current => current.filter(item => item.id !== id))}
- />
-
- );
- }
- render();
- await user.click(screen.getByRole('button', { name: `Manage ${removed}` }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
- await waitFor(() => expect(screen.getByRole('button', { name: `Manage ${remaining}` })).toHaveFocus());
- });
-
- it('renames through a prefilled form, preserves the draft after failure, and retries', async () => {
- const user = userEvent.setup();
- const onRename = vi.fn().mockRejectedValueOnce(new Error('Try again')).mockResolvedValueOnce(undefined);
- render(
-
-
- ,
- );
- await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
- await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
- const input = screen.getByRole('textbox', { name: 'Passkey name' });
- expect(input).toHaveValue('MacBook');
- await waitFor(() => expect(input).toHaveFocus());
- expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-disabled', 'true');
- await user.clear(input);
- await user.type(input, 'Work laptop');
- await user.click(screen.getByRole('button', { name: 'Save' }));
- expect(await screen.findByRole('alert')).toHaveTextContent('Try again');
- expect(input).toHaveValue('Work laptop');
- await user.type(input, ' updated');
- expect(screen.getByRole('alert')).toHaveTextContent('Try again');
- expect(input).toHaveValue('Work laptop updated');
- await user.click(screen.getByRole('button', { name: 'Save' }));
- expect(onRename).toHaveBeenLastCalledWith('laptop', 'Work laptop updated');
- await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
- });
-
- it('keeps an empty section visible when Add is unavailable', () => {
- renderView({ passkeys: [], onAdd: undefined });
-
- expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible();
- expect(screen.getByRole('heading', { name: 'Passkeys' })).toBeVisible();
- expect(screen.getByText('No passkeys added')).toBeVisible();
- expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument();
- });
-
- it('keeps Add available with an empty list and creation error', async () => {
- const user = userEvent.setup();
- const onAdd = vi.fn();
- renderView({ passkeys: [], onAdd, addError: 'Could not create passkey' });
-
- expect(screen.getByRole('alert')).toHaveTextContent('Could not create passkey');
- expect(screen.getByRole('alert')).toHaveAttribute('data-open');
- expect(screen.getByRole('alert')).toHaveAttribute('data-starting-style');
- const addButton = screen.getByRole('button', { name: 'Add passkey' });
- expect(addButton).toHaveTextContent(/^Add$/);
- await user.click(addButton);
- expect(onAdd).toHaveBeenCalledOnce();
- });
-
- it.each([true, false])('restores focus after removing the final passkey with Add available: %s', async hasAdd => {
- const user = userEvent.setup();
- let finish = () => {};
- const pending = new Promise(resolve => {
- finish = resolve;
- });
- function Example() {
- const [items, setItems] = useState([passkeys[0]]);
- return (
-
- setItems([passkeys[0]]) : undefined}
- onRemove={async () => {
- await pending;
- setItems([]);
- }}
- />
-
- );
- }
- render();
- await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
- expect(screen.getByRole('button', { name: 'Remove', exact: true })).toHaveAttribute('aria-busy', 'true');
- await act(async () => {
- finish();
- await pending;
- });
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- expect(screen.queryByText('MacBook')).not.toBeInTheDocument();
- if (hasAdd) {
- expect(screen.getByRole('button', { name: 'Add passkey' })).toHaveFocus();
- await user.click(screen.getByRole('button', { name: 'Add passkey' }));
- expect(screen.getByText('MacBook')).toBeVisible();
- } else {
- expect(screen.getByRole('group', { name: 'Passkeys' })).toHaveFocus();
- }
- });
-
- it('retries removal for the same passkey after a failure', async () => {
- const user = userEvent.setup();
- const onRemove = vi.fn().mockRejectedValueOnce(new Error('Removal failed')).mockResolvedValueOnce(undefined);
- render(
-
-
- ,
- );
- await user.click(screen.getByRole('button', { name: 'Manage iPhone' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
- expect(await screen.findByRole('alert')).toHaveTextContent('Removal failed');
- await user.click(screen.getByRole('button', { name: 'Remove', exact: true }));
- expect(onRemove.mock.calls).toEqual([['phone'], ['phone']]);
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- });
- it('preserves literal passkey names in menu labels and removal copy', async () => {
- const user = userEvent.setup();
- render(
-
-
- ,
- );
- await user.click(screen.getByRole('button', { name: 'Manage $& laptop' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription('$& laptop will be removed from this account.');
- });
-
- it('keeps rename pending until saving finishes', async () => {
- const user = userEvent.setup();
- let finish = () => {};
- const onRename = vi.fn(
- () =>
- new Promise(resolve => {
- finish = resolve;
- }),
- );
- render(
-
-
- ,
- );
- await user.click(screen.getByRole('button', { name: 'Manage MacBook' }));
- await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
- const input = screen.getByRole('textbox', { name: 'Passkey name' });
- await user.clear(input);
- await user.type(input, 'Work laptop');
- await user.click(screen.getByRole('button', { name: 'Save' }));
- expect(input).toBeDisabled();
- expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-busy', 'true');
- await user.keyboard('{Escape}');
- expect(screen.getByRole('dialog')).toBeInTheDocument();
- await act(() => finish());
- await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
- expect(onRename).toHaveBeenCalledExactlyOnceWith('laptop', 'Work laptop');
- });
-});
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 cf514d03431..0484d27b828 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import { useDestructiveController } from '../../../blocks/destructive/destructive.controller';
import { MosaicProvider } from '../../../mosaic-provider';
import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view';
+import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view';
import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view';
import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view';
@@ -14,15 +15,17 @@ function DeleteAccount({ onDelete = () => Promise.resolve() }: { onDelete?: () =
return ;
}
+const passkeys = [
+ {
+ id: 'passkey_1',
+ name: 'Passkey',
+ createdAtLabel: 'Created today at 10:12 PM',
+ lastUsedAtLabel: 'Last used 1h ago',
+ },
+];
+
const props: UserProfileSecurityPanelViewProps = {
- passkeys: [
- {
- id: 'passkey_1',
- name: 'Passkey',
- createdAtLabel: 'Created today at 10:12 PM',
- lastUsedAtLabel: 'Last used 1h ago',
- },
- ],
+ passkeysSlot: { content: },
mfaMethods: [
{ id: 'sms_1', type: 'sms', description: '+1 801-888-8181' },
{ id: 'totp_1', type: 'authenticator' },
@@ -105,24 +108,17 @@ describe('UserProfileSecurityPanelView', () => {
});
it('forwards security actions', async () => {
- const onAddPasskey = vi.fn();
- const onRenamePasskey = vi.fn(() => Promise.resolve());
- const onRemovePasskey = vi.fn();
const onSignOutDevice = vi.fn();
const onSignOutAllOtherDevices = vi.fn();
const onDeleteAccount = vi.fn(() => Promise.resolve());
const user = userEvent.setup();
renderView({
- onAddPasskey,
- onRenamePasskey,
- onRemovePasskey,
onSignOutDevice,
onSignOutAllOtherDevices,
deleteAccountSlot: ,
});
- await user.click(screen.getByRole('button', { name: 'Add passkey' }));
const signOutAll = screen.getByRole('button', { name: 'Sign out of all devices' });
expect(signOutAll).toHaveAttribute('data-variant', 'outline');
expect(screen.getByRole('group', { name: 'Active devices' }).querySelector('.cl-section-header')).toContainElement(
@@ -132,18 +128,6 @@ describe('UserProfileSecurityPanelView', () => {
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- await user.click(screen.getByRole('button', { name: 'Manage Passkey' }));
- await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
- const passkeyName = screen.getByRole('textbox', { name: 'Passkey name' });
- await user.clear(passkeyName);
- await user.type(passkeyName, 'Work laptop');
- await user.click(screen.getByRole('button', { name: 'Save' }));
- await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
- await user.click(screen.getByRole('button', { name: 'Manage Passkey' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove', exact: true }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
-
const activeDevices = screen.getByRole('group', { name: 'Active devices' });
await user.click(within(activeDevices).getByRole('button', { name: 'Manage Safari on iOS' }));
await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
@@ -156,9 +140,6 @@ describe('UserProfileSecurityPanelView', () => {
await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account');
await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' }));
- expect(onAddPasskey).toHaveBeenCalledOnce();
- expect(onRenamePasskey).toHaveBeenCalledWith('passkey_1', 'Work laptop');
- expect(onRemovePasskey).toHaveBeenCalledWith('passkey_1');
expect(onSignOutDevice).toHaveBeenCalledWith('mobile');
expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce();
expect(onDeleteAccount).toHaveBeenCalledOnce();
@@ -166,10 +147,16 @@ describe('UserProfileSecurityPanelView', () => {
it('keeps supported empty authentication methods actionable', () => {
renderView({
- passkeys: [],
+ passkeysSlot: {
+ content: (
+
+ ),
+ },
mfaMethods: [],
devices: [],
- onAddPasskey: vi.fn(),
onAddMfaMethod: vi.fn(),
addableMfaMethods: ['sms', 'authenticator'],
});
@@ -192,10 +179,7 @@ describe('UserProfileSecurityPanelView', () => {
it('keeps the authentication section on MFA when existing passkeys are hidden', () => {
renderView({
- passkeysVisible: false,
- onAddPasskey: vi.fn(),
- onRenamePasskey: vi.fn(),
- onRemovePasskey: vi.fn(),
+ passkeysSlot: null,
});
expect(screen.queryByText('Passkeys')).not.toBeInTheDocument();
@@ -206,7 +190,7 @@ describe('UserProfileSecurityPanelView', () => {
});
it('keeps the passkeys card in the authentication section when passkeys are empty and Add is unavailable', () => {
- renderView({ passkeys: [], onAddPasskey: undefined });
+ renderView({ passkeysSlot: { content: } });
const section = screen.getByRole('region', { name: 'Authentication' });
expect(within(section).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeVisible();
@@ -221,7 +205,17 @@ describe('UserProfileSecurityPanelView', () => {
const onRemovePasskey = vi.fn(async () => {
await removal.promise;
});
- const { rerender } = renderView({ mfaMethods: undefined, onRemovePasskey });
+ const { rerender } = renderView({
+ mfaMethods: undefined,
+ passkeysSlot: {
+ content: (
+
+ ),
+ },
+ });
await user.click(screen.getByRole('button', { name: 'Manage Passkey' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' }));
@@ -230,8 +224,14 @@ describe('UserProfileSecurityPanelView', () => {
rerender(
+ ),
+ }}
/>
,
);
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkey-row.view.tsx
index b8343d0c3c9..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
@@ -6,10 +6,10 @@ import { ActionMenu } from '../../components/action-menu';
import { Dialog } from '../../components/dialog';
import { Icon, IconFrame } from '../../components/icon';
import { Section } from '../../components/section';
-import { fill } from '../../localization/messages';
-import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages';
+import { fill, useMessages } from '../../localization';
import { styles } from './user-profile-passkeys-section.styles';
import type { UserProfilePasskey } from './user-profile-passkeys-section.view';
+import 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';
@@ -17,15 +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 537fad1cc35..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
@@ -4,13 +4,25 @@ 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',
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.',
+ accountUnavailableError: 'This account is no longer available.',
saveError: 'Something went wrong. Please try again.',
removeAction: 'Remove passkey',
removeTitle: 'Remove passkey',
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx
index 38de095b398..ff4ccc39fc1 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx
@@ -6,33 +6,36 @@ import { Field } from '../../components/field';
import { Icon } from '../../components/icon';
import { Section } from '../../components/section';
import { useListRemovalFocus } from '../../hooks/use-list-removal-focus';
-import { fill } from '../../localization/messages';
+import { fill, useMessages } from '../../localization';
import { UserProfilePasskeyRowView } from './user-profile-passkey-row.view';
-import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages';
import { styles } from './user-profile-passkeys-section.styles';
+import type {
+ UserProfilePasskey,
+ UserProfilePasskeyNameValidator,
+} 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;
+ validateName?: UserProfilePasskeyNameValidator;
onRemove?: (id: string) => void | Promise;
}
export function UserProfilePasskeysSectionView({
passkeys,
onAdd,
+ isAdding,
addError,
onRename,
+ validateName,
onRemove,
}: UserProfilePasskeysSectionViewProps) {
+ const m = useMessages('userProfilePasskeys');
const addButton = useRef(null);
const section = useRef(null);
const removalFocus = useListRemovalFocus({
@@ -58,6 +61,8 @@ export function UserProfilePasskeysSectionView({
color='neutral'
size='sm'
variant='outline'
+ disabled={isAdding}
+ aria-busy={isAdding}
onClick={onAdd}
>
removePasskey.open(passkey) : undefined}
/>
))
@@ -104,6 +110,7 @@ export function UserProfilePasskeysSectionView({
title={m.removeTitle}
description={passkey => fill(m.removeDescription, { name: passkey.name })}
actionLabel={m.remove}
+ cancelLabel={m.cancel}
finalFocus={removalFocus.finalFocus}
onConfirm={passkey => removalFocus.remove(passkey.id)}
/>
diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts
new file mode 100644
index 00000000000..fee34e04de6
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.controller.ts
@@ -0,0 +1,55 @@
+import { useMessages } from '../../../localization';
+import { setup } from '../../../machine/setup';
+import { useMachine } from '../../../machine/use-machine';
+
+interface Context {
+ run: () => 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..ada5000f1ff
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.test.ts
@@ -0,0 +1,41 @@
+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.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',
+ 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..f9b1a0cfd2b
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts
@@ -0,0 +1,226 @@
+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 type {
+ UserProfilePasskey,
+ UserProfilePasskeyNameValidator,
+ UserProfileRenamePasskeyValues,
+} 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;
+ validateName: UserProfilePasskeyNameValidator;
+ 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();
+ const validateName: UserProfilePasskeyNameValidator = name =>
+ new TextEncoder().encode(name).length > 256 ? { type: 'error', message: messages.nameTooLongError } : undefined;
+
+ 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 FormSubmitError({ 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) {
+ 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,
+ validateName,
+ 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 FormSubmitError({ message: messages.unavailableError });
+ }
+ const feedback = validateName(name);
+ 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 });
+ }
+ },
+ 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..b969ae8d541
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.tsx
@@ -0,0 +1,48 @@
+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..69a3105e71a
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.types.ts
@@ -0,0 +1,20 @@
+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;
+ createdAtLabel?: string;
+ lastUsedAtLabel?: string;
+}
+
+export interface UserProfilePasskeysSlot {
+ content: ReactNode;
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts
index 5aa10686cc9..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,99 +1,49 @@
-import { setup } from '../../machine/setup';
-import { useMachine } from '../../machine/use-machine';
-import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages';
+import { useState } from 'react';
-interface UserProfileRenamePasskeyContext {
- passkeyId: string;
- savedName: string;
- name: string;
- onRename: ((id: string, name: string) => void | Promise) | undefined;
- error: string | 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: event.error instanceof Error ? event.error.message : m.saveError })),
- },
- }),
- },
- },
-});
+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 [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,
+ 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 25cef6658d9..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,38 +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 { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages';
+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 formId = useId();
+ const m = useMessages('userProfilePasskeys');
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}
+ />
+
@@ -86,17 +81,17 @@ export function UserProfileRenamePasskeyDialog({
}
/>
{m.save}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx
index 4c1d9143a9c..acf8e2f8ef8 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx
@@ -11,23 +11,18 @@ import { UserProfileActiveDevicesSectionView } from './user-profile-active-devic
import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view';
import { UserProfileMfaSectionView } from './user-profile-mfa-section.view';
import type { UserProfilePasskey } from './user-profile-passkeys-section.view';
-import { UserProfilePasskeysSectionView } from './user-profile-passkeys-section.view';
+import type { UserProfilePasskeysSlot } from './user-profile-passkeys-section/user-profile-passkeys-section.types';
import type { UserProfilePasswordSlot } from './user-profile-password-section/user-profile-password-section.types';
export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey };
export interface UserProfileSecurityPanelViewProps extends Omit {
passwordSlot?: UserProfilePasswordSlot | null;
- passkeys?: UserProfilePasskey[];
- passkeysVisible?: boolean;
+ passkeysSlot?: UserProfilePasskeysSlot | null;
mfaMethods?: UserProfileMfaMethod[];
addableMfaMethods?: readonly UserProfileMfaAddableMethod[];
mfaAddControl?: ReactNode;
devices?: UserProfileDevice[];
- onAddPasskey?: () => void;
- addPasskeyError?: string;
- onRenamePasskey?: (id: string, name: string) => void | Promise;
- onRemovePasskey?: (id: string) => void | Promise;
onAddMfaMethod?: (type: UserProfileMfaAddableMethod) => void;
onRegenerateBackupCodes?: () => void;
onRemoveMfaMethod?: (id: string) => void | Promise;
@@ -38,16 +33,11 @@ export interface UserProfileSecurityPanelViewProps extends Omit}>
@@ -67,15 +55,7 @@ export function UserProfileSecurityPanelView({
{hasAuthentication ? (
{passwordSlot?.content}
- {showPasskeys ? (
-
- ) : null}
+ {passkeysSlot?.content}
{mfaMethods !== undefined ? (
+
+
+
Passkeys
+
Manage passkeys for the signed-in user.
+
+ {!isLoaded ?
Loading…
: null}
+ {isLoaded && !isSignedIn ? (
+
+
+ Sign in
+ {' '}
+ to manage passkeys.
+
+ ) : null}
+ {isLoaded && isSignedIn ?
: null}
+
+
+ );
+}
diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts
index da710a19657..3ff35463b80 100644
--- a/packages/swingset/src/lib/live-navigation.ts
+++ b/packages/swingset/src/lib/live-navigation.ts
@@ -16,6 +16,7 @@ const liveRoutes = new Map([
['/user-profile/user-profile-connected-accounts-section', '/live/connected-accounts'],
['/user-profile/user-profile-enterprise-accounts-section', '/live/enterprise-accounts'],
['/user-profile/user-profile-delete-section', '/live/delete-account'],
+ ['/user-profile/user-profile-passkeys-section', '/live/passkeys'],
['/reverification/reverification', '/live/reverification'],
]);
diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx
index fa3e7a5b8ab..0951e048f5b 100644
--- a/packages/swingset/src/stories/fixtures/user-profile.tsx
+++ b/packages/swingset/src/stories/fixtures/user-profile.tsx
@@ -6,6 +6,7 @@ import type {
} from '@clerk/mosaic/features/user-profile/user-profile-billing-panel.view';
import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view';
import { UserProfileDeleteSectionView } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.view';
+import { UserProfilePasskeysSectionView } from '@clerk/mosaic/features/user-profile/user-profile-passkeys-section.view';
import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view';
import type {
UserProfileEmail,
@@ -156,14 +157,10 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions
},
security: {
passwordSlot: { content: },
- passkeys: passkeys.passkeys,
- addPasskeyError: passkeys.addError,
- onRenamePasskey: passkeys.onRename,
+ passkeysSlot: { content: },
...mfa.security,
devices: activeDevices.devices,
- onAddPasskey: passkeys.onAdd,
deleteAccountSlot: ,
- onRemovePasskey: passkeys.onRemove,
onSignOutAllOtherDevices: activeDevices.onSignOutAllOtherDevices,
onSignOutDevice: activeDevices.onSignOutDevice,
},