From adb318c3fcd95bb28b2af691dbd5e94ad7cd9003 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 20:02:07 -0600 Subject: [PATCH] fix(mosaic): localize passkey cancellation and failure errors --- .../mosaic-reverification-passkey-cancel.md | 2 + .../__tests__/reverification.feature.test.tsx | 40 +++++++++++++++++-- .../reverification.controller.ts | 21 ++++++---- .../reverification/reverification.utils.ts | 21 ++++++---- ...-profile-passkeys-section.feature.test.tsx | 10 +++++ .../src/localization/errors.messages.ts | 2 + 6 files changed, 76 insertions(+), 20 deletions(-) create mode 100644 .changeset/mosaic-reverification-passkey-cancel.md diff --git a/.changeset/mosaic-reverification-passkey-cancel.md b/.changeset/mosaic-reverification-passkey-cancel.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-reverification-passkey-cancel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index 6aa155725c9..1ad1222d4b9 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -842,7 +842,6 @@ describe('Reverification', () => { }); describe('passkey', () => { - // Currently wrong: the banner shows the browser or clerk-js error message as is, which is not localized. it('completes the challenge with the passkey', async () => { const { clerk } = await renderReverification( guardedAction(), @@ -853,7 +852,9 @@ describe('Reverification', () => { .fn() .mockResolvedValueOnce({ publicKeyCredential: null, - error: new ClerkWebAuthnError('The operation was refused', { code: 'passkey_retrieval_failed' }), + error: new ClerkWebAuthnError('The operation either timed out or was not allowed.', { + code: 'passkey_retrieval_cancelled', + }), }) .mockImplementationOnce( () => @@ -869,9 +870,12 @@ describe('Reverification', () => { const user = await startVerification(); expect(await screen.findByText(STEP.passkey)).toBeVisible(); - // A refused credential shows an error banner and stays on the step + // A cancelled prompt shows the localized error and stays on the step await user.click(screen.getByRole('button', { name: 'Continue' })); - await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('The operation was refused')); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent('Passkey verification was cancelled or timed out.'), + ); + expect(screen.getByRole('alert')).not.toHaveTextContent('The operation either timed out or was not allowed.'); expect(screen.getByText(STEP.passkey)).toBeVisible(); // The button is disabled and shows the verifying label while the credential is being verified @@ -888,6 +892,34 @@ describe('Reverification', () => { } }); + it('explains when the device returns no passkey', async () => { + const { clerk } = await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] }), + ); + clerk.__internal_getPublicCredentials = vi.fn().mockResolvedValue({ + publicKeyCredential: null, + error: new ClerkWebAuthnError('Browser failed to get credential', { code: 'passkey_retrieval_failed' }), + }); + const restore = pretendToBeARealBrowser(); + + try { + const user = await startVerification(); + expect(await screen.findByText(STEP.passkey)).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent( + "Your device couldn't verify your passkey. Please try again.", + ), + ); + expect(screen.getByText(STEP.passkey)).toBeVisible(); + } finally { + restore(); + } + }); + it('offers the passkey from the picker when another method is showing', async () => { await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor, passkeyFactor] })); const restore = pretendToBeARealBrowser(); diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index 1fb1d62677c..e6f2bd6c56c 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; import type { FlowDirection } from '../../components/flow'; -import { useMessages } from '../../localization'; +import type { ErrorDescription } from '../../localization'; +import { useErrorText, useMessages } from '../../localization'; import { setup } from '../../machine/setup'; import type { DoneInvokeEvent, StateConfig } from '../../machine/types'; import { useMachine } from '../../machine/use-machine'; @@ -13,7 +14,7 @@ import type { ReverificationStep, ReverificationViewProps, } from './reverification.types'; -import { errorDetail, needsPrepare, otpChannelFor } from './reverification.utils'; +import { errorDescription, needsPrepare, otpChannelFor, serverErrorDetail } from './reverification.utils'; type ReverificationControllerState = | { status: 'idle'; onCancel?: undefined } @@ -35,9 +36,7 @@ type OverlayFrom = 'factor' | 'method-picker'; export type ReverificationDeps = Pick; -interface FactorError { - detail: string | undefined; -} +type FactorError = { serverDetail: string } | { description: ErrorDescription }; interface ReverificationContext { inputValue: string; @@ -88,7 +87,8 @@ function unlockResend(): Pick { } function factorError(error: unknown): FactorError { - return { detail: errorDetail(error) }; + const serverDetail = serverErrorDetail(error); + return serverDetail ? { serverDetail } : { description: errorDescription(error) }; } function selectMethod(ctx: ReverificationContext, id: string): Partial { @@ -415,6 +415,7 @@ export function useReverificationController(model: ReverificationModel): Reverif function useReverificationState(model: ReverificationModel): ReverificationControllerState { const m = useMessages('reverification'); + const errorText = useErrorText(); const active = model.status === 'active' ? model : null; const [snapshot, send] = useMachine( @@ -495,7 +496,11 @@ function useReverificationState(model: ReverificationModel): ReverificationContr direction: context.direction, value: context.inputValue, onValueChange: (value: string) => send({ type: 'TYPE', value }), - errorMessage: context.error ? (context.error.detail ?? m.unstable__errors__generic) : undefined, + errorMessage: context.error + ? 'serverDetail' in context.error + ? context.error.serverDetail + : errorText(context.error.description, m.unstable__errors__generic) + : undefined, isPending: pendingStates.has(state), onSubmit: () => send({ type: 'SUBMIT' }), onShowMethods: () => send({ type: 'SHOW_METHODS' }), @@ -525,5 +530,5 @@ function useReverificationState(model: ReverificationModel): ReverificationContr }; return state === 'retrying' ? { status: 'retrying', ...view } : { status: 'ready', onCancel, ...view }; - }, [inactive, snapshot, onCancel, send, m, canResend, countingDown, resendAvailableAt, now]); + }, [inactive, snapshot, onCancel, send, m, errorText, canResend, countingDown, resendAvailableAt, now]); } diff --git a/packages/mosaic/src/features/reverification/reverification.utils.ts b/packages/mosaic/src/features/reverification/reverification.utils.ts index 3aca0d4b86a..c7b3d36e6f3 100644 --- a/packages/mosaic/src/features/reverification/reverification.utils.ts +++ b/packages/mosaic/src/features/reverification/reverification.utils.ts @@ -1,6 +1,8 @@ import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; import type { PreferredSignInStrategy } from '@clerk/shared/types'; +import type { ErrorDescription } from '../../localization'; +import { toLocalizableError } from '../../utils/errors'; import type { ReverificationMethod, ReverificationOtpChannel, @@ -8,16 +10,19 @@ import type { ReverificationStrategy, } from './reverification.types'; -// TODO: Return toLocalizableError(error) and render with errorText so Clerk errors are localized by code instead of shown raw. -export function errorDetail(error: unknown): string | undefined { - if (isClerkAPIResponseError(error)) { - const first = error.errors[0]; - return first?.longMessage || first?.message || error.message; - } +export function errorDescription(error: unknown): ErrorDescription { if (isClerkRuntimeError(error) && error.code !== 'network_error') { - return error.message; + return toLocalizableError(error); } - return undefined; + return { cause: error }; +} + +export function serverErrorDetail(error: unknown): string | undefined { + if (!isClerkAPIResponseError(error)) { + return undefined; + } + const first = error.errors[0]; + return first?.longMessage || first?.message || error.message; } export function otpChannelFor(strategy: ReverificationStrategy): ReverificationOtpChannel | undefined { diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx index 46008130173..48dfc1d8124 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx @@ -368,6 +368,16 @@ describe('Adding a passkey', () => { await user.click(screen.getByRole('button', { name: 'Add passkey' })); expect(await screen.findByText('Chrome on macOS')).toBeVisible(); }); + it('explains when the device returns no passkey', async () => { + servePasskeys([]); + fakeAuthenticator().mockResolvedValueOnce(null); + await renderWithClerk(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Add passkey' })); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent("Your device couldn't create a passkey. Please try again."), + ); + expect(screen.getByRole('button', { name: 'Add passkey' })).toBeEnabled(); + }); }); describe('Renaming a passkey', () => { diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 6d85f5863b9..161dc0e748c 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -69,7 +69,9 @@ export const errorMessages: { readonly generic: string } & Readonly