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; + } +};