diff --git a/.changeset/blocked-request-screen.md b/.changeset/blocked-request-screen.md
new file mode 100644
index 00000000000..4bd186ed95c
--- /dev/null
+++ b/.changeset/blocked-request-screen.md
@@ -0,0 +1,17 @@
+---
+'@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`.
+
+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 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.
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/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/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: '搜尋金鑰',
diff --git a/packages/shared/src/__tests__/blockedRequestMeta.spec.ts b/packages/shared/src/__tests__/blockedRequestMeta.spec.ts
new file mode 100644
index 00000000000..2e80024722e
--- /dev/null
+++ b/packages/shared/src/__tests__/blockedRequestMeta.spec.ts
@@ -0,0 +1,60 @@
+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',
+ 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 },
+ },
+ };
+
+ it('parses every field off the wire', () => {
+ 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 },
+ });
+ });
+
+ // 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',
+ 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 },
+ });
+ });
+
+ 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/clerkApiError.ts b/packages/shared/src/errors/clerkApiError.ts
index f8c26fa2b17..adaeb3e5b9d 100644
--- a/packages/shared/src/errors/clerkApiError.ts
+++ b/packages/shared/src/errors/clerkApiError.ts
@@ -28,6 +28,13 @@ 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,
+ 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 e29ebf2e864..a13945d8f10 100644
--- a/packages/shared/src/errors/parseError.ts
+++ b/packages/shared/src/errors/parseError.ts
@@ -41,6 +41,13 @@ 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,
+ 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 60531f065b4..773440e17e0 100644
--- a/packages/shared/src/types/errors.ts
+++ b/packages/shared/src/types/errors.ts
@@ -23,6 +23,13 @@ export interface ClerkAPIErrorJSON {
is_plan_upgrade_possible?: boolean;
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;
};
}
@@ -67,6 +74,54 @@ 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 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.
+ *
+ * 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;
+ /**
+ * 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/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..e63689f379c
--- /dev/null
+++ b/packages/ui/src/common/ActionBlockedCard.tsx
@@ -0,0 +1,120 @@
+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';
+
+export type { ActionBlockedDetails };
+
+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}
+
+
+
+
+
+ );
+};
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/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 658643d83bb..a81b35fddf1 100644
--- a/packages/ui/src/components/SignIn/SignInStart.tsx
+++ b/packages/ui/src/components/SignIn/SignInStart.tsx
@@ -26,6 +26,7 @@ import { buildRequest, useFormControl } from '@/ui/utils/useFormControl';
import type { SignInStartIdentifier } from '../../common';
import {
+ ActionBlockedCard,
getIdentifierControlDisplayValues,
groupIdentifiers,
withRedirectToAfterSignIn,
@@ -593,6 +594,14 @@ function SignInStartInternal(): JSX.Element {
? validLastAuthenticationStrategies?.has(lastAuthenticationStrategy)
: false;
+ // 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 (
{!alternativePhoneCodeProvider ? (
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 e146fd0341d..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 { 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';
@@ -389,6 +389,14 @@ function SignUpStartInternal(): JSX.Element {
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 (
{!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/__tests__/cardState.test.tsx b/packages/ui/src/elements/contexts/__tests__/cardState.test.tsx
new file mode 100644
index 00000000000..ff7847fe3d1
--- /dev/null
+++ b/packages/ui/src/elements/contexts/__tests__/cardState.test.tsx
@@ -0,0 +1,79 @@
+import { act, renderHook } 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 379e43ebf3b..37a9ba69cb2 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,34 @@ export const useCardState = () => {
const { translateError } = useLocalizations();
const setIdle = (metadata?: Metadata) => setState(s => ({ ...s, status: 'idle', metadata }));
- const setError = (metadata: ClerkRuntimeError | ClerkAPIError | Metadata | string) =>
- setState(s => ({ ...s, error: translateError(metadata) }));
+ /**
+ * 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;
+ }
+ // 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) => {
setLoading(metadata);
@@ -63,6 +97,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,
@@ -141,7 +181,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;
diff --git a/packages/ui/src/utils/__tests__/actionBlocked.test.ts b/packages/ui/src/utils/__tests__/actionBlocked.test.ts
new file mode 100644
index 00000000000..cdf365bdf8d
--- /dev/null
+++ b/packages/ui/src/utils/__tests__/actionBlocked.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it } from 'vitest';
+
+import { actionBlockedDetailsFrom, getActionBlockedDetails, safeHref } from '../actionBlocked';
+
+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();
+ });
+});
+
+// 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();
+ });
+});
+
+// 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
new file mode 100644
index 00000000000..deb34b54bb3
--- /dev/null
+++ b/packages/ui/src/utils/actionBlocked.ts
@@ -0,0 +1,89 @@
+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;
+ /**
+ * 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;
+};
+
+/**
+ * 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, 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, kind, title, description, linkUrl, linkText, data };
+};
+
+/**
+ * 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;
+ }
+};