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, },