diff --git a/.changeset/blocked-request-screen.md b/.changeset/blocked-request-screen.md
new file mode 100644
index 00000000000..2134e5b7077
--- /dev/null
+++ b/.changeset/blocked-request-screen.md
@@ -0,0 +1,9 @@
+---
+'@clerk/localizations': minor
+'@clerk/shared': minor
+'@clerk/ui': minor
+---
+
+`` and `` show a dedicated screen when a request is blocked, with a reference the user can quote to support.
+
+`action_blocked` errors now expose `traceId`, `title`, `description`, `linkUrl`, `linkText`, `kind` and `data` on `meta`.
diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts
index 79464cff73d..abc75799054 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 0499ceeee98..bdd953bd13c 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 c3202e61755..450becd0a65 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 a5e177ae7dd..701c5945ca9 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 be31010d7ed..d98555fca89 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 018f7b4a436..afae42d07de 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 a34d0a620d6..5b936ef9f7b 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 f9b838ef47e..eeba2a2982e 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 50f0672c06e..0ecafa33088 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 f41dfdd3265..d417e56a005 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 0ec04871220..9c07fb52146 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 0d8ea7143cf..57879bf85b8 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 4ef9b4bd31b..a9117040297 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 0f71e3df409..fec6f4d697b 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 398fe3a806c..b2ee9573843 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 69f31d17907..7b171d34f9f 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 92673e1d8fd..fb752974c2d 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 d0da663e7f1..7aeb57da5fa 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 620cf66b1e7..e6858234500 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 36678670448..98d0c0fae13 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 0dc97826c30..3b734291016 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 68784c5f74b..a8bff797e71 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 0d7501697e3..9c7ceb13b3a 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 447a608a5ae..351c10e08da 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 85c063ec9e5..31e52f8b457 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 4f35db7734a..5d8d3db8d89 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 46121895fee..4167b373d0e 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 03da92dd60f..ad5b4cf22d3 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 ff13835bc24..b96fd7764c1 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 ec8f6d5ebba..0f5febb75f6 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 cedb85df992..ad3c06e17df 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 cbe40324bdf..06b4956b051 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 81c6dc0e1f0..8012f9bbd65 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 104d1190f30..22b7b268eb5 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 4e3f15b9ea2..db238db5c0d 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 6d6200408f7..fafe686c049 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 d8f2a12cfe0..6c40143d2ae 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 7a2630db832..aef3e518f58 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 f17f98c54a1..082a1613e1f 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 27654d0c457..f7d1166ad90 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 a0805ff84ae..ac5f30f0c58 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 bb91d5af794..7e33a3f6738 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 2cbe9dd2e0d..296bfeaf459 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 28bd03c214e..8f0b0007edf 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 73d3d25c826..e5973ed6aba 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 f590cf5557d..0b5276288d3 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 5cb8bc9b8dd..91843807c98 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 bed7dffb089..16f338d03ef 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 e5275233143..8f6091c6a0a 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..703d184bc18
--- /dev/null
+++ b/packages/shared/src/__tests__/blockedRequestMeta.spec.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+
+import { ClerkAPIError } from '../errors/clerkApiError';
+import { errorToJSON } from '../errors/parseError';
+import type { ClerkAPIErrorJSON } from '../types/errors';
+
+describe('blocked request error meta', () => {
+ const json: ClerkAPIErrorJSON = {
+ code: 'action_blocked',
+ message: 'Action blocked',
+ long_message: 'This action was detected as suspicious and has been blocked.',
+ meta: {
+ trace_id: '7Q8ikxgt',
+ kind: 'custom_kind',
+ 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);
+ expect(error.meta).toMatchObject({
+ traceId: '7Q8ikxgt',
+ kind: 'custom_kind',
+ 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('survives a snapshot round trip', () => {
+ const roundTripped = new ClerkAPIError(errorToJSON(new ClerkAPIError(json)));
+ expect(roundTripped.meta).toMatchObject({
+ traceId: '7Q8ikxgt',
+ kind: 'custom_kind',
+ 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',
+ long_message: 'x',
+ meta: { param_name: 'email' },
+ });
+ 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..f54e6982ea0 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.
+ *
+ * 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 configured for this block.
+ *
+ * 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 15eaa32b173..37c552fdb49 100644
--- a/packages/shared/src/types/localization.ts
+++ b/packages/shared/src/types/localization.ts
@@ -2240,6 +2240,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/bundlewatch.config.json b/packages/ui/bundlewatch.config.json
index e1e6d6e6fc6..c2cd9a0e4f4 100644
--- a/packages/ui/bundlewatch.config.json
+++ b/packages/ui/bundlewatch.config.json
@@ -1,11 +1,11 @@
{
"files": [
- { "path": "./dist/ui.browser.js", "maxSize": "44KB" },
+ { "path": "./dist/ui.browser.js", "maxSize": "45KB" },
{ "path": "./dist/ui.legacy.browser.js", "maxSize": "85KB" },
{ "path": "./dist/ui.shared.browser.js", "maxSize": "42KB" },
{ "path": "./dist/framework*.js", "maxSize": "44KB" },
{ "path": "./dist/vendors*.js", "maxSize": "73KB" },
- { "path": "./dist/ui-common*.js", "maxSize": "136KB" },
+ { "path": "./dist/ui-common*.js", "maxSize": "137KB" },
{ "path": "./dist/signin*.js", "maxSize": "19KB" },
{ "path": "./dist/signup*.js", "maxSize": "13KB" },
{ "path": "./dist/userprofile*.js", "maxSize": "16KB" },
diff --git a/packages/ui/src/common/ActionBlockedCard.tsx b/packages/ui/src/common/ActionBlockedCard.tsx
new file mode 100644
index 00000000000..839bfa9f1fd
--- /dev/null
+++ b/packages/ui/src/common/ActionBlockedCard.tsx
@@ -0,0 +1,103 @@
+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;
+};
+
+export const ActionBlockedCard = (props: ActionBlockedCardProps) => {
+ const { traceId, title, description, linkUrl, linkText } = props.details;
+ const href = safeHref(linkUrl);
+
+ return (
+
+
+
+
+ {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 ? (
+
+
+ ({
+ fontFamily: theme.fonts.$buttons,
+ userSelect: 'all',
+ letterSpacing: theme.space.$xxs,
+ })}
+ >
+ {traceId}
+
+
+ ) : null}
+
+
+
+
+
+ );
+};
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..202d83618a1
--- /dev/null
+++ b/packages/ui/src/common/__tests__/ActionBlockedCard.test.tsx
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen } from '@/test/utils';
+import { CardStateProvider } from '@/ui/elements/contexts';
+
+import type { ActionBlockedDetails } from '../ActionBlockedCard';
+import { ActionBlockedCard } from '../ActionBlockedCard';
+
+const { createFixtures } = bindCreateFixtures('SignIn');
+
+const renderCard = async (details: ActionBlockedDetails) => {
+ const { wrapper } = await createFixtures();
+ return render(
+
+
+ ,
+ { wrapper },
+ );
+};
+
+describe('ActionBlockedCard', () => {
+ it('falls back to the default wording and shows the reference', async () => {
+ await renderCard({ traceId: '7Q8ikxgt' });
+
+ screen.getByText("We couldn't complete this request");
+ screen.getByText('For your security, this request could not be completed.');
+ screen.getByText('Reference');
+ screen.getByText('7Q8ikxgt');
+ });
+
+ it("renders the application's wording as text, never as markup", async () => {
+ const { container } = await renderCard({
+ traceId: '7Q8ikxgt',
+ title: 'Blocked',
+ description: '
',
+ });
+
+ screen.getByText('Blocked');
+ screen.getByText('
');
+ expect(container.querySelector('b')).toBeNull();
+ expect(container.querySelector('img')).toBeNull();
+ });
+
+ it('opens an https link in a new tab without an opener or referrer', async () => {
+ await renderCard({ traceId: '7Q8ikxgt', linkUrl: 'https://help.example.com/blocked', linkText: 'Contact support' });
+
+ const link = screen.getByRole('link', { name: 'Contact support' });
+ expect(link).toHaveAttribute('href', 'https://help.example.com/blocked');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('drops a link that is not https and still renders the rest', async () => {
+ const { container } = await renderCard({
+ traceId: '7Q8ikxgt',
+ title: 'Blocked',
+ linkUrl: 'javascript:alert(1)',
+ linkText: 'Click',
+ });
+
+ expect(screen.queryByText('Click')).toBeNull();
+ expect(container.querySelector('a[href^="javascript:"]')).toBeNull();
+ screen.getByText('Blocked');
+ screen.getByText('7Q8ikxgt');
+ });
+});
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 a2b38954a9b..80f312e24cd 100644
--- a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
+++ b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
@@ -6,8 +6,9 @@ import { useEffect, useRef, useState } from 'react';
import { Card } from '@/ui/elements/Card';
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { Header } from '@/ui/elements/Header';
+import { actionBlockedDetailsFrom } from '@/ui/utils/actionBlocked';
-import { withRedirectToAfterSignIn } from '../../common';
+import { ActionBlockedCard, withRedirectToAfterSignIn } from '../../common';
import { useCoreSignIn, useSignInContext } from '../../contexts';
import {
Box,
@@ -140,6 +141,11 @@ function SignInProtectCheckInternal(): JSX.Element | null {
return null;
}
+ const blockedDetails = actionBlockedDetailsFrom(card.rawError);
+ if (blockedDetails) {
+ return ;
+ }
+
return (
diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx
index 2477ce5fefc..d2115a3e575 100644
--- a/packages/ui/src/components/SignIn/SignInStart.tsx
+++ b/packages/ui/src/components/SignIn/SignInStart.tsx
@@ -19,6 +19,7 @@ import { Form } from '@/ui/elements/Form';
import { Header } from '@/ui/elements/Header';
import { LoadingCard } from '@/ui/elements/LoadingCard';
import { SocialButtonsReversibleContainerWithDivider } from '@/ui/elements/ReversibleContainer';
+import { actionBlockedDetailsFrom } from '@/ui/utils/actionBlocked';
import { handleError } from '@/ui/utils/errorHandler';
import { isMobileDevice } from '@/ui/utils/isMobileDevice';
import type { FormControlState } from '@/ui/utils/useFormControl';
@@ -26,6 +27,7 @@ import { buildRequest, useFormControl } from '@/ui/utils/useFormControl';
import type { SignInStartIdentifier } from '../../common';
import {
+ ActionBlockedCard,
getIdentifierControlDisplayValues,
groupIdentifiers,
withRedirectToAfterSignIn,
@@ -606,6 +608,11 @@ function SignInStartInternal(): JSX.Element {
? validLastAuthenticationStrategies?.has(lastAuthenticationStrategy)
: false;
+ const blockedDetails = actionBlockedDetailsFrom(card.rawError);
+ if (blockedDetails) {
+ return ;
+ }
+
return (
{!alternativePhoneCodeProvider ? (
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInActionBlocked.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInActionBlocked.test.tsx
new file mode 100644
index 00000000000..fb9fb8c0bc5
--- /dev/null
+++ b/packages/ui/src/components/SignIn/__tests__/SignInActionBlocked.test.tsx
@@ -0,0 +1,92 @@
+import { ClerkAPIResponseError } from '@clerk/shared/error';
+import type { ClerkAPIErrorJSON, SignInResource } from '@clerk/shared/types';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen } from '@/test/utils';
+
+import { SignInFactorOne } from '../SignInFactorOne';
+import { SignInStart } from '../SignInStart';
+
+const { createFixtures } = bindCreateFixtures('SignIn');
+
+const blockedError = (meta?: ClerkAPIErrorJSON['meta']) =>
+ new ClerkAPIResponseError('Error', {
+ data: [{ code: 'action_blocked', message: 'Action blocked', long_message: 'Blocked.', meta }],
+ status: 403,
+ });
+
+const INLINE_ERROR = /This action couldn't be completed/i;
+
+describe('a blocked sign-in', () => {
+ const originalClerk = window.Clerk;
+ afterEach(() => {
+ window.Clerk = originalClerk;
+ });
+
+ it('replaces the start form with the blocked screen', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ });
+ fixtures.signIn.create.mockRejectedValueOnce(blockedError({ trace_id: '7Q8ikxgt' }));
+
+ const { userEvent } = render(, { wrapper });
+ await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com');
+ await userEvent.click(screen.getByText('Continue'));
+
+ await screen.findByText('7Q8ikxgt');
+ screen.getByText("We couldn't complete this request");
+ expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument();
+ });
+
+ it('keeps the inline error when the block carries no details', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ });
+ fixtures.signIn.create.mockRejectedValueOnce(blockedError());
+
+ const { userEvent } = render(, { wrapper });
+ await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com');
+ await userEvent.click(screen.getByText('Continue'));
+
+ await screen.findByText(INLINE_ERROR);
+ screen.getByLabelText(/email address/i);
+ });
+
+ it('shows the blocked screen for an error handed over from another card', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ });
+ let lastError: unknown = blockedError({ trace_id: '7Q8ikxgt' }).errors[0];
+ window.Clerk = {
+ get __internal_last_error() {
+ const value = lastError;
+ lastError = null;
+ return value;
+ },
+ } as typeof window.Clerk;
+
+ render(, { wrapper });
+
+ await screen.findByText('7Q8ikxgt');
+ });
+
+ it('leaves the inline error alone on a card that does not render the blocked screen', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPassword();
+ f.withPreferredSignInStrategy({ strategy: 'password' });
+ f.startSignInWithPhoneNumber({ supportPassword: true });
+ });
+ fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
+ fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(blockedError({ trace_id: '7Q8ikxgt' }));
+
+ const { userEvent } = render(, { wrapper });
+ await userEvent.type(screen.getByLabelText('Password'), '123456');
+ await userEvent.click(screen.getByText('Continue'));
+
+ await screen.findByText(INLINE_ERROR);
+ expect(screen.queryByText('7Q8ikxgt')).not.toBeInTheDocument();
+ screen.getByLabelText('Password');
+ });
+});
diff --git a/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
index 4356435394f..9654cbef133 100644
--- a/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
+++ b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
@@ -4,8 +4,9 @@ import { type ComponentType, useEffect, useRef, useState } from 'react';
import { Card } from '@/ui/elements/Card';
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { Header } from '@/ui/elements/Header';
+import { actionBlockedDetailsFrom } from '@/ui/utils/actionBlocked';
-import { withRedirectToAfterSignUp } from '../../common';
+import { ActionBlockedCard, withRedirectToAfterSignUp } from '../../common';
import { useCoreSignUp } from '../../contexts';
import {
Box,
@@ -103,6 +104,11 @@ function SignUpProtectCheckInternal({
return null;
}
+ const blockedDetails = actionBlockedDetailsFrom(card.rawError);
+ if (blockedDetails) {
+ return ;
+ }
+
return (
diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx
index e146fd0341d..e565ea575f3 100644
--- a/packages/ui/src/components/SignUp/SignUpStart.tsx
+++ b/packages/ui/src/components/SignUp/SignUpStart.tsx
@@ -11,13 +11,14 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { Header } from '@/ui/elements/Header';
import { LoadingCard } from '@/ui/elements/LoadingCard';
import { SocialButtonsReversibleContainerWithDivider } from '@/ui/elements/ReversibleContainer';
+import { actionBlockedDetailsFrom } from '@/ui/utils/actionBlocked';
import { handleError } from '@/ui/utils/errorHandler';
import { createPasswordError } from '@/ui/utils/passwordUtils';
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';
@@ -385,6 +386,11 @@ function SignUpStartInternal(): JSX.Element {
setAlternativePhoneCodeProvider(phoneCodeProvider);
};
+ const blockedDetails = actionBlockedDetailsFrom(card.rawError);
+ if (blockedDetails) {
+ return ;
+ }
+
if (mode !== SIGN_UP_MODES.PUBLIC && !(hasTicket || hasExistingSignUpWithTicket)) {
return ;
}
diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpActionBlocked.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpActionBlocked.test.tsx
new file mode 100644
index 00000000000..e9a8579e174
--- /dev/null
+++ b/packages/ui/src/components/SignUp/__tests__/SignUpActionBlocked.test.tsx
@@ -0,0 +1,58 @@
+import { ClerkAPIResponseError } from '@clerk/shared/error';
+import type { ClerkAPIErrorJSON } from '@clerk/shared/types';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen } from '@/test/utils';
+
+import { SignUpStart } from '../SignUpStart';
+
+const { createFixtures } = bindCreateFixtures('SignUp');
+
+const blockedError = (meta?: ClerkAPIErrorJSON['meta']) =>
+ new ClerkAPIResponseError('Error', {
+ data: [{ code: 'action_blocked', message: 'Action blocked', long_message: 'Blocked.', meta }],
+ status: 403,
+ });
+
+describe('a blocked sign-up', () => {
+ const originalClerk = window.Clerk;
+ afterEach(() => {
+ window.Clerk = originalClerk;
+ });
+
+ it('replaces the start form with the blocked screen', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress({ required: true });
+ f.withPassword({ required: true });
+ });
+ fixtures.signUp.create.mockRejectedValueOnce(blockedError({ trace_id: '7Q8ikxgt' }));
+
+ const { userEvent } = render(, { wrapper });
+ await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com');
+ await userEvent.type(screen.getByLabelText('Password'), 'a-long-enough-password');
+ await userEvent.click(screen.getByText('Continue'));
+
+ await screen.findByText('7Q8ikxgt');
+ expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument();
+ });
+
+ it('shows the blocked screen rather than the restricted-access screen', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withRestrictedMode();
+ });
+ let lastError: unknown = blockedError({ trace_id: '7Q8ikxgt' }).errors[0];
+ window.Clerk = {
+ get __internal_last_error() {
+ const value = lastError;
+ lastError = null;
+ return value;
+ },
+ } as typeof window.Clerk;
+
+ render(, { wrapper });
+
+ await screen.findByText('7Q8ikxgt');
+ expect(screen.queryByText('Access restricted')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts
index c7301b05d2e..50199a04a29 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..dce3f88da9c
--- /dev/null
+++ b/packages/ui/src/elements/contexts/__tests__/cardState.test.tsx
@@ -0,0 +1,59 @@
+import type { ClerkAPIError } from '@clerk/shared/types';
+import { act, renderHook } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { useCardState, withCardStateProvider } from '../index';
+
+vi.mock('../../../customizables', () => ({
+ useLocalizations: () => ({
+ translateError: (e: ClerkAPIError | string | undefined) => (typeof e === 'string' ? e : (e?.code ?? '')),
+ }),
+}));
+
+vi.mock('@/ui/router', () => ({
+ useRouter: () => ({ currentPath: '/' }),
+}));
+
+const blocked: ClerkAPIError = {
+ code: 'action_blocked',
+ message: 'Action blocked',
+ meta: { traceId: '7Q8ikxgt' },
+};
+
+const Provider = withCardStateProvider(({ children }: { children?: React.ReactNode }) => <>{children}>);
+
+const renderCard = () =>
+ renderHook(() => useCardState(), {
+ wrapper: ({ children }) => {children},
+ });
+
+describe('card state keeps the raw error beside the translated one', () => {
+ it('sets both', () => {
+ const { result } = renderCard();
+ act(() => result.current.setError(blocked));
+ expect(result.current.rawError).toBe(blocked);
+ expect(result.current.error).toBe('action_blocked');
+ });
+
+ it('clears both', () => {
+ const { result } = renderCard();
+ act(() => result.current.setError(blocked));
+ act(() => result.current.setError(undefined));
+ expect(result.current.rawError).toBeUndefined();
+ expect(result.current.error).toBeUndefined();
+
+ act(() => result.current.setError(blocked));
+ act(() => result.current.setError(''));
+ expect(result.current.rawError).toBeUndefined();
+ });
+
+ it('replaces both', () => {
+ const { result } = renderCard();
+ const incorrect: ClerkAPIError = { code: 'form_password_incorrect', message: 'nope' };
+ act(() => result.current.setError(blocked));
+ act(() => result.current.setError(incorrect));
+ expect(result.current.rawError).toBe(incorrect);
+ expect(result.current.error).toBe('form_password_incorrect');
+ });
+});
diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx
index 3bf0bb70382..6d13d25476c 100644
--- a/packages/ui/src/elements/contexts/index.tsx
+++ b/packages/ui/src/elements/contexts/index.tsx
@@ -9,7 +9,13 @@ import { useLocalizations } from '../../customizables';
type Status = 'idle' | 'loading' | 'error';
type Metadata = string | undefined;
-type State = { status: Status; metadata: Metadata; error: string | undefined };
+type CardError = ClerkRuntimeError | ClerkAPIError | Metadata | string;
+type State = {
+ status: Status;
+ metadata: Metadata;
+ error: string | undefined;
+ rawError?: CardError;
+};
type CardStateCtxValue = {
state: State;
setState: React.Dispatch>;
@@ -21,17 +27,16 @@ export const CardStateProvider = (props: React.PropsWithChildren) => {
const { translateError } = useLocalizations();
const router = useRouter();
- const [state, setState] = React.useState(() => ({
- status: 'idle',
- metadata: undefined,
- error: translateError(window?.Clerk?.__internal_last_error || undefined),
- }));
+ const [state, setState] = React.useState(() => {
+ const lastError = window?.Clerk?.__internal_last_error || undefined;
+ return { status: 'idle', metadata: undefined, error: translateError(lastError), rawError: lastError };
+ });
React.useEffect(() => {
const error = window?.Clerk?.__internal_last_error;
if (error) {
- setState(s => ({ ...s, error: translateError(error) }));
+ setState(s => ({ ...s, error: translateError(error), rawError: error }));
}
}, [translateError, setState, router.currentPath]);
@@ -44,8 +49,8 @@ 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) }));
+ const setError = (metadata: CardError) =>
+ setState(s => ({ ...s, error: translateError(metadata), rawError: metadata || undefined }));
const setLoading = (metadata?: Metadata) => setState(s => ({ ...s, status: 'loading', metadata }));
const runAsync = async (cb: Promise | (() => Promise), metadata?: Metadata) => {
setLoading(metadata);
@@ -63,6 +68,7 @@ export const useCardState = () => {
runAsync,
loadingMetadata: state.status === 'loading' ? state.metadata : undefined,
error: state.error ? state.error : undefined,
+ rawError: state.rawError,
isLoading: state.status === 'loading',
isIdle: state.status === 'idle',
state,
@@ -144,7 +150,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 93a617d45a2..99c1f1f7537 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..c5694f85cac
--- /dev/null
+++ b/packages/ui/src/utils/__tests__/actionBlocked.test.ts
@@ -0,0 +1,104 @@
+import type { ClerkAPIError } from '@clerk/shared/types';
+import { describe, expect, it } from 'vitest';
+
+import { actionBlockedDetailsFrom, getActionBlockedDetails, safeHref } from '../actionBlocked';
+
+const blocked = (meta?: ClerkAPIError['meta']): ClerkAPIError => ({
+ code: 'action_blocked',
+ message: 'Action blocked',
+ meta,
+});
+
+describe('safeHref', () => {
+ it('allows https', () => {
+ expect(safeHref('https://help.example.com/blocked?ref=7Q8ikxgt')).toBe(
+ 'https://help.example.com/blocked?ref=7Q8ikxgt',
+ );
+ });
+
+ 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(
+ blocked({
+ traceId: '7Q8ikxgt',
+ kind: 'custom_kind',
+ 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 },
+ }),
+ ),
+ ).toEqual({
+ traceId: '7Q8ikxgt',
+ kind: 'custom_kind',
+ 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('accepts a reference with no message', () => {
+ const details = getActionBlockedDetails(blocked({ traceId: '7Q8ikxgt' }));
+ expect(details?.traceId).toBe('7Q8ikxgt');
+ expect(details?.title).toBeUndefined();
+ });
+
+ it('returns null when there is nothing to show', () => {
+ expect(getActionBlockedDetails(undefined)).toBeNull();
+ expect(getActionBlockedDetails(blocked())).toBeNull();
+ expect(getActionBlockedDetails(blocked({}))).toBeNull();
+ });
+
+ it('does not count fields the screen does not render', () => {
+ expect(getActionBlockedDetails(blocked({ linkText: 'Contact support' }))).toBeNull();
+ expect(getActionBlockedDetails(blocked({ kind: 'custom_kind' }))).toBeNull();
+ expect(getActionBlockedDetails(blocked({ data: { a: 1 } }))).toBeNull();
+ });
+});
+
+describe('actionBlockedDetailsFrom', () => {
+ it('detects a blocked request carrying details', () => {
+ expect(actionBlockedDetailsFrom(blocked({ traceId: '7Q8ikxgt' }))).toMatchObject({ traceId: '7Q8ikxgt' });
+ });
+
+ it('ignores every other error', () => {
+ const other: ClerkAPIError = { code: 'form_param_nil', message: 'x', meta: { traceId: 'x' } };
+ expect(actionBlockedDetailsFrom(other)).toBeNull();
+ });
+
+ it('ignores a blocked request with no details', () => {
+ expect(actionBlockedDetailsFrom(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..d22e5254bbb
--- /dev/null
+++ b/packages/ui/src/utils/actionBlocked.ts
@@ -0,0 +1,45 @@
+import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants';
+import type { ClerkAPIError } from '@clerk/shared/types';
+
+export type ActionBlockedDetails = {
+ traceId?: string;
+ kind?: string;
+ title?: string;
+ description?: string;
+ linkUrl?: string;
+ linkText?: string;
+ data?: Record;
+};
+
+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;
+ if (!traceId && !title && !description && !linkUrl) {
+ return null;
+ }
+ return { traceId, kind, title, description, linkUrl, linkText, data };
+};
+
+export const actionBlockedDetailsFrom = (error: unknown): ActionBlockedDetails | null => {
+ if (!error || typeof error !== 'object') {
+ return null;
+ }
+ if ((error as ClerkAPIError).code !== ERROR_CODES.FRAUD_ACTION_BLOCKED) {
+ return null;
+ }
+ return getActionBlockedDetails(error as ClerkAPIError);
+};
+
+export const safeHref = (url: string | undefined): string | null => {
+ if (!url) {
+ return null;
+ }
+ try {
+ return new URL(url).protocol === 'https:' ? url : null;
+ } catch {
+ return null;
+ }
+};