From 6abb0bf55b85a260377bcaee7451c46d322df9c7 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Thu, 27 Aug 2026 13:56:07 -0800 Subject: [PATCH 1/7] feat(ui,shared,localizations): dedicated screen for a blocked request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A blocked sign-in or sign-up is terminal — there is no field to correct and no retry that helps — but it currently renders in the same small inline error slot as "incorrect password", with nothing the user can act on or quote. It now replaces the card. The screen shows a short reference for the request so the end user can quote it to support, and renders the application's own title, description and https link when it supplies them. - shared: `trace_id`, `title`, `description`, `link_url` and `link_text` on the API error meta, parsed to camelCase alongside the existing fields. - ui: ActionBlockedCard plus a `useActionBlocked` hook. The hook wraps `card.setError`, which is where every error in these flows already funnels, so both the submit path and the OAuth-callback path are covered without either knowing about it. - ui: new appearance descriptors and an `actionBlocked` flow part. - localizations: `actionBlocked.title`, `.subtitle` and `.traceIdLabel` as the fallbacks used when the application supplies no wording of its own. Additive and degrades safely: the error's code, message and long_message are unchanged, so an older client is unaffected, and a response carrying no meta renders exactly the inline error it did before. The application-supplied text is rendered as text nodes, never as markup, and only `https` links become an href — the URL is validated before it is sent, and checked again here before it reaches the DOM. Verified: `pnpm --filter @clerk/ui type-check` reports no errors in any changed file, and `@clerk/shared` and `@clerk/localizations` build clean. 17 new tests pass, covering the meta parsing and every rejected URL scheme. The 24 failing test files and the type errors that remain are all under `src/mosaic/**`, which this change does not touch — they fail on an unbuilt `@clerk/headless`. --- .changeset/blocked-request-screen.md | 15 ++ packages/localizations/src/en-US.ts | 5 + packages/shared/src/errors/clerkApiError.ts | 5 + packages/shared/src/types/errors.ts | 34 +++ packages/shared/src/types/localization.ts | 14 ++ packages/ui/src/common/ActionBlockedCard.tsx | 206 ++++++++++++++++++ .../__tests__/ActionBlockedCard.test.tsx | 93 ++++++++ packages/ui/src/common/index.ts | 1 + .../ui/src/components/SignIn/SignInStart.tsx | 16 +- .../ui/src/components/SignUp/SignUpStart.tsx | 16 +- .../src/customizables/elementDescriptors.ts | 6 + packages/ui/src/elements/contexts/index.tsx | 3 +- packages/ui/src/internal/appearance.ts | 6 + 13 files changed, 412 insertions(+), 8 deletions(-) create mode 100644 .changeset/blocked-request-screen.md create mode 100644 packages/ui/src/common/ActionBlockedCard.tsx create mode 100644 packages/ui/src/common/__tests__/ActionBlockedCard.test.tsx diff --git a/.changeset/blocked-request-screen.md b/.changeset/blocked-request-screen.md new file mode 100644 index 00000000000..994c8344b55 --- /dev/null +++ b/.changeset/blocked-request-screen.md @@ -0,0 +1,15 @@ +--- +'@clerk/localizations': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Show a dedicated screen when a sign-in or sign-up request is blocked, instead of a generic inline error. + +A blocked request is terminal — there is no field to correct and no retry that helps — so it now replaces the card rather than appearing as a small error beside a form the user cannot resubmit. + +The screen shows a short reference for the request, which the end user can quote when contacting support. When the application supplies its own wording, the screen renders that instead of the default: `title`, `description`, and an optional `https` link with a label are read from the error's `meta`. + +Also adds the `actionBlocked` localization keys (`title`, `subtitle`, `traceIdLabel`) and appearance descriptors for the new elements, so both the copy and the styling are customizable. + +This is additive and degrades safely: the error's `code`, `message` and `long_message` are unchanged, and a response without the new `meta` renders exactly as before. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index aa0385251de..d17ec1ae195 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -2,6 +2,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const enUS: LocalizationResource = { locale: 'en-US', + actionBlocked: { + subtitle: 'For your security, this request could not be completed.', + title: "We couldn't complete this request", + traceIdLabel: 'Reference', + }, apiKeys: { action__add: 'Add new key', action__search: 'Search keys', diff --git a/packages/shared/src/errors/clerkApiError.ts b/packages/shared/src/errors/clerkApiError.ts index f8c26fa2b17..a85476aa872 100644 --- a/packages/shared/src/errors/clerkApiError.ts +++ b/packages/shared/src/errors/clerkApiError.ts @@ -28,6 +28,11 @@ export class ClerkAPIError implements Cler isPlanUpgradePossible: json.meta?.is_plan_upgrade_possible, seatsQuantityToAdd: json.meta?.seats_quantity_to_add, seatsQuantity: json.meta?.seats_quantity, + traceId: json.meta?.trace_id, + title: json.meta?.title, + description: json.meta?.description, + linkUrl: json.meta?.link_url, + linkText: json.meta?.link_text, } as unknown as Meta, }; this.code = parsedError.code; diff --git a/packages/shared/src/types/errors.ts b/packages/shared/src/types/errors.ts index 60531f065b4..d8bfcecb36c 100644 --- a/packages/shared/src/types/errors.ts +++ b/packages/shared/src/types/errors.ts @@ -23,6 +23,11 @@ export interface ClerkAPIErrorJSON { is_plan_upgrade_possible?: boolean; seats_quantity_to_add?: number; seats_quantity?: number; + trace_id?: string; + title?: string; + description?: string; + link_url?: string; + link_text?: string; }; } @@ -67,6 +72,35 @@ export interface ClerkAPIError { isPlanUpgradePossible?: boolean; seatsQuantityToAdd?: number; seatsQuantity?: number; + /** + * A short reference for the request that produced this error. It is shown to + * the end user so they can quote it when contacting support. + * + * Treat it as an opaque string: do not parse it, reformat it, or assume a + * length. + */ + traceId?: string; + /** + * A heading for the error, configured by the application's owner. + * + * Plain text. Render it as text, never as HTML or markdown. + */ + title?: string; + /** + * A description of the error, configured by the application's owner. + * + * Plain text. Render it as text, never as HTML or markdown. + */ + description?: string; + /** + * An `https` URL the end user can follow for help, configured by the + * application's owner. Verify the scheme before using it as an `href`. + */ + linkUrl?: string; + /** + * The label for `linkUrl`. Only ever set when `linkUrl` is set. + */ + linkText?: string; }; } diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 26b9452fc15..f03da042154 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1971,6 +1971,20 @@ export type __internal_LocalizationResource = { doneButton: LocalizationValue; }; }; + /** + * The screen shown when a request is blocked and there is no way for the end + * user to retry. These are the fallbacks: an application can supply its own + * title and description, and when it does they are used instead. + */ + actionBlocked: { + title: LocalizationValue; + subtitle: LocalizationValue; + /** + * Labels the short reference the end user can quote when contacting + * support. + */ + traceIdLabel: LocalizationValue; + }; apiKeys: { formTitle: LocalizationValue; formHint: LocalizationValue; diff --git a/packages/ui/src/common/ActionBlockedCard.tsx b/packages/ui/src/common/ActionBlockedCard.tsx new file mode 100644 index 00000000000..603df5b1088 --- /dev/null +++ b/packages/ui/src/common/ActionBlockedCard.tsx @@ -0,0 +1,206 @@ +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; +import type { ClerkAPIError } from '@clerk/shared/types'; +import React from 'react'; + +import { Col, descriptors, Flex, Flow, Icon, localizationKeys, Text } from '../customizables'; +import { Card } from '../elements/Card'; +import { Header } from '../elements/Header'; +import { ExclamationTriangle } from '../icons'; + +/** + * The details an application can attach to a blocked request. Every field is + * optional; when none are present the card falls back to its own wording and + * shows only the reference. + * + * The text fields are plain text and are rendered as text nodes. They are + * written by the application's owner, so they are treated as content, never as + * markup. + */ +export type ActionBlockedDetails = { + traceId?: string; + title?: string; + description?: string; + linkUrl?: string; + linkText?: string; +}; + +/** + * Reads the details off an API error, or returns null when the error carries + * none — which is also what happens against an older backend that does not send + * them. Callers use the null to fall back to the previous inline error, so a + * missing field degrades rather than rendering a blank screen. + */ +export const getActionBlockedDetails = (error: ClerkAPIError | undefined): ActionBlockedDetails | null => { + const meta = error?.meta as ActionBlockedDetails | undefined; + if (!meta) { + return null; + } + const { traceId, title, description, linkUrl, linkText } = meta; + if (!traceId && !title && !description && !linkUrl) { + return null; + } + return { traceId, title, description, linkUrl, linkText }; +}; + +/** + * Only `https` links are rendered. + * + * The URL is already checked before it is sent, so this is a second, local + * check rather than the only one: it is what stands between a value that + * reached the browser anyway and a `javascript:` or `data:` URI becoming an + * `href`. A link that fails is dropped and the rest of the card still renders. + */ +export const safeHref = (url: string | undefined): string | null => { + if (!url) { + return null; + } + try { + return new URL(url).protocol === 'https:' ? url : null; + } catch { + return null; + } +}; + +type ActionBlockedCardProps = { + details: ActionBlockedDetails; +}; + +/** + * The screen shown when a request was blocked and there is nothing the end user + * can do to retry it. + * + * A block is terminal — there is no field to correct and no second attempt that + * helps — so it replaces the form rather than appearing as an inline error + * beside it. The one thing the user can act on is the reference, which is why it + * is always rendered and is selectable. + */ +export const ActionBlockedCard = (props: ActionBlockedCardProps) => { + const { traceId, title, description, linkUrl, linkText } = props.details; + const href = safeHref(linkUrl); + + return ( + + + + + {/* An application-supplied title is plain text, so it is passed as a + child rather than through localizationKey. Without one we fall + back to our own wording. */} + {title ? ( + {title} + ) : ( + + )} + {description ? ( + {description} + ) : ( + + )} + + + + ({ + alignSelf: 'center', + width: theme.sizes.$16, + height: theme.sizes.$16, + borderRadius: theme.radii.$circle, + backgroundColor: theme.colors.$neutralAlpha100, + color: theme.colors.$danger500, + })} + > + ({ height: theme.sizes.$5, width: theme.sizes.$5 })} + /> + + + {href ? ( + + {linkText || href} + + ) : null} + + {traceId ? ( + + + {/* Selectable and monospaced: this is the one thing on the + screen the user is expected to copy or retype. */} + ({ + fontFamily: theme.fonts.$buttons, + userSelect: 'all', + letterSpacing: theme.space.$xxs, + })} + > + {traceId} + + + ) : null} + + + + + + ); +}; + +/** + * Intercepts a blocked-request error on its way to the card's inline error slot + * and turns it into the terminal screen instead. + * + * Every error in these flows funnels through `card.setError`, so wrapping that + * one function catches both the form-submit path and the OAuth-callback path + * without either having to know about this. + * + * Anything that is not a blocked request — or that is, but carries no details, + * which is what an older backend sends — passes straight through and still + * renders as the inline error it always did. + */ +export const useActionBlocked = (setError: (e: any) => void) => { + const [blockedDetails, setBlockedDetails] = React.useState(null); + + const setErrorOrBlock = React.useCallback( + (e: any) => { + if (e && typeof e === 'object' && e.code === ERROR_CODES.FRAUD_ACTION_BLOCKED) { + const details = getActionBlockedDetails(e as ClerkAPIError); + if (details) { + setBlockedDetails(details); + return; + } + } + setError(e); + }, + [setError], + ); + + return { blockedDetails, setErrorOrBlock }; +}; diff --git a/packages/ui/src/common/__tests__/ActionBlockedCard.test.tsx b/packages/ui/src/common/__tests__/ActionBlockedCard.test.tsx new file mode 100644 index 00000000000..05815c05bf9 --- /dev/null +++ b/packages/ui/src/common/__tests__/ActionBlockedCard.test.tsx @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +import { getActionBlockedDetails, safeHref } from '../ActionBlockedCard'; + +describe('safeHref', () => { + it('allows https', () => { + expect(safeHref('https://help.example.com/blocked?ref=7Q8ikxgt')).toBe( + 'https://help.example.com/blocked?ref=7Q8ikxgt', + ); + }); + + // The link is chosen by the application's owner and rendered in an end user's + // browser. It is checked before it is sent, so this is the second check + // rather than the only one — but it is the one standing between a value that + // arrived anyway and an href. + it.each([ + 'javascript:alert(1)', + 'JavaScript:alert(1)', + ' javascript:alert(1)', + 'data:text/html;base64,PHNjcmlwdD4=', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + 'http://example.com/help', + '/relative', + '//example.com', + 'not a url', + '', + ])('rejects %j', url => { + expect(safeHref(url)).toBeNull(); + }); + + it('rejects a missing link', () => { + expect(safeHref(undefined)).toBeNull(); + }); +}); + +describe('getActionBlockedDetails', () => { + it('reads every field off the error meta', () => { + expect( + getActionBlockedDetails({ + code: 'action_blocked', + message: 'Action blocked', + meta: { + traceId: '7Q8ikxgt', + title: 'We could not verify this sign-in', + description: 'Try again from a different network.', + linkUrl: 'https://help.example.com/blocked?ref=7Q8ikxgt', + linkText: 'Contact support', + }, + } as any), + ).toEqual({ + traceId: '7Q8ikxgt', + title: 'We could not verify this sign-in', + description: 'Try again from a different network.', + linkUrl: 'https://help.example.com/blocked?ref=7Q8ikxgt', + linkText: 'Contact support', + }); + }); + + // The common case: no application-supplied message, but the end user still + // gets a reference to quote. + it('accepts a reference with no message', () => { + const details = getActionBlockedDetails({ + code: 'action_blocked', + message: 'Action blocked', + meta: { traceId: '7Q8ikxgt' }, + } as any); + expect(details).not.toBeNull(); + expect(details?.traceId).toBe('7Q8ikxgt'); + expect(details?.title).toBeUndefined(); + }); + + // An older backend sends no meta at all. Returning null is what makes the + // caller fall back to the previous inline error instead of rendering a blank + // screen. + it('returns null when there is nothing to show', () => { + expect(getActionBlockedDetails(undefined)).toBeNull(); + expect(getActionBlockedDetails({ code: 'action_blocked', message: 'x' } as any)).toBeNull(); + expect(getActionBlockedDetails({ code: 'action_blocked', message: 'x', meta: {} } as any)).toBeNull(); + }); + + // A label with no destination is not a link, so it alone is not a reason to + // take over the screen. + it('ignores a link label with no link', () => { + expect( + getActionBlockedDetails({ + code: 'action_blocked', + message: 'x', + meta: { linkText: 'Contact support' }, + } as any), + ).toBeNull(); + }); +}); diff --git a/packages/ui/src/common/index.ts b/packages/ui/src/common/index.ts index f859201d4b6..efd8a1259db 100644 --- a/packages/ui/src/common/index.ts +++ b/packages/ui/src/common/index.ts @@ -1,3 +1,4 @@ +export * from './ActionBlockedCard'; export * from './CalloutWithAction'; export * from './constants'; export * from './EmailLinkStatusCard'; diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 658643d83bb..ebedc80e38c 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -26,8 +26,10 @@ import { buildRequest, useFormControl } from '@/ui/utils/useFormControl'; import type { SignInStartIdentifier } from '../../common'; import { + ActionBlockedCard, getIdentifierControlDisplayValues, groupIdentifiers, + useActionBlocked, withRedirectToAfterSignIn, withRedirectToSignInTask, } from '../../common'; @@ -86,6 +88,10 @@ const useAutoFillPasskey = () => { function SignInStartInternal(): JSX.Element { const card = useCardState(); + // A blocked request is terminal, so it replaces the card rather than showing + // an inline error beside a form the user cannot resubmit. setErrorOrBlock + // passes everything else through untouched. + const { blockedDetails, setErrorOrBlock } = useActionBlocked(card.setError); const clerk = useClerk(); const status = useLoadingStatus(); const { userSettings, authConfig } = useEnvironment(); @@ -312,7 +318,7 @@ function SignInStartInternal(): JSX.Element { case ERROR_CODES.SIGNUP_RATE_LIMIT_EXCEEDED: case ERROR_CODES.USER_BANNED: case ERROR_CODES.USER_DEACTIVATED: - card.setError(error); + setErrorOrBlock(error); break; default: defaultErrorHandler(); @@ -527,7 +533,7 @@ function SignInStartInternal(): JSX.Element { return handleCombinedFlowTransfer({ afterSignUpUrl: ctx.afterSignUpUrl || '/', clerk, - handleError: e => handleError(e, [identifierField, instantPasswordField], card.setError), + handleError: e => handleError(e, [identifierField, instantPasswordField], setErrorOrBlock), identifierAttribute: attribute, identifierValue: identifierField.value, navigate, @@ -547,7 +553,7 @@ function SignInStartInternal(): JSX.Element { unsafeMetadata: ctx.unsafeMetadata, }); } else { - handleError(e, [identifierField, instantPasswordField], card.setError); + handleError(e, [identifierField, instantPasswordField], setErrorOrBlock); } }; @@ -593,6 +599,10 @@ function SignInStartInternal(): JSX.Element { ? validLastAuthenticationStrategies?.has(lastAuthenticationStrategy) : false; + if (blockedDetails) { + return ; + } + return ( {!alternativePhoneCodeProvider ? ( diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx index e146fd0341d..16f57e30fb3 100644 --- a/packages/ui/src/components/SignUp/SignUpStart.tsx +++ b/packages/ui/src/components/SignUp/SignUpStart.tsx @@ -17,7 +17,7 @@ import type { FormControlState } from '@/ui/utils/useFormControl'; import { buildRequest, useFormControl } from '@/ui/utils/useFormControl'; import { createUsernameError } from '@/ui/utils/usernameUtils'; -import { withRedirectToAfterSignUp, withRedirectToSignUpTask } from '../../common'; +import { ActionBlockedCard, useActionBlocked, withRedirectToAfterSignUp, withRedirectToSignUpTask } from '../../common'; import { SignInContext, useCoreSignUp, useEnvironment, useSignUpContext } from '../../contexts'; import { descriptors, Flex, Flow, localizationKeys, useAppearance, useLocalizations } from '../../customizables'; import { CaptchaElement } from '../../elements/CaptchaElement'; @@ -34,6 +34,10 @@ import { useCompleteSignUpFlow } from './useCompleteSignUpFlow'; function SignUpStartInternal(): JSX.Element { const card = useCardState(); + // A blocked request is terminal, so it replaces the card rather than showing + // an inline error beside a form the user cannot resubmit. setErrorOrBlock + // passes everything else through untouched. + const { blockedDetails, setErrorOrBlock } = useActionBlocked(card.setError); const clerk = useClerk(); const status = useLoadingStatus(); const signUp = useCoreSignUp(); @@ -168,7 +172,7 @@ function SignUpStartInternal(): JSX.Element { .catch(err => { /* Clear ticket values when an error occurs in the initial sign up attempt */ formState.ticket.setValue(''); - handleError(err, [], card.setError); + handleError(err, [], setErrorOrBlock); }) .finally(() => { // Keep the card in loading state during SSO redirect to prevent UI flicker @@ -211,7 +215,7 @@ function SignUpStartInternal(): JSX.Element { case ERROR_CODES.SIGNUP_RATE_LIMIT_EXCEEDED: case ERROR_CODES.USER_BANNED: case ERROR_CODES.USER_DEACTIVATED: - card.setError(error); + setErrorOrBlock(error); break; default: // Error from server may be too much information for the end user, so set a generic error @@ -350,7 +354,7 @@ function SignUpStartInternal(): JSX.Element { return navigate('./enterprise-connections'); } - return handleError(err, fieldsToSubmit, card.setError); + return handleError(err, fieldsToSubmit, setErrorOrBlock); }) .finally(() => card.setIdle()); }; @@ -389,6 +393,10 @@ function SignUpStartInternal(): JSX.Element { return ; } + if (blockedDetails) { + return ; + } + return ( {!alternativePhoneCodeProvider ? ( diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index 3720f78cd17..35be1139e6c 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -150,6 +150,12 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'formHeaderTitle', 'formHeaderSubtitle', + 'actionBlockedIconBox', + 'actionBlockedIcon', + 'actionBlockedLink', + 'actionBlockedTraceIdBox', + 'actionBlockedTraceIdLabel', + 'actionBlockedTraceId', 'verificationLinkStatusBox', 'verificationLinkStatusIconBox', 'verificationLinkStatusIcon', diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx index 379e43ebf3b..337d4f3c064 100644 --- a/packages/ui/src/elements/contexts/index.tsx +++ b/packages/ui/src/elements/contexts/index.tsx @@ -141,7 +141,8 @@ export type FlowMetadata = { | 'configureMapAttributes' | 'testSso' | 'ssoActivate' - | 'protectCheck'; + | 'protectCheck' + | 'actionBlocked'; }; const [FlowMetadataCtx, useFlowMetadata] = createContextAndHook('FlowMetadata'); diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index 9cacc2b8bc5..344c902d014 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -279,6 +279,12 @@ export type ElementsConfig = { formHeaderSubtitle: WithOptions; formResendCodeLink: WithOptions; + actionBlockedIconBox: WithOptions; + actionBlockedIcon: WithOptions; + actionBlockedLink: WithOptions; + actionBlockedTraceIdBox: WithOptions; + actionBlockedTraceIdLabel: WithOptions; + actionBlockedTraceId: WithOptions; verificationLinkStatusBox: WithOptions; verificationLinkStatusIconBox: WithOptions; verificationLinkStatusIcon: WithOptions; From f4fd774325d44766b1126e570fe695c6839c7b31 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Thu, 27 Aug 2026 14:01:39 -0800 Subject: [PATCH 2/7] fix(shared): keep blocked-request details across a snapshot round trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit errorToJSON has its own exhaustive meta field list and backs Verification.__internal_toSnapshot, so on the SSR/hydration path a verification error lost the reference and the application's message. The blocked screen then degraded to its generic wording with nothing saying why — and that is the OAuth and SAML path, one of the two the screen is wired for. Both directions of the meta mapping are hand-maintained lists, so a field added to one and not the other is dropped silently. Added a round-trip test that fails if either side stops carrying them; verified it fails when the mapping is removed, so it is a real check and not a passing no-op. --- .../src/__tests__/blockedRequestMeta.spec.ts | 54 +++++++++++++++++++ packages/shared/src/errors/parseError.ts | 5 ++ 2 files changed, 59 insertions(+) create mode 100644 packages/shared/src/__tests__/blockedRequestMeta.spec.ts diff --git a/packages/shared/src/__tests__/blockedRequestMeta.spec.ts b/packages/shared/src/__tests__/blockedRequestMeta.spec.ts new file mode 100644 index 00000000000..3728119b4f9 --- /dev/null +++ b/packages/shared/src/__tests__/blockedRequestMeta.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { ClerkAPIError } from '../errors/clerkApiError'; +import { errorToJSON } from '../errors/parseError'; + +// The details shown on the blocked-request screen ride on the error's meta. +// Both directions of the mapping have an exhaustive field list, so a field +// added to one and not the other is dropped silently — which reads as "the +// application configured no message" rather than as a bug. +describe('blocked request error meta', () => { + const json = { + code: 'action_blocked', + message: 'Action blocked', + long_message: 'This action was detected as suspicious and has been blocked.', + meta: { + trace_id: '7Q8ikxgt', + title: 'We could not verify this sign-in', + description: 'Try again from a different network.', + link_url: 'https://help.example.com/blocked?ref=7Q8ikxgt', + link_text: 'Contact support', + }, + }; + + it('parses every field off the wire', () => { + const error = new ClerkAPIError(json as any); + expect(error.meta).toMatchObject({ + traceId: '7Q8ikxgt', + title: 'We could not verify this sign-in', + description: 'Try again from a different network.', + linkUrl: 'https://help.example.com/blocked?ref=7Q8ikxgt', + linkText: 'Contact support', + }); + }); + + // errorToJSON backs __internal_toSnapshot, so this is the SSR/hydration path: + // without it the screen loses its message and reference after rehydration and + // silently degrades to the generic wording. + it('survives a snapshot round trip', () => { + const roundTripped = new ClerkAPIError(errorToJSON(new ClerkAPIError(json as any)) as any); + expect(roundTripped.meta).toMatchObject({ + traceId: '7Q8ikxgt', + title: 'We could not verify this sign-in', + description: 'Try again from a different network.', + linkUrl: 'https://help.example.com/blocked?ref=7Q8ikxgt', + linkText: 'Contact support', + }); + }); + + it('leaves an error without these fields alone', () => { + const error = new ClerkAPIError({ code: 'form_param_nil', message: 'x', meta: { param_name: 'email' } } as any); + expect(error.meta.traceId).toBeUndefined(); + expect(errorToJSON(error).meta?.trace_id).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/errors/parseError.ts b/packages/shared/src/errors/parseError.ts index e29ebf2e864..51fdc7e902d 100644 --- a/packages/shared/src/errors/parseError.ts +++ b/packages/shared/src/errors/parseError.ts @@ -41,6 +41,11 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { is_plan_upgrade_possible: error?.meta?.isPlanUpgradePossible, seats_quantity_to_add: error?.meta?.seatsQuantityToAdd, seats_quantity: error?.meta?.seatsQuantity, + trace_id: error?.meta?.traceId, + title: error?.meta?.title, + description: error?.meta?.description, + link_url: error?.meta?.linkUrl, + link_text: error?.meta?.linkText, }, }; } From 70a55a2720b8f51177d08d1f31e944e37a654e66 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Thu, 27 Aug 2026 14:23:00 -0800 Subject: [PATCH 3/7] chore(localizations): regenerate locales for the new actionBlocked keys CI's "Verify localizations are generated" step failed: adding a key to en-US requires regenerating all 48 locale files, which was not obvious from the local build (both @clerk/localizations and @clerk/shared build clean without it). Untranslated locales get `undefined` and fall back to en-US at runtime, which is the existing pattern for a newly added key. --- packages/localizations/src/ar-SA.ts | 5 +++++ packages/localizations/src/be-BY.ts | 5 +++++ packages/localizations/src/bg-BG.ts | 5 +++++ packages/localizations/src/bn-IN.ts | 5 +++++ packages/localizations/src/ca-ES.ts | 5 +++++ packages/localizations/src/cs-CZ.ts | 5 +++++ packages/localizations/src/da-DK.ts | 5 +++++ packages/localizations/src/de-DE.ts | 5 +++++ packages/localizations/src/el-GR.ts | 5 +++++ packages/localizations/src/en-GB.ts | 5 +++++ packages/localizations/src/es-CR.ts | 5 +++++ packages/localizations/src/es-ES.ts | 5 +++++ packages/localizations/src/es-MX.ts | 5 +++++ packages/localizations/src/es-UY.ts | 5 +++++ packages/localizations/src/fa-IR.ts | 5 +++++ packages/localizations/src/fi-FI.ts | 5 +++++ packages/localizations/src/fr-FR.ts | 5 +++++ packages/localizations/src/he-IL.ts | 5 +++++ packages/localizations/src/hi-IN.ts | 5 +++++ packages/localizations/src/hr-HR.ts | 5 +++++ packages/localizations/src/hu-HU.ts | 5 +++++ packages/localizations/src/id-ID.ts | 5 +++++ packages/localizations/src/is-IS.ts | 5 +++++ packages/localizations/src/it-IT.ts | 5 +++++ packages/localizations/src/ja-JP.ts | 5 +++++ packages/localizations/src/kk-KZ.ts | 5 +++++ packages/localizations/src/ko-KR.ts | 5 +++++ packages/localizations/src/mn-MN.ts | 5 +++++ packages/localizations/src/ms-MY.ts | 5 +++++ packages/localizations/src/nb-NO.ts | 5 +++++ packages/localizations/src/nl-BE.ts | 5 +++++ packages/localizations/src/nl-NL.ts | 5 +++++ packages/localizations/src/pl-PL.ts | 5 +++++ packages/localizations/src/pt-BR.ts | 5 +++++ packages/localizations/src/pt-PT.ts | 5 +++++ packages/localizations/src/ro-RO.ts | 5 +++++ packages/localizations/src/ru-RU.ts | 5 +++++ packages/localizations/src/sk-SK.ts | 5 +++++ packages/localizations/src/sr-RS.ts | 5 +++++ packages/localizations/src/sv-SE.ts | 5 +++++ packages/localizations/src/ta-IN.ts | 5 +++++ packages/localizations/src/te-IN.ts | 5 +++++ packages/localizations/src/th-TH.ts | 5 +++++ packages/localizations/src/tr-TR.ts | 5 +++++ packages/localizations/src/uk-UA.ts | 5 +++++ packages/localizations/src/vi-VN.ts | 5 +++++ packages/localizations/src/zh-CN.ts | 5 +++++ packages/localizations/src/zh-TW.ts | 5 +++++ 48 files changed, 240 insertions(+) diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index 1a0e9542bce..c29486952ac 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const arSA: LocalizationResource = { locale: 'ar-SA', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts index 87edd1c7956..7c90bf2f290 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const beBY: LocalizationResource = { locale: 'be-BY', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts index d7c80b74640..a78899bc857 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const bgBG: LocalizationResource = { locale: 'bg-BG', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/bn-IN.ts b/packages/localizations/src/bn-IN.ts index cdf78289cad..bf89043fb9e 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const bnIN: LocalizationResource = { locale: 'bn-IN', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'নতুন কী যোগ করুন', action__search: 'কী অনুসন্ধান করুন', diff --git a/packages/localizations/src/ca-ES.ts b/packages/localizations/src/ca-ES.ts index d761a145fb1..bc237b9ae01 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const caES: LocalizationResource = { locale: 'ca-ES', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts index 3c3b8dc5443..075d95da87e 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const csCZ: LocalizationResource = { locale: 'cs-CZ', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Přidat nový klíč', action__search: 'Vyhledat klíče', diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts index 92936019d80..9db6ca09da5 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const daDK: LocalizationResource = { locale: 'da-DK', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index 07f932898cf..23123e0f1fe 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const deDE: LocalizationResource = { locale: 'de-DE', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Neuen API-Key hinzufügen', action__search: 'Suche', diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts index 02c1d49ec2e..0cd1d6e6381 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const elGR: LocalizationResource = { locale: 'el-GR', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Προσθήκη', action__search: 'Αναζήτηση', diff --git a/packages/localizations/src/en-GB.ts b/packages/localizations/src/en-GB.ts index 33071fdb142..141014e2ceb 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const enGB: LocalizationResource = { locale: 'en-GB', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/es-CR.ts b/packages/localizations/src/es-CR.ts index baeb439a0ac..fec5115f12f 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const esCR: LocalizationResource = { locale: 'es-CR', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts index e4ecf17e7ee..dd9bc72d415 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const esES: LocalizationResource = { locale: 'es-ES', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts index 09d0fc1e04e..5c65f1d9345 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const esMX: LocalizationResource = { locale: 'es-MX', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts index 6a799bdf3c5..f37ced5f328 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const esUY: LocalizationResource = { locale: 'es-UY', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts index 86beba5c4c9..b35c758fd51 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const faIR: LocalizationResource = { locale: 'fa-IR', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'افزودن کلید جدید', action__search: 'جستجوی کلیدها', diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts index b1a1b5094d2..b2e57275a8f 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const fiFI: LocalizationResource = { locale: 'fi-FI', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Lisää uusi avain', action__search: 'Etsi avaimia', diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts index 583b57611f8..205b5918e51 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const frFR: LocalizationResource = { locale: 'fr-FR', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Ajouter une nouvelle clé', action__search: 'Rechercher des clés', diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts index ae7a88af420..7ce3063a84a 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const heIL: LocalizationResource = { locale: 'he-IL', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/hi-IN.ts b/packages/localizations/src/hi-IN.ts index 9d04a365a23..b9895ab9c45 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const hiIN: LocalizationResource = { locale: 'hi-IN', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'नई कुंजी जोड़ें', action__search: 'कुंजियाँ खोजें', diff --git a/packages/localizations/src/hr-HR.ts b/packages/localizations/src/hr-HR.ts index 34b0d179df7..61b6438f5ba 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const hrHR: LocalizationResource = { locale: 'hr-HR', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Dodaj novi ključ', action__search: 'Pretraži ključeve', diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts index 56540481844..22a1772d7c5 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const huHU: LocalizationResource = { locale: 'hu-HU', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Új kulcs hozzáadása', action__search: 'Kulcsok keresése', diff --git a/packages/localizations/src/id-ID.ts b/packages/localizations/src/id-ID.ts index a66d1a4b145..388df5c2879 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const idID: LocalizationResource = { locale: 'id-ID', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts index 1fc6b0994ea..40b75a74bef 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const isIS: LocalizationResource = { locale: 'is-IS', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Bæta við lykli', action__search: 'Leita að lyklum', diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts index 4c20d26e3c1..6ad8d39b785 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const itIT: LocalizationResource = { locale: 'it-IT', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Aggiungi nuova chiave', action__search: 'Cerca chiavi', diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts index 81068f420aa..184ed6778d9 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const jaJP: LocalizationResource = { locale: 'ja-JP', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: '新しいキーを追加', action__search: 'キーを検索', diff --git a/packages/localizations/src/kk-KZ.ts b/packages/localizations/src/kk-KZ.ts index ba47ffd33de..49afd1fcaa6 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const kkKZ: LocalizationResource = { locale: 'kk-KZ', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts index 6fc94c87f5f..1ae46e8ce0b 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const koKR: LocalizationResource = { locale: 'ko-KR', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: '새 키 만들기', action__search: '키 검색', diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts index ad8fcf996f1..6bed5af9e10 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const mnMN: LocalizationResource = { locale: 'mn-MN', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/ms-MY.ts b/packages/localizations/src/ms-MY.ts index 092c1db2245..5f52e28c5d4 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const msMY: LocalizationResource = { locale: 'ms-MY', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Tambah kunci baharu', action__search: 'Cari kunci', diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts index 475ca454fd7..38f13196827 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const nbNO: LocalizationResource = { locale: 'nb-NO', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Legg til ny nøkkel', action__search: 'Søk i nøkler', diff --git a/packages/localizations/src/nl-BE.ts b/packages/localizations/src/nl-BE.ts index 1f2f19eacea..4ecd0b4825a 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const nlBE: LocalizationResource = { locale: 'nl-BE', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts index 00c4e01265b..4a1ee51f434 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const nlNL: LocalizationResource = { locale: 'nl-NL', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts index 9a0741b215a..307f6af1c2f 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const plPL: LocalizationResource = { locale: 'pl-PL', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts index 9ae09c0eabd..70e283ec08c 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const ptBR: LocalizationResource = { locale: 'pt-BR', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Adicionar nova chave', action__search: 'Pesquisar chaves', diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts index 8e370687ade..3951fcbadb8 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const ptPT: LocalizationResource = { locale: 'pt-PT', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Adicionar nova chave', action__search: 'Pesquisar chaves', diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts index f8cccc3e9f8..8f1bd29d72a 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const roRO: LocalizationResource = { locale: 'ro-RO', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Adaugă cheie nouă', action__search: 'Caută chei', diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts index 32b4035047c..be67aecb7c0 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const ruRU: LocalizationResource = { locale: 'ru-RU', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts index dc36b79e866..8c32d0530ae 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const skSK: LocalizationResource = { locale: 'sk-SK', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts index 157efee229b..ed1c343886e 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const srRS: LocalizationResource = { locale: 'sr-RS', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts index 747357f7ef7..e70a326b0a2 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const svSE: LocalizationResource = { locale: 'sv-SE', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/ta-IN.ts b/packages/localizations/src/ta-IN.ts index 58024742b32..4825ad862b0 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const taIN: LocalizationResource = { locale: 'ta-IN', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'புதிய விசையைச் சேர்', action__search: 'விசைகளைத் தேடு', diff --git a/packages/localizations/src/te-IN.ts b/packages/localizations/src/te-IN.ts index 1dcefe67386..25f27474e3e 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const teIN: LocalizationResource = { locale: 'te-IN', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'కొత్త కీని జోడించు', action__search: 'కీలను శోధించు', diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index e67007a7c1e..23209601570 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const thTH: LocalizationResource = { locale: 'th-TH', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'เพิ่มคีย์ใหม่', action__search: 'ค้นหาคีย์', diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts index b95612c1e19..18d8029fe63 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const trTR: LocalizationResource = { locale: 'tr-TR', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts index 3f982bc72b8..ac1aa579c3d 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const ukUA: LocalizationResource = { locale: 'uk-UA', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts index d848ef3838f..c12d3855877 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const viVN: LocalizationResource = { locale: 'vi-VN', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: 'Thêm khoá mới', action__search: 'Tìm kiếm khoá', diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts index dfbf68c8f6b..544140e9785 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const zhCN: LocalizationResource = { locale: 'zh-CN', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: undefined, action__search: undefined, diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts index 6ca3ead8138..34cf73a8cf8 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -14,6 +14,11 @@ import type { LocalizationResource } from '@clerk/shared/types'; export const zhTW: LocalizationResource = { locale: 'zh-TW', + actionBlocked: { + subtitle: undefined, + title: undefined, + traceIdLabel: undefined, + }, apiKeys: { action__add: '新增金鑰', action__search: '搜尋金鑰', From 79c906efdb76014af0f5cfd966510357d029e871 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Thu, 27 Aug 2026 14:57:58 -0800 Subject: [PATCH 4/7] fix(ui): a block from a challenge submission also gets the terminal screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex review found the gap: when a challenge is submitted and the request is then blocked, useProtectCheckRunner routes the error through handleError(..., card.setError). The interception lived only in the two start components, so that card rendered an inline error with a RETRY button — for something that cannot succeed. Moved detection into the shared card state, where every error in these flows already funnels. It happens before translateError, which flattens the error to a string and discards the meta the screen is built from. Consequences: - The four cards that can show the screen now read `card.blockedDetails`; the per-component hook is gone, and a card that wants the screen is one guard. - The pure helpers moved to utils/actionBlocked.ts so card state can use them without importing the card and creating a cycle. New tests cover the central predicate specifically, because a false positive there would replace a correctable form error with a dead end: it fires only on action_blocked, only with details, and ignores strings, numbers, null and undefined. 21 tests pass; type-check clean across every file this touches. --- packages/ui/src/common/ActionBlockedCard.tsx | 92 +------------------ .../components/SignIn/SignInProtectCheck.tsx | 8 ++ .../ui/src/components/SignIn/SignInStart.tsx | 19 ++-- .../components/SignUp/SignUpProtectCheck.tsx | 8 ++ .../ui/src/components/SignUp/SignUpStart.tsx | 20 ++-- packages/ui/src/elements/contexts/index.tsx | 39 +++++++- .../__tests__/actionBlocked.test.ts} | 41 ++++++++- packages/ui/src/utils/actionBlocked.ts | 74 +++++++++++++++ 8 files changed, 189 insertions(+), 112 deletions(-) rename packages/ui/src/{common/__tests__/ActionBlockedCard.test.tsx => utils/__tests__/actionBlocked.test.ts} (65%) create mode 100644 packages/ui/src/utils/actionBlocked.ts diff --git a/packages/ui/src/common/ActionBlockedCard.tsx b/packages/ui/src/common/ActionBlockedCard.tsx index 603df5b1088..e63689f379c 100644 --- a/packages/ui/src/common/ActionBlockedCard.tsx +++ b/packages/ui/src/common/ActionBlockedCard.tsx @@ -1,65 +1,11 @@ -import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; -import type { ClerkAPIError } from '@clerk/shared/types'; -import React from 'react'; - import { Col, descriptors, Flex, Flow, Icon, localizationKeys, Text } from '../customizables'; import { Card } from '../elements/Card'; import { Header } from '../elements/Header'; import { ExclamationTriangle } from '../icons'; +import type { ActionBlockedDetails } from '../utils/actionBlocked'; +import { safeHref } from '../utils/actionBlocked'; -/** - * The details an application can attach to a blocked request. Every field is - * optional; when none are present the card falls back to its own wording and - * shows only the reference. - * - * The text fields are plain text and are rendered as text nodes. They are - * written by the application's owner, so they are treated as content, never as - * markup. - */ -export type ActionBlockedDetails = { - traceId?: string; - title?: string; - description?: string; - linkUrl?: string; - linkText?: string; -}; - -/** - * Reads the details off an API error, or returns null when the error carries - * none — which is also what happens against an older backend that does not send - * them. Callers use the null to fall back to the previous inline error, so a - * missing field degrades rather than rendering a blank screen. - */ -export const getActionBlockedDetails = (error: ClerkAPIError | undefined): ActionBlockedDetails | null => { - const meta = error?.meta as ActionBlockedDetails | undefined; - if (!meta) { - return null; - } - const { traceId, title, description, linkUrl, linkText } = meta; - if (!traceId && !title && !description && !linkUrl) { - return null; - } - return { traceId, title, description, linkUrl, linkText }; -}; - -/** - * Only `https` links are rendered. - * - * The URL is already checked before it is sent, so this is a second, local - * check rather than the only one: it is what stands between a value that - * reached the browser anyway and a `javascript:` or `data:` URI becoming an - * `href`. A link that fails is dropped and the rest of the card still renders. - */ -export const safeHref = (url: string | undefined): string | null => { - if (!url) { - return null; - } - try { - return new URL(url).protocol === 'https:' ? url : null; - } catch { - return null; - } -}; +export type { ActionBlockedDetails }; type ActionBlockedCardProps = { details: ActionBlockedDetails; @@ -172,35 +118,3 @@ export const ActionBlockedCard = (props: ActionBlockedCardProps) => { ); }; - -/** - * Intercepts a blocked-request error on its way to the card's inline error slot - * and turns it into the terminal screen instead. - * - * Every error in these flows funnels through `card.setError`, so wrapping that - * one function catches both the form-submit path and the OAuth-callback path - * without either having to know about this. - * - * Anything that is not a blocked request — or that is, but carries no details, - * which is what an older backend sends — passes straight through and still - * renders as the inline error it always did. - */ -export const useActionBlocked = (setError: (e: any) => void) => { - const [blockedDetails, setBlockedDetails] = React.useState(null); - - const setErrorOrBlock = React.useCallback( - (e: any) => { - if (e && typeof e === 'object' && e.code === ERROR_CODES.FRAUD_ACTION_BLOCKED) { - const details = getActionBlockedDetails(e as ClerkAPIError); - if (details) { - setBlockedDetails(details); - return; - } - } - setError(e); - }, - [setError], - ); - - return { blockedDetails, setErrorOrBlock }; -}; diff --git a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx index b71845be905..b818de3daaf 100644 --- a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx +++ b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx @@ -7,6 +7,7 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Header } from '@/ui/elements/Header'; import { withRedirectToAfterSignIn } from '../../common'; +import { ActionBlockedCard } from '../../common'; import { useCoreSignIn, useSignInContext } from '../../contexts'; import { Box, @@ -108,6 +109,13 @@ function SignInProtectCheckInternal(): JSX.Element | null { return null; } + // A block that arrives HERE is still terminal: the challenge was submitted and + // denied, so there is nothing to retry, and the runner's inline error would + // otherwise offer a Retry button for something that cannot succeed. + if (card.blockedDetails) { + return ; + } + return ( diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index ebedc80e38c..a81b35fddf1 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -29,7 +29,6 @@ import { ActionBlockedCard, getIdentifierControlDisplayValues, groupIdentifiers, - useActionBlocked, withRedirectToAfterSignIn, withRedirectToSignInTask, } from '../../common'; @@ -88,10 +87,6 @@ const useAutoFillPasskey = () => { function SignInStartInternal(): JSX.Element { const card = useCardState(); - // A blocked request is terminal, so it replaces the card rather than showing - // an inline error beside a form the user cannot resubmit. setErrorOrBlock - // passes everything else through untouched. - const { blockedDetails, setErrorOrBlock } = useActionBlocked(card.setError); const clerk = useClerk(); const status = useLoadingStatus(); const { userSettings, authConfig } = useEnvironment(); @@ -318,7 +313,7 @@ function SignInStartInternal(): JSX.Element { case ERROR_CODES.SIGNUP_RATE_LIMIT_EXCEEDED: case ERROR_CODES.USER_BANNED: case ERROR_CODES.USER_DEACTIVATED: - setErrorOrBlock(error); + card.setError(error); break; default: defaultErrorHandler(); @@ -533,7 +528,7 @@ function SignInStartInternal(): JSX.Element { return handleCombinedFlowTransfer({ afterSignUpUrl: ctx.afterSignUpUrl || '/', clerk, - handleError: e => handleError(e, [identifierField, instantPasswordField], setErrorOrBlock), + handleError: e => handleError(e, [identifierField, instantPasswordField], card.setError), identifierAttribute: attribute, identifierValue: identifierField.value, navigate, @@ -553,7 +548,7 @@ function SignInStartInternal(): JSX.Element { unsafeMetadata: ctx.unsafeMetadata, }); } else { - handleError(e, [identifierField, instantPasswordField], setErrorOrBlock); + handleError(e, [identifierField, instantPasswordField], card.setError); } }; @@ -599,8 +594,12 @@ function SignInStartInternal(): JSX.Element { ? validLastAuthenticationStrategies?.has(lastAuthenticationStrategy) : false; - if (blockedDetails) { - return ; + // A blocked request is terminal — no field to correct, no retry that helps — + // so it replaces the card rather than showing an inline error beside a form + // the user cannot resubmit. Detection lives in card state, so every path + // that reports an error here is covered. + if (card.blockedDetails) { + return ; } return ( diff --git a/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx index 4356435394f..eee536223a6 100644 --- a/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx +++ b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx @@ -6,6 +6,7 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Header } from '@/ui/elements/Header'; import { withRedirectToAfterSignUp } from '../../common'; +import { ActionBlockedCard } from '../../common'; import { useCoreSignUp } from '../../contexts'; import { Box, @@ -103,6 +104,13 @@ function SignUpProtectCheckInternal({ return null; } + // A block that arrives HERE is still terminal: the challenge was submitted and + // denied, so there is nothing to retry, and the runner's inline error would + // otherwise offer a Retry button for something that cannot succeed. + if (card.blockedDetails) { + return ; + } + return ( diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx index 16f57e30fb3..198e8fa1be7 100644 --- a/packages/ui/src/components/SignUp/SignUpStart.tsx +++ b/packages/ui/src/components/SignUp/SignUpStart.tsx @@ -17,7 +17,7 @@ import type { FormControlState } from '@/ui/utils/useFormControl'; import { buildRequest, useFormControl } from '@/ui/utils/useFormControl'; import { createUsernameError } from '@/ui/utils/usernameUtils'; -import { ActionBlockedCard, useActionBlocked, withRedirectToAfterSignUp, withRedirectToSignUpTask } from '../../common'; +import { ActionBlockedCard, withRedirectToAfterSignUp, withRedirectToSignUpTask } from '../../common'; import { SignInContext, useCoreSignUp, useEnvironment, useSignUpContext } from '../../contexts'; import { descriptors, Flex, Flow, localizationKeys, useAppearance, useLocalizations } from '../../customizables'; import { CaptchaElement } from '../../elements/CaptchaElement'; @@ -34,10 +34,6 @@ import { useCompleteSignUpFlow } from './useCompleteSignUpFlow'; function SignUpStartInternal(): JSX.Element { const card = useCardState(); - // A blocked request is terminal, so it replaces the card rather than showing - // an inline error beside a form the user cannot resubmit. setErrorOrBlock - // passes everything else through untouched. - const { blockedDetails, setErrorOrBlock } = useActionBlocked(card.setError); const clerk = useClerk(); const status = useLoadingStatus(); const signUp = useCoreSignUp(); @@ -172,7 +168,7 @@ function SignUpStartInternal(): JSX.Element { .catch(err => { /* Clear ticket values when an error occurs in the initial sign up attempt */ formState.ticket.setValue(''); - handleError(err, [], setErrorOrBlock); + handleError(err, [], card.setError); }) .finally(() => { // Keep the card in loading state during SSO redirect to prevent UI flicker @@ -215,7 +211,7 @@ function SignUpStartInternal(): JSX.Element { case ERROR_CODES.SIGNUP_RATE_LIMIT_EXCEEDED: case ERROR_CODES.USER_BANNED: case ERROR_CODES.USER_DEACTIVATED: - setErrorOrBlock(error); + card.setError(error); break; default: // Error from server may be too much information for the end user, so set a generic error @@ -354,7 +350,7 @@ function SignUpStartInternal(): JSX.Element { return navigate('./enterprise-connections'); } - return handleError(err, fieldsToSubmit, setErrorOrBlock); + return handleError(err, fieldsToSubmit, card.setError); }) .finally(() => card.setIdle()); }; @@ -393,8 +389,12 @@ function SignUpStartInternal(): JSX.Element { return ; } - if (blockedDetails) { - return ; + // A blocked request is terminal — no field to correct, no retry that helps — + // so it replaces the card rather than showing an inline error beside a form + // the user cannot resubmit. Detection lives in card state, so every path + // that reports an error here is covered. + if (card.blockedDetails) { + return ; } return ( diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx index 337d4f3c064..110e0ab1f0a 100644 --- a/packages/ui/src/elements/contexts/index.tsx +++ b/packages/ui/src/elements/contexts/index.tsx @@ -6,10 +6,18 @@ import React from 'react'; import { useRouter } from '@/ui/router'; import { useLocalizations } from '../../customizables'; +import type { ActionBlockedDetails } from '../../utils/actionBlocked'; +import { actionBlockedDetailsFrom } from '../../utils/actionBlocked'; type Status = 'idle' | 'loading' | 'error'; type Metadata = string | undefined; -type State = { status: Status; metadata: Metadata; error: string | undefined }; +type State = { + status: Status; + metadata: Metadata; + error: string | undefined; + /** Set when the request was blocked and there is nothing to retry. */ + blockedDetails?: ActionBlockedDetails | undefined; +}; type CardStateCtxValue = { state: State; setState: React.Dispatch>; @@ -44,8 +52,29 @@ export const useCardState = () => { const { translateError } = useLocalizations(); const setIdle = (metadata?: Metadata) => setState(s => ({ ...s, status: 'idle', metadata })); - const setError = (metadata: ClerkRuntimeError | ClerkAPIError | Metadata | string) => + /** + * Sets the card's inline error — unless the request was BLOCKED, which is + * terminal and gets its own screen instead. + * + * Detected here rather than in each card because every error in these flows + * funnels through this one function: the form submit, the OAuth callback, and + * a challenge submission that is then denied all arrive here. A card that + * rendered this as an inline error would offer a Retry for something that + * cannot succeed. + * + * It must happen BEFORE translateError, which flattens the error to a string + * and discards the meta the screen is built from. Anything that is not a + * blocked request, or that carries no details (an older backend), falls + * through unchanged. + */ + const setError = (metadata: ClerkRuntimeError | ClerkAPIError | Metadata | string) => { + const blocked = actionBlockedDetailsFrom(metadata); + if (blocked) { + setState(s => ({ ...s, blockedDetails: blocked, error: undefined })); + return; + } setState(s => ({ ...s, error: translateError(metadata) })); + }; const setLoading = (metadata?: Metadata) => setState(s => ({ ...s, status: 'loading', metadata })); const runAsync = async (cb: Promise | (() => Promise), metadata?: Metadata) => { setLoading(metadata); @@ -63,6 +92,12 @@ export const useCardState = () => { runAsync, loadingMetadata: state.status === 'loading' ? state.metadata : undefined, error: state.error ? state.error : undefined, + /** + * Set when the request was blocked and there is nothing to retry. A card + * that can render the terminal screen checks this FIRST and returns it + * instead of its normal body. + */ + blockedDetails: state.blockedDetails, isLoading: state.status === 'loading', isIdle: state.status === 'idle', state, diff --git a/packages/ui/src/common/__tests__/ActionBlockedCard.test.tsx b/packages/ui/src/utils/__tests__/actionBlocked.test.ts similarity index 65% rename from packages/ui/src/common/__tests__/ActionBlockedCard.test.tsx rename to packages/ui/src/utils/__tests__/actionBlocked.test.ts index 05815c05bf9..1d8cb68fc97 100644 --- a/packages/ui/src/common/__tests__/ActionBlockedCard.test.tsx +++ b/packages/ui/src/utils/__tests__/actionBlocked.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { getActionBlockedDetails, safeHref } from '../ActionBlockedCard'; +import { actionBlockedDetailsFrom, getActionBlockedDetails, safeHref } from '../actionBlocked'; describe('safeHref', () => { it('allows https', () => { @@ -91,3 +91,42 @@ describe('getActionBlockedDetails', () => { ).toBeNull(); }); }); + +// This is what card state calls on every error, so it decides whether ANY card +// shows the terminal screen. It must be exact about the code: a false positive +// would replace a correctable form error with a dead end. +describe('actionBlockedDetailsFrom', () => { + it('detects a blocked request carrying details', () => { + expect( + actionBlockedDetailsFrom({ + code: 'action_blocked', + message: 'Action blocked', + meta: { traceId: '7Q8ikxgt' }, + }), + ).toEqual({ + traceId: '7Q8ikxgt', + title: undefined, + description: undefined, + linkUrl: undefined, + linkText: undefined, + }); + }); + + it('ignores every other error', () => { + expect(actionBlockedDetailsFrom({ code: 'form_param_nil', meta: { traceId: 'x' } })).toBeNull(); + expect(actionBlockedDetailsFrom({ code: 'form_password_incorrect' })).toBeNull(); + }); + + // A blocked request from an older backend carries no meta. It must fall + // through to the inline error rather than rendering an empty screen. + it('ignores a blocked request with no details', () => { + expect(actionBlockedDetailsFrom({ code: 'action_blocked', message: 'Action blocked' })).toBeNull(); + }); + + it('ignores non-errors', () => { + expect(actionBlockedDetailsFrom(undefined)).toBeNull(); + expect(actionBlockedDetailsFrom(null)).toBeNull(); + expect(actionBlockedDetailsFrom('a plain string message')).toBeNull(); + expect(actionBlockedDetailsFrom(42)).toBeNull(); + }); +}); diff --git a/packages/ui/src/utils/actionBlocked.ts b/packages/ui/src/utils/actionBlocked.ts new file mode 100644 index 00000000000..585761e17d6 --- /dev/null +++ b/packages/ui/src/utils/actionBlocked.ts @@ -0,0 +1,74 @@ +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; +import type { ClerkAPIError } from '@clerk/shared/types'; + +/** + * The details an application can attach to a blocked request. Every field is + * optional; when none are present the screen falls back to its own wording and + * shows only the reference. + * + * The text fields are plain text and are rendered as text nodes. They are + * written by the application's owner, so they are treated as content, never as + * markup. + */ +export type ActionBlockedDetails = { + traceId?: string; + title?: string; + description?: string; + linkUrl?: string; + linkText?: string; +}; + +/** + * Reads the details off an API error, or returns null when the error carries + * none — which is also what happens against an older backend that does not send + * them. Callers use the null to fall back to the previous inline error, so a + * missing field degrades rather than rendering a blank screen. + */ +export const getActionBlockedDetails = (error: ClerkAPIError | undefined): ActionBlockedDetails | null => { + const meta = error?.meta as ActionBlockedDetails | undefined; + if (!meta) { + return null; + } + const { traceId, title, description, linkUrl, linkText } = meta; + if (!traceId && !title && !description && !linkUrl) { + return null; + } + return { traceId, title, description, linkUrl, linkText }; +}; + +/** + * The details for a blocked request, or null for anything else. + * + * This lives beside the card state rather than in the card, because a blocked + * request is terminal wherever it arrives — the start card, the OAuth callback, + * or a challenge submission that is then denied. Detecting it centrally is what + * stops a screen offering "Retry" for something that cannot succeed. + */ +export const actionBlockedDetailsFrom = (metadata: unknown): ActionBlockedDetails | null => { + if (!metadata || typeof metadata !== 'object') { + return null; + } + if ((metadata as ClerkAPIError).code !== ERROR_CODES.FRAUD_ACTION_BLOCKED) { + return null; + } + return getActionBlockedDetails(metadata as ClerkAPIError); +}; + +/** + * Only `https` links are rendered. + * + * The URL is already checked before it is sent, so this is a second, local + * check rather than the only one: it is what stands between a value that + * reached the browser anyway and a `javascript:` or `data:` URI becoming an + * `href`. A link that fails is dropped and the rest of the screen still renders. + */ +export const safeHref = (url: string | undefined): string | null => { + if (!url) { + return null; + } + try { + return new URL(url).protocol === 'https:' ? url : null; + } catch { + return null; + } +}; From bb78d08765dde212ba5529cbe0976320b98043c7 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Thu, 27 Aug 2026 20:57:06 -0800 Subject: [PATCH 5/7] feat(shared,ui): carry kind and data on a blocked request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions the API can now send, both for an application that wants to render its own screen rather than the built-in one: - `kind` — a tag naming why the request was blocked, e.g. `vpn_detected`. - `data` — arbitrary scalars the application's owner attached. Neither is RENDERED. They are read off the meta, carried through the snapshot round trip, and exposed on the error for an application to switch on. Rendering `data` would put somebody's internal keys in front of an end user, which is the opposite of what it is for. A blocked request carrying only a kind, or only data, now counts as something to show — that is precisely the integration this serves, and treating it as empty would have fallen back to the inline error for exactly those applications. Both directions of the meta mapping are hand-maintained lists, so both were updated together and the round-trip spec covers the new fields. Verified: 24 ui predicate tests and 3 shared round-trip tests pass; type-check clean on every file this touches; @clerk/shared and @clerk/localizations build. --- .changeset/blocked-request-screen.md | 2 ++ .../src/__tests__/blockedRequestMeta.spec.ts | 6 ++++ packages/shared/src/errors/clerkApiError.ts | 2 ++ packages/shared/src/errors/parseError.ts | 2 ++ packages/shared/src/types/errors.ts | 21 +++++++++++++ .../src/utils/__tests__/actionBlocked.test.ts | 31 +++++++++++++++++++ packages/ui/src/utils/actionBlocked.ts | 21 +++++++++++-- 7 files changed, 82 insertions(+), 3 deletions(-) diff --git a/.changeset/blocked-request-screen.md b/.changeset/blocked-request-screen.md index 994c8344b55..44b03e0ff20 100644 --- a/.changeset/blocked-request-screen.md +++ b/.changeset/blocked-request-screen.md @@ -10,6 +10,8 @@ A blocked request is terminal — there is no field to correct and no retry that The screen shows a short reference for the request, which the end user can quote when contacting support. When the application supplies its own wording, the screen renders that instead of the default: `title`, `description`, and an optional `https` link with a label are read from the error's `meta`. +The error's `meta` also carries `kind` — a tag naming why the request was blocked — and `data`, an arbitrary bag of scalars the application's owner attached. Neither is rendered by the built-in screen: they are there so an application can switch on `kind` and render its own UI instead. + Also adds the `actionBlocked` localization keys (`title`, `subtitle`, `traceIdLabel`) and appearance descriptors for the new elements, so both the copy and the styling are customizable. This is additive and degrades safely: the error's `code`, `message` and `long_message` are unchanged, and a response without the new `meta` renders exactly as before. diff --git a/packages/shared/src/__tests__/blockedRequestMeta.spec.ts b/packages/shared/src/__tests__/blockedRequestMeta.spec.ts index 3728119b4f9..2e80024722e 100644 --- a/packages/shared/src/__tests__/blockedRequestMeta.spec.ts +++ b/packages/shared/src/__tests__/blockedRequestMeta.spec.ts @@ -14,10 +14,12 @@ describe('blocked request error meta', () => { long_message: 'This action was detected as suspicious and has been blocked.', meta: { trace_id: '7Q8ikxgt', + kind: 'vpn_detected', title: 'We could not verify this sign-in', description: 'Try again from a different network.', link_url: 'https://help.example.com/blocked?ref=7Q8ikxgt', link_text: 'Contact support', + data: { region: 'EU', retryAfter: 3600, appeal: true }, }, }; @@ -25,10 +27,12 @@ describe('blocked request error meta', () => { const error = new ClerkAPIError(json as any); expect(error.meta).toMatchObject({ traceId: '7Q8ikxgt', + kind: 'vpn_detected', title: 'We could not verify this sign-in', description: 'Try again from a different network.', linkUrl: 'https://help.example.com/blocked?ref=7Q8ikxgt', linkText: 'Contact support', + data: { region: 'EU', retryAfter: 3600, appeal: true }, }); }); @@ -39,10 +43,12 @@ describe('blocked request error meta', () => { const roundTripped = new ClerkAPIError(errorToJSON(new ClerkAPIError(json as any)) as any); expect(roundTripped.meta).toMatchObject({ traceId: '7Q8ikxgt', + kind: 'vpn_detected', title: 'We could not verify this sign-in', description: 'Try again from a different network.', linkUrl: 'https://help.example.com/blocked?ref=7Q8ikxgt', linkText: 'Contact support', + data: { region: 'EU', retryAfter: 3600, appeal: true }, }); }); diff --git a/packages/shared/src/errors/clerkApiError.ts b/packages/shared/src/errors/clerkApiError.ts index a85476aa872..adaeb3e5b9d 100644 --- a/packages/shared/src/errors/clerkApiError.ts +++ b/packages/shared/src/errors/clerkApiError.ts @@ -29,10 +29,12 @@ export class ClerkAPIError implements Cler seatsQuantityToAdd: json.meta?.seats_quantity_to_add, seatsQuantity: json.meta?.seats_quantity, traceId: json.meta?.trace_id, + kind: json.meta?.kind, title: json.meta?.title, description: json.meta?.description, linkUrl: json.meta?.link_url, linkText: json.meta?.link_text, + data: json.meta?.data, } as unknown as Meta, }; this.code = parsedError.code; diff --git a/packages/shared/src/errors/parseError.ts b/packages/shared/src/errors/parseError.ts index 51fdc7e902d..a13945d8f10 100644 --- a/packages/shared/src/errors/parseError.ts +++ b/packages/shared/src/errors/parseError.ts @@ -42,10 +42,12 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { seats_quantity_to_add: error?.meta?.seatsQuantityToAdd, seats_quantity: error?.meta?.seatsQuantity, trace_id: error?.meta?.traceId, + kind: error?.meta?.kind, title: error?.meta?.title, description: error?.meta?.description, link_url: error?.meta?.linkUrl, link_text: error?.meta?.linkText, + data: error?.meta?.data, }, }; } diff --git a/packages/shared/src/types/errors.ts b/packages/shared/src/types/errors.ts index d8bfcecb36c..773440e17e0 100644 --- a/packages/shared/src/types/errors.ts +++ b/packages/shared/src/types/errors.ts @@ -24,10 +24,12 @@ export interface ClerkAPIErrorJSON { seats_quantity_to_add?: number; seats_quantity?: number; trace_id?: string; + kind?: string; title?: string; description?: string; link_url?: string; link_text?: string; + data?: Record; }; } @@ -80,6 +82,14 @@ export interface ClerkAPIError { * length. */ traceId?: string; + /** + * A tag naming why the request was blocked, configured by the application's + * owner — for example `vpn_detected`. + * + * Opaque, and never rendered by the built-in screen. It exists so an + * application can switch on it and render its own UI instead. + */ + kind?: string; /** * A heading for the error, configured by the application's owner. * @@ -101,6 +111,17 @@ export interface ClerkAPIError { * The label for `linkUrl`. Only ever set when `linkUrl` is set. */ linkText?: string; + /** + * Arbitrary values the application's owner attached to this rule. + * + * Passed through untouched and **never rendered** by the built-in screen — + * it is here for an application rendering its own UI (usually keyed off + * `kind`). Values are strings, numbers or booleans; there is no nesting. + * + * It is visible to whoever was blocked, so it should not carry anything + * sensitive. + */ + data?: Record; }; } diff --git a/packages/ui/src/utils/__tests__/actionBlocked.test.ts b/packages/ui/src/utils/__tests__/actionBlocked.test.ts index 1d8cb68fc97..cdf365bdf8d 100644 --- a/packages/ui/src/utils/__tests__/actionBlocked.test.ts +++ b/packages/ui/src/utils/__tests__/actionBlocked.test.ts @@ -130,3 +130,34 @@ describe('actionBlockedDetailsFrom', () => { expect(actionBlockedDetailsFrom(42)).toBeNull(); }); }); + +// kind and data are CARRIED, never rendered. A rule configured with only a kind +// is one whose application draws its own screen, so treating it as "nothing to +// show" would take the feature away from exactly that integration. +describe('kind and data', () => { + it('reads them off the meta', () => { + const details = getActionBlockedDetails({ + code: 'action_blocked', + message: 'Action blocked', + meta: { + traceId: '7Q8ikxgt', + kind: 'vpn_detected', + data: { region: 'EU', retryAfter: 3600, appeal: true }, + }, + } as any); + expect(details?.kind).toBe('vpn_detected'); + expect(details?.data).toEqual({ region: 'EU', retryAfter: 3600, appeal: true }); + }); + + it('treats a kind on its own as something to show', () => { + expect( + getActionBlockedDetails({ code: 'action_blocked', message: 'x', meta: { kind: 'vpn_detected' } } as any), + ).not.toBeNull(); + }); + + it('treats data on its own as something to show', () => { + expect( + getActionBlockedDetails({ code: 'action_blocked', message: 'x', meta: { data: { a: 1 } } } as any), + ).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/utils/actionBlocked.ts b/packages/ui/src/utils/actionBlocked.ts index 585761e17d6..deb34b54bb3 100644 --- a/packages/ui/src/utils/actionBlocked.ts +++ b/packages/ui/src/utils/actionBlocked.ts @@ -12,10 +12,22 @@ import type { ClerkAPIError } from '@clerk/shared/types'; */ export type ActionBlockedDetails = { traceId?: string; + /** + * Why the request was blocked, as the application's owner tagged it. Opaque + * and NEVER rendered by this screen — an application switches on it to render + * its own UI instead. + */ + kind?: string; title?: string; description?: string; linkUrl?: string; linkText?: string; + /** + * Arbitrary values the application's owner attached. Carried so an + * application can read them; NEVER rendered here. Rendering them would put + * somebody's internal keys in front of an end user. + */ + data?: Record; }; /** @@ -29,11 +41,14 @@ export const getActionBlockedDetails = (error: ClerkAPIError | undefined): Actio if (!meta) { return null; } - const { traceId, title, description, linkUrl, linkText } = meta; - if (!traceId && !title && !description && !linkUrl) { + const { traceId, kind, title, description, linkUrl, linkText, data } = meta; + // `kind` and `data` count: a rule configured with only a kind is one whose + // application renders its own screen, and treating that as "nothing to show" + // would take the feature away from exactly that integration. + if (!traceId && !kind && !title && !description && !linkUrl && !data) { return null; } - return { traceId, title, description, linkUrl, linkText }; + return { traceId, kind, title, description, linkUrl, linkText, data }; }; /** From bb655ca55e317321b8ebb51f0e430b3e38309854 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Thu, 27 Aug 2026 20:57:26 -0800 Subject: [PATCH 6/7] docs(changeset): message and long_message do change when wording is configured The changeset claimed they were unchanged. That was true when the wording lived only on `meta`; it stopped being true once the owner's title and description started driving them, which is what makes a client that renders only those show the configured wording rather than the generic sentence. --- .changeset/blocked-request-screen.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/blocked-request-screen.md b/.changeset/blocked-request-screen.md index 44b03e0ff20..4bd186ed95c 100644 --- a/.changeset/blocked-request-screen.md +++ b/.changeset/blocked-request-screen.md @@ -14,4 +14,4 @@ The error's `meta` also carries `kind` — a tag naming why the request was bloc Also adds the `actionBlocked` localization keys (`title`, `subtitle`, `traceIdLabel`) and appearance descriptors for the new elements, so both the copy and the styling are customizable. -This is additive and degrades safely: the error's `code`, `message` and `long_message` are unchanged, and a response without the new `meta` renders exactly as before. +This degrades safely: the error's `code` is unchanged, and a response without the new `meta` renders exactly as before. Note that `message` and `long_message` now carry the application owner's own wording when they configured any — so a client that renders only those, rather than the new screen, shows what the owner wrote instead of the generic sentence. From 10709c0d0952e1193b428de3cf44bfb393dfb64a Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Thu, 27 Aug 2026 22:30:47 -0800 Subject: [PATCH 7/7] fix(ui): the blocked screen no longer latches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blockedDetails was set and never cleared, so once a card had shown the blocked screen it could not show anything else. That is worse than it sounds, because clearing an error is how these flows START one: handleClerkApiError calls setGlobalError(undefined) before setting the real error, and the protect-check runner calls card.setError(''). Both cleared `error` and left `blockedDetails` in place — so after any block, the next genuine error on that card would have been invisible behind a terminal screen the user could not leave. setError now owns both fields, which is the invariant that was missing: a blocked error sets the screen, and anything else clears it. Found while investigating the CI failures. It is NOT their cause — those are `Too many requests` from the shared test backend, which is also failing unrelated branches — but it is a real defect and nothing would have caught it. The new tests would: verified they fail when the clear is removed. --- .../contexts/__tests__/cardState.test.tsx | 79 +++++++++++++++++++ packages/ui/src/elements/contexts/index.tsx | 7 +- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/elements/contexts/__tests__/cardState.test.tsx diff --git a/packages/ui/src/elements/contexts/__tests__/cardState.test.tsx b/packages/ui/src/elements/contexts/__tests__/cardState.test.tsx new file mode 100644 index 00000000000..18531df2f78 --- /dev/null +++ b/packages/ui/src/elements/contexts/__tests__/cardState.test.tsx @@ -0,0 +1,79 @@ +import { renderHook, act } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useCardState, withCardStateProvider } from '../index'; + +// The localization layer is not what these assertions are about; translateError +// just needs to hand back something. +vi.mock('../../../customizables', () => ({ + useLocalizations: () => ({ translateError: (e: any) => (typeof e === 'string' ? e : (e?.code ?? '')) }), +})); + +// The provider reads the router only to re-surface Clerk's last error on +// navigation, which is not what these assertions are about. +vi.mock('@/ui/router', () => ({ + useRouter: () => ({ currentPath: '/' }), +})); + +const blocked = { + code: 'action_blocked', + message: 'Action blocked', + meta: { traceId: '7Q8ikxgt' }, +}; + +const wrapper = withCardStateProvider(({ children }: { children?: React.ReactNode }) => <>{children}); + +const renderCard = () => + renderHook(() => useCardState(), { + wrapper: ({ children }) => React.createElement(wrapper as any, null, children), + }); + +describe('card state, blocked requests', () => { + it('sets blockedDetails and clears the inline error', () => { + const { result } = renderCard(); + act(() => result.current.setError(blocked as any)); + expect(result.current.blockedDetails).toEqual({ traceId: '7Q8ikxgt' }); + expect(result.current.error).toBeUndefined(); + }); + + // The blocked screen must not LATCH. Every caller that clears an error passes + // undefined or '' first — handleClerkApiError does, and so does the + // protect-check runner — so a card that had once been blocked would otherwise + // never show anything again, including the next real error. + it('clears blockedDetails when the error is cleared', () => { + const { result } = renderCard(); + act(() => result.current.setError(blocked as any)); + expect(result.current.blockedDetails).toBeTruthy(); + + act(() => result.current.setError(undefined as any)); + expect(result.current.blockedDetails).toBeUndefined(); + }); + + it('clears blockedDetails when a different error replaces it', () => { + const { result } = renderCard(); + act(() => result.current.setError(blocked as any)); + expect(result.current.blockedDetails).toBeTruthy(); + + act(() => result.current.setError({ code: 'form_password_incorrect', message: 'nope' } as any)); + expect(result.current.blockedDetails).toBeUndefined(); + expect(result.current.error).toBe('form_password_incorrect'); + }); + + it('leaves a normal error alone', () => { + const { result } = renderCard(); + act(() => result.current.setError('something went wrong')); + expect(result.current.blockedDetails).toBeUndefined(); + expect(result.current.error).toBe('something went wrong'); + }); + + // A blocked request from an older backend carries no meta, so it must fall + // through to the inline error rather than taking over the screen with nothing + // on it. + it('ignores a blocked error with no details', () => { + const { result } = renderCard(); + act(() => result.current.setError({ code: 'action_blocked', message: 'Action blocked' } as any)); + expect(result.current.blockedDetails).toBeUndefined(); + expect(result.current.error).toBe('action_blocked'); + }); +}); diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx index 110e0ab1f0a..37a9ba69cb2 100644 --- a/packages/ui/src/elements/contexts/index.tsx +++ b/packages/ui/src/elements/contexts/index.tsx @@ -73,7 +73,12 @@ export const useCardState = () => { setState(s => ({ ...s, blockedDetails: blocked, error: undefined })); return; } - setState(s => ({ ...s, error: translateError(metadata) })); + // Clearing blockedDetails here is what keeps setError the single owner of + // BOTH fields. Without it the blocked screen latches: every caller that + // clears an error passes undefined or '' first — handleClerkApiError does, + // and so does the protect-check runner — so a card that had once been + // blocked could never show anything again, including the next real error. + setState(s => ({ ...s, error: translateError(metadata), blockedDetails: undefined })); }; const setLoading = (metadata?: Metadata) => setState(s => ({ ...s, status: 'loading', metadata })); const runAsync = async (cb: Promise | (() => Promise), metadata?: Metadata) => {