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