From 296905fb6a415d2a11e536a107e32a3d299141cd Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 23 Sep 2026 18:02:29 -0400 Subject: [PATCH 01/20] feat(shared,ui,localizations): Show password confirmation attempt countdown --- .changeset/small-mice-change.md | 5 ++ .changeset/tidy-password-attempts.md | 7 ++ .../src/core/resources/__tests__/Base.test.ts | 42 ++++++++++ packages/localizations/src/ar-SA.ts | 3 + packages/localizations/src/be-BY.ts | 3 + packages/localizations/src/bg-BG.ts | 3 + packages/localizations/src/bn-IN.ts | 3 + packages/localizations/src/ca-ES.ts | 3 + packages/localizations/src/cs-CZ.ts | 3 + packages/localizations/src/da-DK.ts | 3 + packages/localizations/src/de-DE.ts | 3 + packages/localizations/src/el-GR.ts | 3 + packages/localizations/src/en-GB.ts | 3 + packages/localizations/src/en-US.ts | 4 + packages/localizations/src/es-CR.ts | 3 + packages/localizations/src/es-ES.ts | 3 + packages/localizations/src/es-MX.ts | 3 + packages/localizations/src/es-UY.ts | 3 + packages/localizations/src/fa-IR.ts | 3 + packages/localizations/src/fi-FI.ts | 3 + packages/localizations/src/fr-FR.ts | 3 + packages/localizations/src/he-IL.ts | 3 + packages/localizations/src/hi-IN.ts | 3 + packages/localizations/src/hr-HR.ts | 3 + packages/localizations/src/hu-HU.ts | 3 + packages/localizations/src/id-ID.ts | 3 + packages/localizations/src/is-IS.ts | 3 + packages/localizations/src/it-IT.ts | 3 + packages/localizations/src/ja-JP.ts | 3 + packages/localizations/src/kk-KZ.ts | 3 + packages/localizations/src/ko-KR.ts | 3 + packages/localizations/src/mn-MN.ts | 3 + packages/localizations/src/ms-MY.ts | 3 + packages/localizations/src/nb-NO.ts | 3 + packages/localizations/src/nl-BE.ts | 3 + packages/localizations/src/nl-NL.ts | 3 + packages/localizations/src/pl-PL.ts | 3 + packages/localizations/src/pt-BR.ts | 3 + packages/localizations/src/pt-PT.ts | 3 + packages/localizations/src/ro-RO.ts | 3 + packages/localizations/src/ru-RU.ts | 3 + packages/localizations/src/sk-SK.ts | 3 + packages/localizations/src/sr-RS.ts | 3 + packages/localizations/src/sv-SE.ts | 3 + packages/localizations/src/ta-IN.ts | 3 + packages/localizations/src/te-IN.ts | 3 + packages/localizations/src/th-TH.ts | 3 + packages/localizations/src/tr-TR.ts | 3 + packages/localizations/src/uk-UA.ts | 3 + packages/localizations/src/vi-VN.ts | 3 + packages/localizations/src/zh-CN.ts | 3 + packages/localizations/src/zh-TW.ts | 3 + .../errors/__tests__/clerkApiError.test.ts | 26 +++++++ packages/shared/src/errors/clerkApiError.ts | 1 + packages/shared/src/errors/parseError.ts | 1 + packages/shared/src/types/errors.ts | 3 + packages/shared/src/types/localization.ts | 3 + .../__tests__/makeLocalizable.test.tsx | 78 +++++++++++++++++++ .../ui/src/localization/makeLocalizable.tsx | 15 ++++ 59 files changed, 329 insertions(+) create mode 100644 .changeset/small-mice-change.md create mode 100644 .changeset/tidy-password-attempts.md create mode 100644 packages/shared/src/errors/__tests__/clerkApiError.test.ts diff --git a/.changeset/small-mice-change.md b/.changeset/small-mice-change.md new file mode 100644 index 00000000000..5f62990d193 --- /dev/null +++ b/.changeset/small-mice-change.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Password validation API errors now expose `meta.remainingAttempts`, including when the value is `0`. The API returns it only when an instance enables password confirmation protection, which is off by default. diff --git a/.changeset/tidy-password-attempts.md b/.changeset/tidy-password-attempts.md new file mode 100644 index 00000000000..59c8508ab6c --- /dev/null +++ b/.changeset/tidy-password-attempts.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Show remaining password confirmation attempts and session-ended explanations during password changes and reverification. These appear only when an instance enables password confirmation protection, which is off by default. The messages are localizable through `unstable__errors.password_confirmation_attempt_remaining`, `unstable__errors.password_confirmation_attempts_remaining` (with `{{remainingAttempts}}`), and `unstable__errors.password_confirmation_session_ended`. diff --git a/packages/clerk-js/src/core/resources/__tests__/Base.test.ts b/packages/clerk-js/src/core/resources/__tests__/Base.test.ts index 0eb8022b2a2..c67026ffee5 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Base.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Base.test.ts @@ -11,6 +11,10 @@ class TestResource extends BaseResource { return this._baseGet(); } + mutate() { + return BaseResource._fetch({ method: 'PATCH', path: '/test' }); + } + fromJSON() { return this; } @@ -58,4 +62,42 @@ describe('BaseResource', () => { console.dir(errResponse); expect(errResponse.retryAfter).toBe(undefined); }); + + it('applies a piggybacked client update before throwing a failed mutation response', async () => { + const requestingSession = { id: 'sess_requesting', status: 'ended' }; + const otherSession = { id: 'sess_other', status: 'active' }; + const client = { id: 'client_1', sessions: [requestingSession, otherSession] }; + const updateClient = vi.fn(); + + BaseResource.clerk = { + // @ts-expect-error - We're not about to mock the entire FapiClient + getFapiClient: () => ({ + request: vi.fn().mockResolvedValue({ + payload: { + client, + errors: [{ code: 'form_password_validation_failed', message: 'Password is incorrect' }], + }, + status: 422, + statusText: 'Unprocessable Entity', + headers: new Headers(), + }), + }), + __internal_setCountry: vi.fn(), + updateClient, + } as any; + + const resource = new TestResource(); + await expect(resource.mutate()).rejects.toMatchObject({ + errors: [expect.objectContaining({ code: 'form_password_validation_failed' })], + }); + + expect(updateClient).toHaveBeenCalledWith( + expect.objectContaining({ + sessions: expect.arrayContaining([ + expect.objectContaining({ id: requestingSession.id, status: requestingSession.status }), + expect.objectContaining({ id: otherSession.id, status: otherSession.status }), + ]), + }), + ); + }); }); diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index c707911a13b..c3518f085db 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -2146,6 +2146,9 @@ export const arSA: LocalizationResource = { requireUppercase: 'حرف كبير', sentencePrefix: 'كلمة السر يجب أن تحتوي على', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'هذا الرقم مأخوذ الرجاء أختيار رقم آخر', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts index db7b9ca5650..4270c749614 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -2157,6 +2157,9 @@ export const beBY: LocalizationResource = { requireUppercase: 'літару ў верхнім рэгістры', sentencePrefix: 'Ваш пароль павінен утрымліваць', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Гэты нумар тэлефона ўжо заняты. Калі ласка, паспрабуйце іншы.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts index fc88d5a762e..f4694b9d66c 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -2150,6 +2150,9 @@ export const bgBG: LocalizationResource = { requireUppercase: 'главна буква', sentencePrefix: 'Вашата парола трябва да съдържа', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Този телефонен номер е зает. Моля, опитайте с друг.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/bn-IN.ts b/packages/localizations/src/bn-IN.ts index 7385f976b99..ef70a944206 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -2175,6 +2175,9 @@ export const bnIN: LocalizationResource = { requireUppercase: 'একটি বড় হাতের অক্ষর', sentencePrefix: 'আপনার পাসওয়ার্ডে অবশ্যই থাকতে হবে', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'এই ফোন নম্বর ব্যবহৃত হয়েছে। দয়া করে অন্য একটি ব্যবহার করুন।', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ca-ES.ts b/packages/localizations/src/ca-ES.ts index 9225a3c42c5..b70a9c8b1c6 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -2163,6 +2163,9 @@ export const caES: LocalizationResource = { requireUppercase: 'una lletra majúscula', sentencePrefix: 'La teva contrasenya ha de contenir', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: "Aquest número de telèfon ja està en ús. Si us plau, prova'n un altre.", protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts index cc16a2a3d70..af3881ac87d 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -2162,6 +2162,9 @@ export const csCZ: LocalizationResource = { requireUppercase: 'velké písmeno', sentencePrefix: 'Vaše heslo musí obsahovat', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Toto telefonní číslo se používá. Zkuste prosím jiný.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts index 362723d2869..1bc830a21e3 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -2147,6 +2147,9 @@ export const daDK: LocalizationResource = { requireUppercase: 'et stort bogstav', sentencePrefix: 'Din adgangskode skal indeholde', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Dette telefonnummer er allerede taget. Prøv et andet.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index 52ecf5ecb8b..eb5c3ce54f8 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -2180,6 +2180,9 @@ export const deDE: LocalizationResource = { requireUppercase: 'einen Großbuchstaben enthalten', sentencePrefix: 'Das Passwort muss', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Diese Telefonnummer ist bereits vergeben. Bitte wählen Sie eine Andere.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts index 5013b35ca5b..269cd2e6041 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -2171,6 +2171,9 @@ export const elGR: LocalizationResource = { requireUppercase: 'ένα κεφαλαίο γράμμα', sentencePrefix: 'Ο κωδικός πρόσβασής σας πρέπει να περιέχει', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Αυτός ο αριθμός τηλεφώνου χρησιμοποιείται ήδη. Δοκιμάστε έναν άλλο.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/en-GB.ts b/packages/localizations/src/en-GB.ts index a038681c897..8202dd35794 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -2154,6 +2154,9 @@ export const enGB: LocalizationResource = { requireUppercase: 'an uppercase letter', sentencePrefix: 'Your password must contain', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'This phone number is taken. Please try another.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index b307ffe16d6..b80efbcb4cd 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -2210,6 +2210,10 @@ export const enUS: LocalizationResource = { requireUppercase: 'an uppercase letter', sentencePrefix: 'Your password must contain', }, + password_confirmation_attempt_remaining: 'Incorrect password. You have 1 attempt remaining.', + password_confirmation_attempts_remaining: 'Incorrect password. You have {{remainingAttempts}} attempts remaining.', + password_confirmation_session_ended: + 'Your session has ended because you reached the password confirmation attempt limit. Sign in again to continue.', phone_number_exists: undefined, protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/es-CR.ts b/packages/localizations/src/es-CR.ts index ed06e9d92f1..f4c9acbc90c 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -2162,6 +2162,9 @@ export const esCR: LocalizationResource = { requireUppercase: 'al menos una letra mayúscula', sentencePrefix: 'Tu contraseña debe contener', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Este número de telefónico ya está en uso.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts index cff65d2b6a5..f563689947b 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -2163,6 +2163,9 @@ export const esES: LocalizationResource = { requireUppercase: 'Al menos una letra mayúscula', sentencePrefix: 'Tu contraseña debe contener:', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Este número de teléfono ya está en uso. Por favor, inténtelo con otro.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts index 2e6d7e4256d..b6b4f893565 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -2163,6 +2163,9 @@ export const esMX: LocalizationResource = { requireUppercase: 'al menos una letra mayúscula', sentencePrefix: 'Tu contraseña debe contener', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Este número de telefónico ya está en uso.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts index 395d82d04e4..7fc4992fa97 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -2164,6 +2164,9 @@ export const esUY: LocalizationResource = { requireUppercase: 'una letra mayúscula', sentencePrefix: 'Tu contraseña debe contener', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Este número de teléfono ya está en uso. Por favor, probá con otro.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts index 38ce6f62bba..db9dab2dd80 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -2161,6 +2161,9 @@ export const faIR: LocalizationResource = { requireUppercase: 'یک حرف بزرگ', sentencePrefix: 'رمز عبور شما باید حاوی', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'این شماره تلفن قبلاً استفاده شده است.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts index 5e68744f92e..166bdf8993c 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -2177,6 +2177,9 @@ export const fiFI: LocalizationResource = { requireUppercase: 'iso kirjain', sentencePrefix: 'Salasanan on sisällettävä', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Tämä puhelinnumero on jo käytössä. Kokeile toista.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts index b10aa5e3a60..937074b12d6 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -2172,6 +2172,9 @@ export const frFR: LocalizationResource = { requireUppercase: 'une lettre majuscule', sentencePrefix: 'Votre mot de passe doit contenir', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Ce numéro de téléphone est déjà utilisé. Veuillez essayer un autre.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts index 76d4e71749c..821b9a55596 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -2138,6 +2138,9 @@ export const heIL: LocalizationResource = { requireUppercase: 'אות גדולה', sentencePrefix: 'הסיסמה שלך חייבת להכיל', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'מספר הטלפון הזה כבר בשימוש. אנא נסה מספר אחר.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/hi-IN.ts b/packages/localizations/src/hi-IN.ts index a15280fb922..1a36f4c5124 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -2176,6 +2176,9 @@ export const hiIN: LocalizationResource = { requireUppercase: 'एक अपरकेस अक्षर', sentencePrefix: 'आपके पासवर्ड में होना चाहिए', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'यह फोन नंबर पहले से लिया गया है। कृपया दूसरा प्रयास करें।', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/hr-HR.ts b/packages/localizations/src/hr-HR.ts index 898e1668daf..b69e7546c70 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -2181,6 +2181,9 @@ export const hrHR: LocalizationResource = { requireUppercase: 'veliko slovo', sentencePrefix: 'Vaša lozinka mora sadržavati', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Ovaj telefonski broj je zauzet. Molimo pokušajte s drugim.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts index ba75bd80414..24e460f1ffa 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -2180,6 +2180,9 @@ export const huHU: LocalizationResource = { requireUppercase: 'egy nagybetű', sentencePrefix: 'A jelszavadnak tartalmaznia kell', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Ez a telefonszám már foglalt. Kérlek próbálj meg egy másikat.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/id-ID.ts b/packages/localizations/src/id-ID.ts index 7b35077a8b8..1b6f9620c29 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -2160,6 +2160,9 @@ export const idID: LocalizationResource = { requireUppercase: 'huruf besar', sentencePrefix: 'Kata sandi Anda harus mengandung', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Nomor telepon ini sudah digunakan. Silakan coba yang lain.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts index f015eed4f00..8a781387135 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -2178,6 +2178,9 @@ export const isIS: LocalizationResource = { requireUppercase: 'hástaf', sentencePrefix: 'Lykilorðið þitt verður að innihalda', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Þetta símanúmer er þegar í notkun. Vinsamlegast reyndu annað.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts index 4044db1665a..76d90cab854 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -2159,6 +2159,9 @@ export const itIT: LocalizationResource = { requireUppercase: undefined, sentencePrefix: undefined, }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Questo numero di telefono è già in uso. Per favore, prova con un altro.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts index e0298371964..e29d5eca524 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -2174,6 +2174,9 @@ export const jaJP: LocalizationResource = { requireUppercase: '大文字を含む', sentencePrefix: 'パスワードは次の条件を満たす必要があります:', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'この電話番号は既に使用されています。別のものをお試しください。', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/kk-KZ.ts b/packages/localizations/src/kk-KZ.ts index 8a51cb18eca..a2e6d572f08 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -2140,6 +2140,9 @@ export const kkKZ: LocalizationResource = { requireUppercase: 'бас әріп', sentencePrefix: 'Құпия сөзде болуы керек:', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Бұл телефон нөмірі тіркелген. Басқасын қолданыңыз.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts index abec99a1dff..9a2ab275f9f 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -2147,6 +2147,9 @@ export const koKR: LocalizationResource = { requireUppercase: '대문자', sentencePrefix: '비밀번호에는 다음이 꼭 포함돼야 해요', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: '이 전화번호는 이미 사용 중이에요. 다른 번호를 시도해 주세요.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts index 64c73cef2a1..daaebd83f3c 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -2152,6 +2152,9 @@ export const mnMN: LocalizationResource = { requireUppercase: 'том үсэг', sentencePrefix: 'таны нууц үг агуулсан байх ёстой', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Энэ утасны дугаарыг авсан. Өөр оролдоно уу.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ms-MY.ts b/packages/localizations/src/ms-MY.ts index 99c926f3155..b66ac1e3ac9 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -2185,6 +2185,9 @@ export const msMY: LocalizationResource = { requireUppercase: 'huruf besar', sentencePrefix: 'Kata laluan anda mesti mengandungi', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Nombor telefon ini telah diambil. Sila cuba yang lain.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts index 0ebbf9b3315..a5e268fb1bd 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -2178,6 +2178,9 @@ export const nbNO: LocalizationResource = { requireUppercase: 'en stor bokstav', sentencePrefix: 'Passordet ditt må inneholde', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Dette telefonnummeret er allerede i bruk. Vennligst bruk et annet telefonnummer.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/nl-BE.ts b/packages/localizations/src/nl-BE.ts index a82b79edd58..459e0f253de 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -2151,6 +2151,9 @@ export const nlBE: LocalizationResource = { requireUppercase: 'Wachtwoord moet minstens 1 hoofdletter bevatten.', sentencePrefix: 'Wachtwoord moet minstens 1 speciaal teken bevatten.', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Dit telefoonnummer is al in gebruik. Probeer een ander nummer.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts index cf305556832..96885dff0d4 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -2151,6 +2151,9 @@ export const nlNL: LocalizationResource = { requireUppercase: 'Wachtwoord moet minstens 1 hoofdletter bevatten.', sentencePrefix: 'Wachtwoord moet minstens 1 speciaal teken bevatten.', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Dit telefoonnummer is al in gebruik. Probeer een ander nummer.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts index 19eed5d20d3..735f8d4fffe 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -2159,6 +2159,9 @@ export const plPL: LocalizationResource = { requireUppercase: 'wielką literę', sentencePrefix: 'Twoje hasło musi zawierać', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Numer telefonu jest już zajęty. Proszę spróbować innego.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts index 0b265682603..5dfdb7848ef 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -2169,6 +2169,9 @@ export const ptBR: LocalizationResource = { requireUppercase: 'uma letra maiúscula', sentencePrefix: 'Sua senha deve conter', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Este número de telefone já está em uso. Por favor, tente outro.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts index 72e81ceabb0..b9930e02ef5 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -2175,6 +2175,9 @@ export const ptPT: LocalizationResource = { requireUppercase: 'uma letra maiúscula', sentencePrefix: 'A sua palavra-passe deve conter', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Este número de telemóvel já está em uso. Por favor, tente outro.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts index d8515f1b2ae..c7a086cbec1 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -2170,6 +2170,9 @@ export const roRO: LocalizationResource = { requireUppercase: 'o literă mare', sentencePrefix: 'Parola ta trebuie să conțină', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: undefined, protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts index 3af75fb03eb..fd46cbdbace 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -2167,6 +2167,9 @@ export const ruRU: LocalizationResource = { requireUppercase: 'букву в верхнем регистре', sentencePrefix: 'Ваш пароль должен содержать', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Этот номер телефона уже занят. Пожалуйста, попробуйте другой.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts index 1dcc2f679d6..ab2f4d831ea 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -2158,6 +2158,9 @@ export const skSK: LocalizationResource = { requireUppercase: 'veľké písmeno', sentencePrefix: 'Vaše heslo musí obsahovať', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Toto telefónne číslo je už obsadené. Skúste prosím iné.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts index 77c45b2f883..43793387a80 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -2150,6 +2150,9 @@ export const srRS: LocalizationResource = { requireUppercase: 'veliko slovo', sentencePrefix: 'Tvoja lozinka mora sadržati', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Ovaj telefonski broj je zauzet. Molimo pokušaj sa drugim.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts index cec06c14be5..4d2cffd86e8 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -2153,6 +2153,9 @@ export const svSE: LocalizationResource = { requireUppercase: 'Kräver stora bokstäver', sentencePrefix: 'Lösenordet måste innehålla', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Detta telefonnummer är taget. Vänligen prova ett annat.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ta-IN.ts b/packages/localizations/src/ta-IN.ts index 5dbb4287d9c..71ec2e3b0cf 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -2186,6 +2186,9 @@ export const taIN: LocalizationResource = { requireUppercase: 'ஒரு பெரிய எழுத்து', sentencePrefix: 'உங்கள் கடவுச்சொல்லில் இருக்க வேண்டும்', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'இந்த தொலைபேசி எண் எடுக்கப்பட்டுள்ளது. வேறொன்றை முயற்சிக்கவும்.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/te-IN.ts b/packages/localizations/src/te-IN.ts index 341a072a8c1..a455d7fe049 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -2180,6 +2180,9 @@ export const teIN: LocalizationResource = { requireUppercase: 'ఒక పెద్ద అక్షరం', sentencePrefix: 'మీ పాస్‌వర్డ్‌లో ఉండాలి', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'ఈ ఫోన్ నంబర్ తీసుకోబడింది. దయచేసి మరొకదాన్ని ప్రయత్నించండి.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index 51dcfa5b0e9..709e51ad178 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -2148,6 +2148,9 @@ export const thTH: LocalizationResource = { requireUppercase: 'ตัวอักษรพิมพ์ใหญ่', sentencePrefix: 'รหัสผ่านของคุณต้องมี', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: undefined, protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts index 50d01c57240..b590a3dab3a 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -2154,6 +2154,9 @@ export const trTR: LocalizationResource = { requireUppercase: 'bir büyük harf içermeli', sentencePrefix: 'Şifreniz;', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Bu telefon numarası zaten kullanılıyor. Lütfen başka bir numara deneyin.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts index 9125e2b878c..2e335b8ed9f 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -2148,6 +2148,9 @@ export const ukUA: LocalizationResource = { requireUppercase: 'букву у верхньому регістрі', sentencePrefix: 'Ваш пароль повинен містити', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: 'Цей номер телефону вже використовується. Спробуйте інший.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts index 1b5a87abe44..7f18bedf2ad 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -2171,6 +2171,9 @@ export const viVN: LocalizationResource = { requireUppercase: 'một chữ cái viết hoa', sentencePrefix: 'Mật khẩu của bạn phải chứa', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: undefined, protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts index 3e65172457b..63c272ed506 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -2131,6 +2131,9 @@ export const zhCN: LocalizationResource = { requireUppercase: '一个大写字母', sentencePrefix: '您的密码必须包含', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: '该电话号码已被使用,请尝试其他号码。', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts index 3d905a96077..ae4bd006ca4 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -2136,6 +2136,9 @@ export const zhTW: LocalizationResource = { requireUppercase: '一個大寫字母', sentencePrefix: '您的密碼必須包含:', }, + password_confirmation_attempt_remaining: undefined, + password_confirmation_attempts_remaining: undefined, + password_confirmation_session_ended: undefined, phone_number_exists: '此電話號碼已被使用,請嘗試其他號碼。', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/shared/src/errors/__tests__/clerkApiError.test.ts b/packages/shared/src/errors/__tests__/clerkApiError.test.ts new file mode 100644 index 00000000000..6ba2047adb0 --- /dev/null +++ b/packages/shared/src/errors/__tests__/clerkApiError.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { ClerkAPIError } from '../clerkApiError'; +import { errorToJSON } from '../parseError'; + +describe('ClerkAPIError remainingAttempts metadata', () => { + it('converts remaining_attempts to remainingAttempts, including zero', () => { + const error = new ClerkAPIError({ + code: 'form_password_validation_failed', + message: 'Password is incorrect', + meta: { remaining_attempts: 0 }, + }); + + expect(error.meta.remainingAttempts).toBe(0); + }); + + it('serializes remainingAttempts as remaining_attempts', () => { + const error = new ClerkAPIError({ + code: 'form_password_incorrect', + message: 'Password is incorrect', + meta: { remaining_attempts: 2 }, + }); + + expect(errorToJSON(error).meta?.remaining_attempts).toBe(2); + }); +}); diff --git a/packages/shared/src/errors/clerkApiError.ts b/packages/shared/src/errors/clerkApiError.ts index adaeb3e5b9d..102c87c09f4 100644 --- a/packages/shared/src/errors/clerkApiError.ts +++ b/packages/shared/src/errors/clerkApiError.ts @@ -35,6 +35,7 @@ export class ClerkAPIError implements Cler linkUrl: json.meta?.link_url, linkText: json.meta?.link_text, data: json.meta?.data, + remainingAttempts: json.meta?.remaining_attempts, } 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 a13945d8f10..a3e0620a2f0 100644 --- a/packages/shared/src/errors/parseError.ts +++ b/packages/shared/src/errors/parseError.ts @@ -48,6 +48,7 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { link_url: error?.meta?.linkUrl, link_text: error?.meta?.linkText, data: error?.meta?.data, + remaining_attempts: error?.meta?.remainingAttempts, }, }; } diff --git a/packages/shared/src/types/errors.ts b/packages/shared/src/types/errors.ts index f54e6982ea0..1192fb43ddb 100644 --- a/packages/shared/src/types/errors.ts +++ b/packages/shared/src/types/errors.ts @@ -30,6 +30,7 @@ export interface ClerkAPIErrorJSON { link_url?: string; link_text?: string; data?: Record; + remaining_attempts?: number; }; } @@ -122,6 +123,8 @@ export interface ClerkAPIError { * sensitive. */ data?: Record; + /** Remaining password confirmation attempts in the current session; zero means the session has ended. */ + remainingAttempts?: number; }; } diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 446d328a8e5..14b9389b289 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -2551,6 +2551,9 @@ type UnstableErrors = WithParamName<{ form_password_incorrect: LocalizationValue; form_password_or_identifier_incorrect: LocalizationValue; form_password_validation_failed: LocalizationValue; + password_confirmation_attempt_remaining: LocalizationValue; + password_confirmation_attempts_remaining: LocalizationValue<'remainingAttempts'>; + password_confirmation_session_ended: LocalizationValue; not_allowed_access: LocalizationValue; oauth_access_denied: LocalizationValue; form_identifier_exists: LocalizationValue; diff --git a/packages/ui/src/localization/__tests__/makeLocalizable.test.tsx b/packages/ui/src/localization/__tests__/makeLocalizable.test.tsx index c3d9b8f1458..01938c7ff5c 100644 --- a/packages/ui/src/localization/__tests__/makeLocalizable.test.tsx +++ b/packages/ui/src/localization/__tests__/makeLocalizable.test.tsx @@ -145,6 +145,84 @@ describe('Test localizable components', () => { ).toBe('form_identifier_exists__email_address'); }); + it.each([ + ['form_password_validation_failed', 2, 'Incorrect password. You have 2 attempts remaining.'], + ['form_password_incorrect', 1, 'Incorrect password. You have 1 attempt remaining.'], + [ + 'form_password_validation_failed', + 0, + 'Your session has ended because you reached the password confirmation attempt limit. Sign in again to continue.', + ], + [ + 'form_password_incorrect', + 0, + 'Your session has ended because you reached the password confirmation attempt limit. Sign in again to continue.', + ], + ])( + 'shows the confirmation countdown for %s with %i attempts remaining', + async (code, remainingAttempts, expected) => { + const { wrapper, fixtures } = await createFixtures(); + fixtures.options.localization = { + unstable__errors: { + form_password_validation_failed: 'Generic translated password error', + form_password_incorrect: 'Generic translated password error', + }, + }; + const { result } = renderHook(() => useLocalizations(), { wrapper }); + + expect( + result.current.translateError({ + code, + message: 'Incorrect password', + longMessage: 'Server message', + meta: { remainingAttempts }, + }), + ).toBe(expected); + }, + ); + + it('uses customer translations for the confirmation countdown', async () => { + const { wrapper, fixtures } = await createFixtures(); + fixtures.options.localization = { + unstable__errors: { + password_confirmation_attempts_remaining: 'Mot de passe incorrect. {{remainingAttempts}} tentatives restantes.', + password_confirmation_session_ended: 'Votre session est terminée.', + }, + }; + const { result } = renderHook(() => useLocalizations(), { wrapper }); + + expect( + result.current.translateError({ + code: 'form_password_validation_failed', + message: 'Incorrect password', + meta: { remainingAttempts: 3 }, + }), + ).toBe('Mot de passe incorrect. 3 tentatives restantes.'); + expect( + result.current.translateError({ + code: 'form_password_incorrect', + message: 'Incorrect password', + meta: { remainingAttempts: 0 }, + }), + ).toBe('Votre session est terminée.'); + }); + + it('keeps translated password errors when there is no confirmation countdown', async () => { + const { wrapper, fixtures } = await createFixtures(); + fixtures.options.localization = { + unstable__errors: { form_password_validation_failed: 'Translated password error' }, + }; + const { result } = renderHook(() => useLocalizations(), { wrapper }); + + expect( + result.current.translateError({ + code: 'form_password_validation_failed', + message: 'Incorrect password', + longMessage: 'Incorrect password. Please try again.', + }), + ).toBe('Translated password error'); + }); + it('translates native OAuth access denied errors using the default localization resource', async () => { const { wrapper } = await createFixtures(); const { result } = renderHook(() => useLocalizations(), { wrapper }); diff --git a/packages/ui/src/localization/makeLocalizable.tsx b/packages/ui/src/localization/makeLocalizable.tsx index 371f8f53351..c1228c7a370 100644 --- a/packages/ui/src/localization/makeLocalizable.tsx +++ b/packages/ui/src/localization/makeLocalizable.tsx @@ -99,6 +99,21 @@ export const useLocalizations = () => { return ''; } + // Password confirmation failures carry a countdown; zero means the session has ended. + const remainingAttempts = meta?.remainingAttempts; + if ( + (code === 'form_password_validation_failed' || code === 'form_password_incorrect') && + typeof remainingAttempts === 'number' + ) { + const countdownKey = + remainingAttempts === 0 + ? localizationKeys('unstable__errors.password_confirmation_session_ended') + : remainingAttempts === 1 + ? localizationKeys('unstable__errors.password_confirmation_attempt_remaining') + : localizationKeys('unstable__errors.password_confirmation_attempts_remaining', { remainingAttempts }); + return t(countdownKey) || longMessage || message; + } + return ( t(localizationKeys(`unstable__errors.${code}__${paramName}` as any)) || t(localizationKeys(`unstable__errors.${code}` as any)) || From ffee6971c0d144c38a508216a05072fea63b2606 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 23 Sep 2026 18:15:14 -0400 Subject: [PATCH 02/20] refactor(ui): Build password confirmation countdown on the password fields --- .../components/UserProfile/PasswordForm.tsx | 4 +- .../UserVerificationFactorOnePassword.tsx | 6 +- .../__tests__/makeLocalizable.test.tsx | 78 ------------------- .../ui/src/localization/makeLocalizable.tsx | 15 ---- .../utils/__tests__/passwordUtils.test.tsx | 52 ++++++++++++- packages/ui/src/utils/passwordUtils.ts | 21 +++++ 6 files changed, 80 insertions(+), 96 deletions(-) diff --git a/packages/ui/src/components/UserProfile/PasswordForm.tsx b/packages/ui/src/components/UserProfile/PasswordForm.tsx index c9038469e86..6821a449af4 100644 --- a/packages/ui/src/components/UserProfile/PasswordForm.tsx +++ b/packages/ui/src/components/UserProfile/PasswordForm.tsx @@ -10,7 +10,7 @@ import { FormContainer } from '@/ui/elements/FormContainer'; import { InformationBox } from '@/ui/elements/InformationBox'; import type { SuccessPage } from '@/ui/elements/SuccessPage'; import { handleError } from '@/ui/utils/errorHandler'; -import { createPasswordError } from '@/ui/utils/passwordUtils'; +import { createPasswordConfirmationError, createPasswordError } from '@/ui/utils/passwordUtils'; import { useFormControl } from '@/ui/utils/useFormControl'; import { useEnvironment } from '../../contexts'; @@ -69,6 +69,8 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => type: 'password', label: localizationKeys('formFieldLabel__currentPassword'), isRequired: true, + validatePassword: false, + buildErrorMessage: errors => createPasswordConfirmationError(errors, { t }), }); const passwordField = useFormControl('newPassword', '', { diff --git a/packages/ui/src/components/UserVerification/UserVerificationFactorOnePassword.tsx b/packages/ui/src/components/UserVerification/UserVerificationFactorOnePassword.tsx index a8f62c47a04..f34b751e07a 100644 --- a/packages/ui/src/components/UserVerification/UserVerificationFactorOnePassword.tsx +++ b/packages/ui/src/components/UserVerification/UserVerificationFactorOnePassword.tsx @@ -6,9 +6,10 @@ import { useCardState } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { Header } from '@/ui/elements/Header'; import { handleError } from '@/ui/utils/errorHandler'; +import { createPasswordConfirmationError } from '@/ui/utils/passwordUtils'; import { useFormControl } from '@/ui/utils/useFormControl'; -import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; +import { Col, descriptors, Flow, localizationKeys, useLocalizations } from '../../customizables'; import { HavingTrouble } from '../SignIn/HavingTrouble'; import { useAfterVerification } from './use-after-verification'; @@ -22,6 +23,7 @@ export function UserVerificationFactorOnePasswordCard(props: UserVerificationFac const { handleVerificationResponse } = useAfterVerification(); const card = useCardState(); + const { t } = useLocalizations(); const [showHavingTrouble, setShowHavingTrouble] = React.useState(false); const toggleHavingTrouble = React.useCallback(() => setShowHavingTrouble(s => !s), [setShowHavingTrouble]); @@ -30,6 +32,8 @@ export function UserVerificationFactorOnePasswordCard(props: UserVerificationFac type: 'password', label: localizationKeys('formFieldLabel__password'), placeholder: localizationKeys('formFieldInputPlaceholder__password'), + validatePassword: false, + buildErrorMessage: errors => createPasswordConfirmationError(errors, { t }), }); const handlePasswordSubmit: React.FormEventHandler = async e => { diff --git a/packages/ui/src/localization/__tests__/makeLocalizable.test.tsx b/packages/ui/src/localization/__tests__/makeLocalizable.test.tsx index 01938c7ff5c..c3d9b8f1458 100644 --- a/packages/ui/src/localization/__tests__/makeLocalizable.test.tsx +++ b/packages/ui/src/localization/__tests__/makeLocalizable.test.tsx @@ -145,84 +145,6 @@ describe('Test localizable components', () => { ).toBe('form_identifier_exists__email_address'); }); - it.each([ - ['form_password_validation_failed', 2, 'Incorrect password. You have 2 attempts remaining.'], - ['form_password_incorrect', 1, 'Incorrect password. You have 1 attempt remaining.'], - [ - 'form_password_validation_failed', - 0, - 'Your session has ended because you reached the password confirmation attempt limit. Sign in again to continue.', - ], - [ - 'form_password_incorrect', - 0, - 'Your session has ended because you reached the password confirmation attempt limit. Sign in again to continue.', - ], - ])( - 'shows the confirmation countdown for %s with %i attempts remaining', - async (code, remainingAttempts, expected) => { - const { wrapper, fixtures } = await createFixtures(); - fixtures.options.localization = { - unstable__errors: { - form_password_validation_failed: 'Generic translated password error', - form_password_incorrect: 'Generic translated password error', - }, - }; - const { result } = renderHook(() => useLocalizations(), { wrapper }); - - expect( - result.current.translateError({ - code, - message: 'Incorrect password', - longMessage: 'Server message', - meta: { remainingAttempts }, - }), - ).toBe(expected); - }, - ); - - it('uses customer translations for the confirmation countdown', async () => { - const { wrapper, fixtures } = await createFixtures(); - fixtures.options.localization = { - unstable__errors: { - password_confirmation_attempts_remaining: 'Mot de passe incorrect. {{remainingAttempts}} tentatives restantes.', - password_confirmation_session_ended: 'Votre session est terminée.', - }, - }; - const { result } = renderHook(() => useLocalizations(), { wrapper }); - - expect( - result.current.translateError({ - code: 'form_password_validation_failed', - message: 'Incorrect password', - meta: { remainingAttempts: 3 }, - }), - ).toBe('Mot de passe incorrect. 3 tentatives restantes.'); - expect( - result.current.translateError({ - code: 'form_password_incorrect', - message: 'Incorrect password', - meta: { remainingAttempts: 0 }, - }), - ).toBe('Votre session est terminée.'); - }); - - it('keeps translated password errors when there is no confirmation countdown', async () => { - const { wrapper, fixtures } = await createFixtures(); - fixtures.options.localization = { - unstable__errors: { form_password_validation_failed: 'Translated password error' }, - }; - const { result } = renderHook(() => useLocalizations(), { wrapper }); - - expect( - result.current.translateError({ - code: 'form_password_validation_failed', - message: 'Incorrect password', - longMessage: 'Incorrect password. Please try again.', - }), - ).toBe('Translated password error'); - }); - it('translates native OAuth access denied errors using the default localization resource', async () => { const { wrapper } = await createFixtures(); const { result } = renderHook(() => useLocalizations(), { wrapper }); diff --git a/packages/ui/src/localization/makeLocalizable.tsx b/packages/ui/src/localization/makeLocalizable.tsx index c1228c7a370..371f8f53351 100644 --- a/packages/ui/src/localization/makeLocalizable.tsx +++ b/packages/ui/src/localization/makeLocalizable.tsx @@ -99,21 +99,6 @@ export const useLocalizations = () => { return ''; } - // Password confirmation failures carry a countdown; zero means the session has ended. - const remainingAttempts = meta?.remainingAttempts; - if ( - (code === 'form_password_validation_failed' || code === 'form_password_incorrect') && - typeof remainingAttempts === 'number' - ) { - const countdownKey = - remainingAttempts === 0 - ? localizationKeys('unstable__errors.password_confirmation_session_ended') - : remainingAttempts === 1 - ? localizationKeys('unstable__errors.password_confirmation_attempt_remaining') - : localizationKeys('unstable__errors.password_confirmation_attempts_remaining', { remainingAttempts }); - return t(countdownKey) || longMessage || message; - } - return ( t(localizationKeys(`unstable__errors.${code}__${paramName}` as any)) || t(localizationKeys(`unstable__errors.${code}` as any)) || diff --git a/packages/ui/src/utils/__tests__/passwordUtils.test.tsx b/packages/ui/src/utils/__tests__/passwordUtils.test.tsx index d1e596d93ac..5d6b443e4b0 100644 --- a/packages/ui/src/utils/__tests__/passwordUtils.test.tsx +++ b/packages/ui/src/utils/__tests__/passwordUtils.test.tsx @@ -4,7 +4,7 @@ import { bindCreateFixtures } from '@/test/create-fixtures'; import { renderHook } from '@/test/utils'; import { OptionsProvider } from '@/ui/contexts'; import { useLocalizations } from '@/ui/customizables'; -import { createPasswordError } from '@/ui/utils/passwordUtils'; +import { createPasswordConfirmationError, createPasswordError } from '@/ui/utils/passwordUtils'; const { createFixtures } = bindCreateFixtures('SignIn'); @@ -286,3 +286,53 @@ describe('createPasswordError() constructs error that password', () => { ); }); }); + +describe('createPasswordConfirmationError()', () => { + const renderLocalizations = async (localization = {}) => { + const { wrapper: Wrapper } = await createFixtures(); + const wrapper = ({ children }) => ( + + {children} + + ); + return renderHook(() => useLocalizations(), { wrapper }).result; + }; + + it.each([ + [2, 'Incorrect password. You have 2 attempts remaining.'], + [1, 'Incorrect password. You have 1 attempt remaining.'], + [ + 0, + 'Your session has ended because you reached the password confirmation attempt limit. Sign in again to continue.', + ], + ])('shows the countdown with %i attempts remaining', async (remainingAttempts, expected) => { + const result = await renderLocalizations(); + + const res = createPasswordConfirmationError( + [{ code: 'form_password_validation_failed', message: 'Incorrect password', meta: { remainingAttempts } }], + { t: result.current.t }, + ); + expect(res).toBe(expected); + }); + + it('uses customer translations for the countdown', async () => { + const result = await renderLocalizations({ + unstable__errors: { + password_confirmation_attempts_remaining: 'Mot de passe incorrect. {{remainingAttempts}} tentatives restantes.', + }, + }); + + const res = createPasswordConfirmationError( + [{ code: 'form_password_incorrect', message: 'Incorrect password', meta: { remainingAttempts: 3 } }], + { t: result.current.t }, + ); + expect(res).toBe('Mot de passe incorrect. 3 tentatives restantes.'); + }); + + it('returns other errors unchanged', async () => { + const result = await renderLocalizations(); + const error = { code: 'form_password_validation_failed', message: 'Incorrect password' }; + + expect(createPasswordConfirmationError([error], { t: result.current.t })).toBe(error); + }); +}); diff --git a/packages/ui/src/utils/passwordUtils.ts b/packages/ui/src/utils/passwordUtils.ts index 55447f7937d..e36fa108469 100644 --- a/packages/ui/src/utils/passwordUtils.ts +++ b/packages/ui/src/utils/passwordUtils.ts @@ -92,6 +92,27 @@ export const createPasswordError = ( return passwordErrorMessage; }; +// A password confirmation failure carries the remaining attempts; zero means the session has ended. +// Other errors are returned unchanged so they keep the default translation. +export const createPasswordConfirmationError = ( + errors: ClerkAPIError[], + localizationConfig: Pick, +) => { + const remainingAttempts = errors[0]?.meta?.remainingAttempts; + if (typeof remainingAttempts !== 'number') { + return errors[0]; + } + + const { t } = localizationConfig; + if (remainingAttempts === 0) { + return t(localizationKeys('unstable__errors.password_confirmation_session_ended')); + } + if (remainingAttempts === 1) { + return t(localizationKeys('unstable__errors.password_confirmation_attempt_remaining')); + } + return t(localizationKeys('unstable__errors.password_confirmation_attempts_remaining', { remainingAttempts })); +}; + export const addFullStop = (string: string | undefined) => { return !string ? '' : string.endsWith('.') ? string : `${string}.`; }; From 89a2ca7109d0a8ca0add8bd636645980c15aa0f2 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 23 Sep 2026 18:49:02 -0400 Subject: [PATCH 03/20] chore(repo): Consolidate password confirmation changesets --- .changeset/password-confirmation-attempts.md | 10 ++++++++++ .changeset/small-mice-change.md | 5 ----- .changeset/tidy-password-attempts.md | 7 ------- 3 files changed, 10 insertions(+), 12 deletions(-) create mode 100644 .changeset/password-confirmation-attempts.md delete mode 100644 .changeset/small-mice-change.md delete mode 100644 .changeset/tidy-password-attempts.md diff --git a/.changeset/password-confirmation-attempts.md b/.changeset/password-confirmation-attempts.md new file mode 100644 index 00000000000..445fdcc764d --- /dev/null +++ b/.changeset/password-confirmation-attempts.md @@ -0,0 +1,10 @@ +--- +'@clerk/localizations': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Show how many password attempts remain when a user enters the wrong current password while changing their password or during reverification, and explain that the session has ended once the attempts run out. This only applies to instances that enable password confirmation protection, which is off by default. + +- `ClerkAPIError.meta.remainingAttempts` holds the attempts left in the current session. `0` means the session has ended. +- Customize the messages with the `unstable__errors.password_confirmation_attempt_remaining`, `unstable__errors.password_confirmation_attempts_remaining` (receives `{{remainingAttempts}}`), and `unstable__errors.password_confirmation_session_ended` localization keys. diff --git a/.changeset/small-mice-change.md b/.changeset/small-mice-change.md deleted file mode 100644 index 5f62990d193..00000000000 --- a/.changeset/small-mice-change.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/shared': patch ---- - -Password validation API errors now expose `meta.remainingAttempts`, including when the value is `0`. The API returns it only when an instance enables password confirmation protection, which is off by default. diff --git a/.changeset/tidy-password-attempts.md b/.changeset/tidy-password-attempts.md deleted file mode 100644 index 59c8508ab6c..00000000000 --- a/.changeset/tidy-password-attempts.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@clerk/localizations': patch -'@clerk/shared': patch -'@clerk/ui': patch ---- - -Show remaining password confirmation attempts and session-ended explanations during password changes and reverification. These appear only when an instance enables password confirmation protection, which is off by default. The messages are localizable through `unstable__errors.password_confirmation_attempt_remaining`, `unstable__errors.password_confirmation_attempts_remaining` (with `{{remainingAttempts}}`), and `unstable__errors.password_confirmation_session_ended`. From 6b465aa981b82f0ce27f0e95efcae6c97679fb49 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 23 Sep 2026 18:49:03 -0400 Subject: [PATCH 04/20] feat(localizations): Translate password confirmation attempt messages --- packages/localizations/src/ar-SA.ts | 7 ++++--- packages/localizations/src/be-BY.ts | 7 ++++--- packages/localizations/src/bg-BG.ts | 7 ++++--- packages/localizations/src/bn-IN.ts | 7 ++++--- packages/localizations/src/ca-ES.ts | 7 ++++--- packages/localizations/src/cs-CZ.ts | 7 ++++--- packages/localizations/src/da-DK.ts | 7 ++++--- packages/localizations/src/de-DE.ts | 7 ++++--- packages/localizations/src/el-GR.ts | 8 +++++--- packages/localizations/src/en-GB.ts | 7 ++++--- packages/localizations/src/es-CR.ts | 7 ++++--- packages/localizations/src/es-ES.ts | 7 ++++--- packages/localizations/src/es-MX.ts | 7 ++++--- packages/localizations/src/es-UY.ts | 7 ++++--- packages/localizations/src/fa-IR.ts | 7 ++++--- packages/localizations/src/fi-FI.ts | 7 ++++--- packages/localizations/src/fr-FR.ts | 7 ++++--- packages/localizations/src/he-IL.ts | 7 ++++--- packages/localizations/src/hi-IN.ts | 7 ++++--- packages/localizations/src/hr-HR.ts | 7 ++++--- packages/localizations/src/hu-HU.ts | 7 ++++--- packages/localizations/src/id-ID.ts | 8 +++++--- packages/localizations/src/is-IS.ts | 7 ++++--- packages/localizations/src/it-IT.ts | 7 ++++--- packages/localizations/src/ja-JP.ts | 7 ++++--- packages/localizations/src/kk-KZ.ts | 7 ++++--- packages/localizations/src/ko-KR.ts | 7 ++++--- packages/localizations/src/mn-MN.ts | 7 ++++--- packages/localizations/src/ms-MY.ts | 7 ++++--- packages/localizations/src/nb-NO.ts | 7 ++++--- packages/localizations/src/nl-BE.ts | 7 ++++--- packages/localizations/src/nl-NL.ts | 7 ++++--- packages/localizations/src/pl-PL.ts | 7 ++++--- packages/localizations/src/pt-BR.ts | 7 ++++--- packages/localizations/src/pt-PT.ts | 7 ++++--- packages/localizations/src/ro-RO.ts | 7 ++++--- packages/localizations/src/ru-RU.ts | 7 ++++--- packages/localizations/src/sk-SK.ts | 7 ++++--- packages/localizations/src/sr-RS.ts | 7 ++++--- packages/localizations/src/sv-SE.ts | 7 ++++--- packages/localizations/src/ta-IN.ts | 8 +++++--- packages/localizations/src/te-IN.ts | 7 ++++--- packages/localizations/src/th-TH.ts | 7 ++++--- packages/localizations/src/tr-TR.ts | 7 ++++--- packages/localizations/src/uk-UA.ts | 7 ++++--- packages/localizations/src/vi-VN.ts | 7 ++++--- packages/localizations/src/zh-CN.ts | 6 +++--- packages/localizations/src/zh-TW.ts | 6 +++--- 48 files changed, 193 insertions(+), 144 deletions(-) diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index c3518f085db..6dac83774ad 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -2146,9 +2146,10 @@ export const arSA: LocalizationResource = { requireUppercase: 'حرف كبير', sentencePrefix: 'كلمة السر يجب أن تحتوي على', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'كلمة المرور غير صحيحة. لديك محاولة واحدة متبقية.', + password_confirmation_attempts_remaining: 'كلمة المرور غير صحيحة. عدد المحاولات المتبقية: {{remainingAttempts}}.', + password_confirmation_session_ended: + 'انتهت جلستك لأنك وصلت إلى الحد الأقصى لمحاولات تأكيد كلمة المرور. سجّل الدخول مرة أخرى للمتابعة.', phone_number_exists: 'هذا الرقم مأخوذ الرجاء أختيار رقم آخر', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts index 4270c749614..3d332e62699 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -2157,9 +2157,10 @@ export const beBY: LocalizationResource = { requireUppercase: 'літару ў верхнім рэгістры', sentencePrefix: 'Ваш пароль павінен утрымліваць', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Няправільны пароль. У вас засталася 1 спроба.', + password_confirmation_attempts_remaining: 'Няправільны пароль. Засталося спроб: {{remainingAttempts}}.', + password_confirmation_session_ended: + 'Ваш сеанс завершаны, бо вы дасягнулі ліміту спроб пацвярджэння пароля. Увайдзіце зноў, каб працягнуць.', phone_number_exists: 'Гэты нумар тэлефона ўжо заняты. Калі ласка, паспрабуйце іншы.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts index f4694b9d66c..38b17de844a 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -2150,9 +2150,10 @@ export const bgBG: LocalizationResource = { requireUppercase: 'главна буква', sentencePrefix: 'Вашата парола трябва да съдържа', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Грешна парола. Остава ви 1 опит.', + password_confirmation_attempts_remaining: 'Грешна парола. Остават ви {{remainingAttempts}} опита.', + password_confirmation_session_ended: + 'Сесията ви приключи, защото достигнахте лимита на опитите за потвърждение на паролата. Влезте отново, за да продължите.', phone_number_exists: 'Този телефонен номер е зает. Моля, опитайте с друг.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/bn-IN.ts b/packages/localizations/src/bn-IN.ts index ef70a944206..249bfbd9383 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -2175,9 +2175,10 @@ export const bnIN: LocalizationResource = { requireUppercase: 'একটি বড় হাতের অক্ষর', sentencePrefix: 'আপনার পাসওয়ার্ডে অবশ্যই থাকতে হবে', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'ভুল পাসওয়ার্ড। আপনার আর ১টি প্রচেষ্টা বাকি আছে।', + password_confirmation_attempts_remaining: 'ভুল পাসওয়ার্ড। আপনার আর {{remainingAttempts}}টি প্রচেষ্টা বাকি আছে।', + password_confirmation_session_ended: + 'পাসওয়ার্ড নিশ্চিতকরণের প্রচেষ্টার সীমায় পৌঁছানোর কারণে আপনার সেশন শেষ হয়েছে। চালিয়ে যেতে আবার সাইন ইন করুন।', phone_number_exists: 'এই ফোন নম্বর ব্যবহৃত হয়েছে। দয়া করে অন্য একটি ব্যবহার করুন।', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ca-ES.ts b/packages/localizations/src/ca-ES.ts index b70a9c8b1c6..f1e4a935fe7 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -2163,9 +2163,10 @@ export const caES: LocalizationResource = { requireUppercase: 'una lletra majúscula', sentencePrefix: 'La teva contrasenya ha de contenir', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Contrasenya incorrecta. Et queda 1 intent.', + password_confirmation_attempts_remaining: 'Contrasenya incorrecta. Et queden {{remainingAttempts}} intents.', + password_confirmation_session_ended: + "La teva sessió ha finalitzat perquè has arribat al límit d'intents de confirmació de la contrasenya. Torna a iniciar sessió per continuar.", phone_number_exists: "Aquest número de telèfon ja està en ús. Si us plau, prova'n un altre.", protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts index af3881ac87d..b96bc73eaa2 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -2162,9 +2162,10 @@ export const csCZ: LocalizationResource = { requireUppercase: 'velké písmeno', sentencePrefix: 'Vaše heslo musí obsahovat', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Nesprávné heslo. Zbývá vám 1 pokus.', + password_confirmation_attempts_remaining: 'Nesprávné heslo. Počet zbývajících pokusů: {{remainingAttempts}}.', + password_confirmation_session_ended: + 'Vaše relace byla ukončena, protože jste dosáhli limitu pokusů o potvrzení hesla. Pro pokračování se znovu přihlaste.', phone_number_exists: 'Toto telefonní číslo se používá. Zkuste prosím jiný.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts index 1bc830a21e3..f098b63dcd3 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -2147,9 +2147,10 @@ export const daDK: LocalizationResource = { requireUppercase: 'et stort bogstav', sentencePrefix: 'Din adgangskode skal indeholde', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Forkert adgangskode. Du har 1 forsøg tilbage.', + password_confirmation_attempts_remaining: 'Forkert adgangskode. Du har {{remainingAttempts}} forsøg tilbage.', + password_confirmation_session_ended: + 'Din session er afsluttet, fordi du har nået grænsen for forsøg på at bekræfte adgangskoden. Log ind igen for at fortsætte.', phone_number_exists: 'Dette telefonnummer er allerede taget. Prøv et andet.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index eb5c3ce54f8..28b8e460914 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -2180,9 +2180,10 @@ export const deDE: LocalizationResource = { requireUppercase: 'einen Großbuchstaben enthalten', sentencePrefix: 'Das Passwort muss', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Falsches Passwort. Sie haben noch 1 Versuch.', + password_confirmation_attempts_remaining: 'Falsches Passwort. Sie haben noch {{remainingAttempts}} Versuche.', + password_confirmation_session_ended: + 'Ihre Sitzung wurde beendet, da Sie die maximale Anzahl an Versuchen zur Passwortbestätigung erreicht haben. Melden Sie sich erneut an, um fortzufahren.', phone_number_exists: 'Diese Telefonnummer ist bereits vergeben. Bitte wählen Sie eine Andere.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts index 269cd2e6041..2b5b7307e41 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -2171,9 +2171,11 @@ export const elGR: LocalizationResource = { requireUppercase: 'ένα κεφαλαίο γράμμα', sentencePrefix: 'Ο κωδικός πρόσβασής σας πρέπει να περιέχει', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Λανθασμένος κωδικός πρόσβασης. Σας απομένει 1 προσπάθεια.', + password_confirmation_attempts_remaining: + 'Λανθασμένος κωδικός πρόσβασης. Σας απομένουν {{remainingAttempts}} προσπάθειες.', + password_confirmation_session_ended: + 'Η συνεδρία σας έληξε επειδή φτάσατε το όριο προσπαθειών επιβεβαίωσης κωδικού πρόσβασης. Συνδεθείτε ξανά για να συνεχίσετε.', phone_number_exists: 'Αυτός ο αριθμός τηλεφώνου χρησιμοποιείται ήδη. Δοκιμάστε έναν άλλο.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/en-GB.ts b/packages/localizations/src/en-GB.ts index 8202dd35794..8fbdc8a1f45 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -2154,9 +2154,10 @@ export const enGB: LocalizationResource = { requireUppercase: 'an uppercase letter', sentencePrefix: 'Your password must contain', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Incorrect password. You have 1 attempt remaining.', + password_confirmation_attempts_remaining: 'Incorrect password. You have {{remainingAttempts}} attempts remaining.', + password_confirmation_session_ended: + 'Your session has ended because you reached the password confirmation attempt limit. Sign in again to continue.', phone_number_exists: 'This phone number is taken. Please try another.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/es-CR.ts b/packages/localizations/src/es-CR.ts index f4c9acbc90c..30292412b05 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -2162,9 +2162,10 @@ export const esCR: LocalizationResource = { requireUppercase: 'al menos una letra mayúscula', sentencePrefix: 'Tu contraseña debe contener', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Contraseña incorrecta. Te queda 1 intento.', + password_confirmation_attempts_remaining: 'Contraseña incorrecta. Te quedan {{remainingAttempts}} intentos.', + password_confirmation_session_ended: + 'Tu sesión finalizó porque alcanzaste el límite de intentos de confirmación de contraseña. Vuelve a iniciar sesión para continuar.', phone_number_exists: 'Este número de telefónico ya está en uso.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts index f563689947b..af7a759d093 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -2163,9 +2163,10 @@ export const esES: LocalizationResource = { requireUppercase: 'Al menos una letra mayúscula', sentencePrefix: 'Tu contraseña debe contener:', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Contraseña incorrecta. Te queda 1 intento.', + password_confirmation_attempts_remaining: 'Contraseña incorrecta. Te quedan {{remainingAttempts}} intentos.', + password_confirmation_session_ended: + 'Tu sesión ha finalizado porque has alcanzado el límite de intentos de confirmación de contraseña. Vuelve a iniciar sesión para continuar.', phone_number_exists: 'Este número de teléfono ya está en uso. Por favor, inténtelo con otro.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts index b6b4f893565..ec3f9e7fbef 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -2163,9 +2163,10 @@ export const esMX: LocalizationResource = { requireUppercase: 'al menos una letra mayúscula', sentencePrefix: 'Tu contraseña debe contener', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Contraseña incorrecta. Te queda 1 intento.', + password_confirmation_attempts_remaining: 'Contraseña incorrecta. Te quedan {{remainingAttempts}} intentos.', + password_confirmation_session_ended: + 'Tu sesión finalizó porque alcanzaste el límite de intentos de confirmación de contraseña. Vuelve a iniciar sesión para continuar.', phone_number_exists: 'Este número de telefónico ya está en uso.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts index 7fc4992fa97..f266ab8b1ec 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -2164,9 +2164,10 @@ export const esUY: LocalizationResource = { requireUppercase: 'una letra mayúscula', sentencePrefix: 'Tu contraseña debe contener', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Contraseña incorrecta. Te queda 1 intento.', + password_confirmation_attempts_remaining: 'Contraseña incorrecta. Te quedan {{remainingAttempts}} intentos.', + password_confirmation_session_ended: + 'Tu sesión finalizó porque alcanzaste el límite de intentos de confirmación de contraseña. Vuelve a iniciar sesión para continuar.', phone_number_exists: 'Este número de teléfono ya está en uso. Por favor, probá con otro.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts index db9dab2dd80..1747a93118b 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -2161,9 +2161,10 @@ export const faIR: LocalizationResource = { requireUppercase: 'یک حرف بزرگ', sentencePrefix: 'رمز عبور شما باید حاوی', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'رمز عبور نادرست است. ۱ تلاش دیگر باقی مانده است.', + password_confirmation_attempts_remaining: 'رمز عبور نادرست است. {{remainingAttempts}} تلاش دیگر باقی مانده است.', + password_confirmation_session_ended: + 'جلسه شما به پایان رسید زیرا به حد مجاز تلاش‌های تأیید رمز عبور رسیدید. برای ادامه دوباره وارد شوید.', phone_number_exists: 'این شماره تلفن قبلاً استفاده شده است.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts index 166bdf8993c..5cbd0fdc638 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -2177,9 +2177,10 @@ export const fiFI: LocalizationResource = { requireUppercase: 'iso kirjain', sentencePrefix: 'Salasanan on sisällettävä', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Väärä salasana. Sinulla on 1 yritys jäljellä.', + password_confirmation_attempts_remaining: 'Väärä salasana. Sinulla on {{remainingAttempts}} yritystä jäljellä.', + password_confirmation_session_ended: + 'Istuntosi päättyi, koska saavutit salasanan vahvistusyritysten rajan. Kirjaudu uudelleen sisään jatkaaksesi.', phone_number_exists: 'Tämä puhelinnumero on jo käytössä. Kokeile toista.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts index 937074b12d6..47c2ea3e894 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -2172,9 +2172,10 @@ export const frFR: LocalizationResource = { requireUppercase: 'une lettre majuscule', sentencePrefix: 'Votre mot de passe doit contenir', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Mot de passe incorrect. Il vous reste 1 tentative.', + password_confirmation_attempts_remaining: 'Mot de passe incorrect. Il vous reste {{remainingAttempts}} tentatives.', + password_confirmation_session_ended: + 'Votre session a pris fin, car vous avez atteint la limite de tentatives de confirmation du mot de passe. Reconnectez-vous pour continuer.', phone_number_exists: 'Ce numéro de téléphone est déjà utilisé. Veuillez essayer un autre.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts index 821b9a55596..9bcdafc486d 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -2138,9 +2138,10 @@ export const heIL: LocalizationResource = { requireUppercase: 'אות גדולה', sentencePrefix: 'הסיסמה שלך חייבת להכיל', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'סיסמה שגויה. נותר לך ניסיון אחד.', + password_confirmation_attempts_remaining: 'סיסמה שגויה. נותרו לך {{remainingAttempts}} ניסיונות.', + password_confirmation_session_ended: + 'הסשן שלך הסתיים מכיוון שהגעת למגבלת ניסיונות אימות הסיסמה. התחבר שוב כדי להמשיך.', phone_number_exists: 'מספר הטלפון הזה כבר בשימוש. אנא נסה מספר אחר.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/hi-IN.ts b/packages/localizations/src/hi-IN.ts index 1a36f4c5124..eaad6dd8be9 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -2176,9 +2176,10 @@ export const hiIN: LocalizationResource = { requireUppercase: 'एक अपरकेस अक्षर', sentencePrefix: 'आपके पासवर्ड में होना चाहिए', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'गलत पासवर्ड। आपके पास 1 प्रयास शेष है।', + password_confirmation_attempts_remaining: 'गलत पासवर्ड। आपके पास {{remainingAttempts}} प्रयास शेष हैं।', + password_confirmation_session_ended: + 'पासवर्ड पुष्टि प्रयासों की सीमा पूरी होने के कारण आपका सत्र समाप्त हो गया है। जारी रखने के लिए फिर से साइन इन करें।', phone_number_exists: 'यह फोन नंबर पहले से लिया गया है। कृपया दूसरा प्रयास करें।', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/hr-HR.ts b/packages/localizations/src/hr-HR.ts index b69e7546c70..6001c5c1207 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -2181,9 +2181,10 @@ export const hrHR: LocalizationResource = { requireUppercase: 'veliko slovo', sentencePrefix: 'Vaša lozinka mora sadržavati', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Neispravna lozinka. Preostao vam je 1 pokušaj.', + password_confirmation_attempts_remaining: 'Neispravna lozinka. Preostalo pokušaja: {{remainingAttempts}}.', + password_confirmation_session_ended: + 'Vaša sesija je završena jer ste dosegnuli ograničenje pokušaja potvrde lozinke. Ponovno se prijavite za nastavak.', phone_number_exists: 'Ovaj telefonski broj je zauzet. Molimo pokušajte s drugim.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts index 24e460f1ffa..938b50ae53b 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -2180,9 +2180,10 @@ export const huHU: LocalizationResource = { requireUppercase: 'egy nagybetű', sentencePrefix: 'A jelszavadnak tartalmaznia kell', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Helytelen jelszó. 1 próbálkozásod maradt.', + password_confirmation_attempts_remaining: 'Helytelen jelszó. {{remainingAttempts}} próbálkozásod maradt.', + password_confirmation_session_ended: + 'A munkameneted véget ért, mert elérted a jelszó-megerősítési kísérletek korlátját. A folytatáshoz jelentkezz be újra.', phone_number_exists: 'Ez a telefonszám már foglalt. Kérlek próbálj meg egy másikat.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/id-ID.ts b/packages/localizations/src/id-ID.ts index 1b6f9620c29..e3fee76584e 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -2160,9 +2160,11 @@ export const idID: LocalizationResource = { requireUppercase: 'huruf besar', sentencePrefix: 'Kata sandi Anda harus mengandung', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Kata sandi salah. Anda memiliki 1 percobaan tersisa.', + password_confirmation_attempts_remaining: + 'Kata sandi salah. Anda memiliki {{remainingAttempts}} percobaan tersisa.', + password_confirmation_session_ended: + 'Sesi Anda telah berakhir karena Anda mencapai batas percobaan konfirmasi kata sandi. Masuk kembali untuk melanjutkan.', phone_number_exists: 'Nomor telepon ini sudah digunakan. Silakan coba yang lain.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts index 8a781387135..e95989ebeaf 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -2178,9 +2178,10 @@ export const isIS: LocalizationResource = { requireUppercase: 'hástaf', sentencePrefix: 'Lykilorðið þitt verður að innihalda', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Rangt lykilorð. Þú átt 1 tilraun eftir.', + password_confirmation_attempts_remaining: 'Rangt lykilorð. Þú átt {{remainingAttempts}} tilraunir eftir.', + password_confirmation_session_ended: + 'Lotunni þinni lauk vegna þess að þú náðir hámarksfjölda tilrauna til að staðfesta lykilorð. Skráðu þig inn aftur til að halda áfram.', phone_number_exists: 'Þetta símanúmer er þegar í notkun. Vinsamlegast reyndu annað.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts index 76d90cab854..ab392b1329b 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -2159,9 +2159,10 @@ export const itIT: LocalizationResource = { requireUppercase: undefined, sentencePrefix: undefined, }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Password errata. Hai ancora 1 tentativo.', + password_confirmation_attempts_remaining: 'Password errata. Hai ancora {{remainingAttempts}} tentativi.', + password_confirmation_session_ended: + 'La sessione è terminata perché hai raggiunto il limite di tentativi di conferma della password. Accedi di nuovo per continuare.', phone_number_exists: 'Questo numero di telefono è già in uso. Per favore, prova con un altro.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts index e29d5eca524..fe6cc09f7d6 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -2174,9 +2174,10 @@ export const jaJP: LocalizationResource = { requireUppercase: '大文字を含む', sentencePrefix: 'パスワードは次の条件を満たす必要があります:', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'パスワードが正しくありません。残り1回試行できます。', + password_confirmation_attempts_remaining: 'パスワードが正しくありません。残り{{remainingAttempts}}回試行できます。', + password_confirmation_session_ended: + 'パスワード確認の試行回数の上限に達したため、セッションが終了しました。続行するには再度サインインしてください。', phone_number_exists: 'この電話番号は既に使用されています。別のものをお試しください。', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/kk-KZ.ts b/packages/localizations/src/kk-KZ.ts index a2e6d572f08..9b256fcf3d1 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -2140,9 +2140,10 @@ export const kkKZ: LocalizationResource = { requireUppercase: 'бас әріп', sentencePrefix: 'Құпия сөзде болуы керек:', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Құпиясөз қате. Сізде 1 әрекет қалды.', + password_confirmation_attempts_remaining: 'Құпиясөз қате. Сізде {{remainingAttempts}} әрекет қалды.', + password_confirmation_session_ended: + 'Құпиясөзді растау әрекеттерінің шегіне жеткендіктен, сеансыңыз аяқталды. Жалғастыру үшін қайта кіріңіз.', phone_number_exists: 'Бұл телефон нөмірі тіркелген. Басқасын қолданыңыз.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts index 9a2ab275f9f..df86e61230e 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -2147,9 +2147,10 @@ export const koKR: LocalizationResource = { requireUppercase: '대문자', sentencePrefix: '비밀번호에는 다음이 꼭 포함돼야 해요', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: '비밀번호가 올바르지 않습니다. 남은 시도 횟수: 1회', + password_confirmation_attempts_remaining: '비밀번호가 올바르지 않습니다. 남은 시도 횟수: {{remainingAttempts}}회', + password_confirmation_session_ended: + '비밀번호 확인 시도 횟수 한도에 도달하여 세션이 종료되었습니다. 계속하려면 다시 로그인하세요.', phone_number_exists: '이 전화번호는 이미 사용 중이에요. 다른 번호를 시도해 주세요.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts index daaebd83f3c..d7323c21936 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -2152,9 +2152,10 @@ export const mnMN: LocalizationResource = { requireUppercase: 'том үсэг', sentencePrefix: 'таны нууц үг агуулсан байх ёстой', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Нууц үг буруу байна. Танд 1 оролдлого үлдлээ.', + password_confirmation_attempts_remaining: 'Нууц үг буруу байна. Танд {{remainingAttempts}} оролдлого үлдлээ.', + password_confirmation_session_ended: + 'Нууц үг баталгаажуулах оролдлогын хязгаарт хүрсэн тул таны сесс дууслаа. Үргэлжлүүлэхийн тулд дахин нэвтэрнэ үү.', phone_number_exists: 'Энэ утасны дугаарыг авсан. Өөр оролдоно уу.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ms-MY.ts b/packages/localizations/src/ms-MY.ts index b66ac1e3ac9..9ac8a48332f 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -2185,9 +2185,10 @@ export const msMY: LocalizationResource = { requireUppercase: 'huruf besar', sentencePrefix: 'Kata laluan anda mesti mengandungi', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Kata laluan salah. Anda mempunyai 1 percubaan lagi.', + password_confirmation_attempts_remaining: 'Kata laluan salah. Anda mempunyai {{remainingAttempts}} percubaan lagi.', + password_confirmation_session_ended: + 'Sesi anda telah tamat kerana anda telah mencapai had percubaan pengesahan kata laluan. Log masuk semula untuk meneruskan.', phone_number_exists: 'Nombor telefon ini telah diambil. Sila cuba yang lain.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts index a5e268fb1bd..ee58dd8d26e 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -2178,9 +2178,10 @@ export const nbNO: LocalizationResource = { requireUppercase: 'en stor bokstav', sentencePrefix: 'Passordet ditt må inneholde', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Feil passord. Du har 1 forsøk igjen.', + password_confirmation_attempts_remaining: 'Feil passord. Du har {{remainingAttempts}} forsøk igjen.', + password_confirmation_session_ended: + 'Økten din er avsluttet fordi du nådde grensen for forsøk på passordbekreftelse. Logg inn på nytt for å fortsette.', phone_number_exists: 'Dette telefonnummeret er allerede i bruk. Vennligst bruk et annet telefonnummer.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/nl-BE.ts b/packages/localizations/src/nl-BE.ts index 459e0f253de..bc64a02add1 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -2151,9 +2151,10 @@ export const nlBE: LocalizationResource = { requireUppercase: 'Wachtwoord moet minstens 1 hoofdletter bevatten.', sentencePrefix: 'Wachtwoord moet minstens 1 speciaal teken bevatten.', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Onjuist wachtwoord. Je hebt nog 1 poging.', + password_confirmation_attempts_remaining: 'Onjuist wachtwoord. Je hebt nog {{remainingAttempts}} pogingen.', + password_confirmation_session_ended: + 'Je sessie is beëindigd omdat je het maximale aantal pogingen voor wachtwoordbevestiging hebt bereikt. Log opnieuw in om door te gaan.', phone_number_exists: 'Dit telefoonnummer is al in gebruik. Probeer een ander nummer.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts index 96885dff0d4..ed56dfa4a64 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -2151,9 +2151,10 @@ export const nlNL: LocalizationResource = { requireUppercase: 'Wachtwoord moet minstens 1 hoofdletter bevatten.', sentencePrefix: 'Wachtwoord moet minstens 1 speciaal teken bevatten.', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Onjuist wachtwoord. Je hebt nog 1 poging.', + password_confirmation_attempts_remaining: 'Onjuist wachtwoord. Je hebt nog {{remainingAttempts}} pogingen.', + password_confirmation_session_ended: + 'Je sessie is beëindigd omdat je het maximale aantal pogingen voor wachtwoordbevestiging hebt bereikt. Log opnieuw in om door te gaan.', phone_number_exists: 'Dit telefoonnummer is al in gebruik. Probeer een ander nummer.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts index 735f8d4fffe..643fc94fcbf 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -2159,9 +2159,10 @@ export const plPL: LocalizationResource = { requireUppercase: 'wielką literę', sentencePrefix: 'Twoje hasło musi zawierać', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Nieprawidłowe hasło. Pozostała 1 próba.', + password_confirmation_attempts_remaining: 'Nieprawidłowe hasło. Pozostałe próby: {{remainingAttempts}}.', + password_confirmation_session_ended: + 'Twoja sesja została zakończona, ponieważ osiągnięto limit prób potwierdzenia hasła. Zaloguj się ponownie, aby kontynuować.', phone_number_exists: 'Numer telefonu jest już zajęty. Proszę spróbować innego.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts index 5dfdb7848ef..9ef745a208d 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -2169,9 +2169,10 @@ export const ptBR: LocalizationResource = { requireUppercase: 'uma letra maiúscula', sentencePrefix: 'Sua senha deve conter', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Senha incorreta. Você tem mais 1 tentativa.', + password_confirmation_attempts_remaining: 'Senha incorreta. Você tem mais {{remainingAttempts}} tentativas.', + password_confirmation_session_ended: + 'Sua sessão foi encerrada porque você atingiu o limite de tentativas de confirmação de senha. Faça login novamente para continuar.', phone_number_exists: 'Este número de telefone já está em uso. Por favor, tente outro.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts index b9930e02ef5..9597fba8fd4 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -2175,9 +2175,10 @@ export const ptPT: LocalizationResource = { requireUppercase: 'uma letra maiúscula', sentencePrefix: 'A sua palavra-passe deve conter', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Palavra-passe incorreta. Tem mais 1 tentativa.', + password_confirmation_attempts_remaining: 'Palavra-passe incorreta. Tem mais {{remainingAttempts}} tentativas.', + password_confirmation_session_ended: + 'A sua sessão terminou porque atingiu o limite de tentativas de confirmação da palavra-passe. Inicie sessão novamente para continuar.', phone_number_exists: 'Este número de telemóvel já está em uso. Por favor, tente outro.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts index c7a086cbec1..c2fa7162b62 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -2170,9 +2170,10 @@ export const roRO: LocalizationResource = { requireUppercase: 'o literă mare', sentencePrefix: 'Parola ta trebuie să conțină', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Parolă incorectă. Mai aveți 1 încercare.', + password_confirmation_attempts_remaining: 'Parolă incorectă. Încercări rămase: {{remainingAttempts}}.', + password_confirmation_session_ended: + 'Sesiunea dvs. s-a încheiat deoarece ați atins limita de încercări de confirmare a parolei. Autentificați-vă din nou pentru a continua.', phone_number_exists: undefined, protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts index fd46cbdbace..4ca946984cb 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -2167,9 +2167,10 @@ export const ruRU: LocalizationResource = { requireUppercase: 'букву в верхнем регистре', sentencePrefix: 'Ваш пароль должен содержать', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Неверный пароль. У вас осталась 1 попытка.', + password_confirmation_attempts_remaining: 'Неверный пароль. Осталось попыток: {{remainingAttempts}}.', + password_confirmation_session_ended: + 'Ваш сеанс завершён, так как вы достигли лимита попыток подтверждения пароля. Войдите снова, чтобы продолжить.', phone_number_exists: 'Этот номер телефона уже занят. Пожалуйста, попробуйте другой.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts index ab2f4d831ea..f3bd06a9847 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -2158,9 +2158,10 @@ export const skSK: LocalizationResource = { requireUppercase: 'veľké písmeno', sentencePrefix: 'Vaše heslo musí obsahovať', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Nesprávne heslo. Zostáva vám 1 pokus.', + password_confirmation_attempts_remaining: 'Nesprávne heslo. Počet zostávajúcich pokusov: {{remainingAttempts}}.', + password_confirmation_session_ended: + 'Vaša relácia bola ukončená, pretože ste dosiahli limit pokusov o potvrdenie hesla. Ak chcete pokračovať, znova sa prihláste.', phone_number_exists: 'Toto telefónne číslo je už obsadené. Skúste prosím iné.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts index 43793387a80..51c01e779d0 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -2150,9 +2150,10 @@ export const srRS: LocalizationResource = { requireUppercase: 'veliko slovo', sentencePrefix: 'Tvoja lozinka mora sadržati', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Pogrešna lozinka. Preostao vam je 1 pokušaj.', + password_confirmation_attempts_remaining: 'Pogrešna lozinka. Preostalo pokušaja: {{remainingAttempts}}.', + password_confirmation_session_ended: + 'Vaša sesija je završena jer ste dostigli ograničenje pokušaja potvrde lozinke. Ponovo se prijavite da biste nastavili.', phone_number_exists: 'Ovaj telefonski broj je zauzet. Molimo pokušaj sa drugim.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts index 4d2cffd86e8..08c715b9040 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -2153,9 +2153,10 @@ export const svSE: LocalizationResource = { requireUppercase: 'Kräver stora bokstäver', sentencePrefix: 'Lösenordet måste innehålla', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Felaktigt lösenord. Du har 1 försök kvar.', + password_confirmation_attempts_remaining: 'Felaktigt lösenord. Du har {{remainingAttempts}} försök kvar.', + password_confirmation_session_ended: + 'Din session har avslutats eftersom du nådde gränsen för försök att bekräfta lösenordet. Logga in igen för att fortsätta.', phone_number_exists: 'Detta telefonnummer är taget. Vänligen prova ett annat.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/ta-IN.ts b/packages/localizations/src/ta-IN.ts index 71ec2e3b0cf..6db7a04f946 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -2186,9 +2186,11 @@ export const taIN: LocalizationResource = { requireUppercase: 'ஒரு பெரிய எழுத்து', sentencePrefix: 'உங்கள் கடவுச்சொல்லில் இருக்க வேண்டும்', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'தவறான கடவுச்சொல். உங்களுக்கு 1 முயற்சி மீதமுள்ளது.', + password_confirmation_attempts_remaining: + 'தவறான கடவுச்சொல். உங்களுக்கு {{remainingAttempts}} முயற்சிகள் மீதமுள்ளன.', + password_confirmation_session_ended: + 'கடவுச்சொல் உறுதிப்படுத்தல் முயற்சிகளின் வரம்பை அடைந்ததால் உங்கள் அமர்வு முடிந்தது. தொடர மீண்டும் உள்நுழையவும்.', phone_number_exists: 'இந்த தொலைபேசி எண் எடுக்கப்பட்டுள்ளது. வேறொன்றை முயற்சிக்கவும்.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/te-IN.ts b/packages/localizations/src/te-IN.ts index a455d7fe049..27ad7bf077a 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -2180,9 +2180,10 @@ export const teIN: LocalizationResource = { requireUppercase: 'ఒక పెద్ద అక్షరం', sentencePrefix: 'మీ పాస్‌వర్డ్‌లో ఉండాలి', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'తప్పు పాస్‌వర్డ్. మీకు 1 ప్రయత్నం మిగిలి ఉంది.', + password_confirmation_attempts_remaining: 'తప్పు పాస్‌వర్డ్. మీకు {{remainingAttempts}} ప్రయత్నాలు మిగిలి ఉన్నాయి.', + password_confirmation_session_ended: + 'పాస్‌వర్డ్ నిర్ధారణ ప్రయత్నాల పరిమితిని చేరుకున్నందున మీ సెషన్ ముగిసింది. కొనసాగించడానికి మళ్లీ సైన్ ఇన్ చేయండి.', phone_number_exists: 'ఈ ఫోన్ నంబర్ తీసుకోబడింది. దయచేసి మరొకదాన్ని ప్రయత్నించండి.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index 709e51ad178..c4d5cdaaa99 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -2148,9 +2148,10 @@ export const thTH: LocalizationResource = { requireUppercase: 'ตัวอักษรพิมพ์ใหญ่', sentencePrefix: 'รหัสผ่านของคุณต้องมี', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'รหัสผ่านไม่ถูกต้อง คุณเหลือโอกาสอีก 1 ครั้ง', + password_confirmation_attempts_remaining: 'รหัสผ่านไม่ถูกต้อง คุณเหลือโอกาสอีก {{remainingAttempts}} ครั้ง', + password_confirmation_session_ended: + 'เซสชันของคุณสิ้นสุดลงเนื่องจากคุณยืนยันรหัสผ่านครบจำนวนครั้งที่กำหนดแล้ว โปรดลงชื่อเข้าใช้อีกครั้งเพื่อดำเนินการต่อ', phone_number_exists: undefined, protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts index b590a3dab3a..61a63b4a0c7 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -2154,9 +2154,10 @@ export const trTR: LocalizationResource = { requireUppercase: 'bir büyük harf içermeli', sentencePrefix: 'Şifreniz;', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Hatalı şifre. 1 deneme hakkınız kaldı.', + password_confirmation_attempts_remaining: 'Hatalı şifre. {{remainingAttempts}} deneme hakkınız kaldı.', + password_confirmation_session_ended: + 'Şifre doğrulama deneme sınırına ulaştığınız için oturumunuz sona erdi. Devam etmek için tekrar giriş yapın.', phone_number_exists: 'Bu telefon numarası zaten kullanılıyor. Lütfen başka bir numara deneyin.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts index 2e335b8ed9f..bd0ba8d32dd 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -2148,9 +2148,10 @@ export const ukUA: LocalizationResource = { requireUppercase: 'букву у верхньому регістрі', sentencePrefix: 'Ваш пароль повинен містити', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Неправильний пароль. У вас залишилася 1 спроба.', + password_confirmation_attempts_remaining: 'Неправильний пароль. Залишилося спроб: {{remainingAttempts}}.', + password_confirmation_session_ended: + 'Ваш сеанс завершено, оскільки ви досягли ліміту спроб підтвердження пароля. Увійдіть знову, щоб продовжити.', phone_number_exists: 'Цей номер телефону вже використовується. Спробуйте інший.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts index 7f18bedf2ad..2db7a3c44f9 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -2171,9 +2171,10 @@ export const viVN: LocalizationResource = { requireUppercase: 'một chữ cái viết hoa', sentencePrefix: 'Mật khẩu của bạn phải chứa', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: 'Mật khẩu không đúng. Bạn còn 1 lần thử.', + password_confirmation_attempts_remaining: 'Mật khẩu không đúng. Bạn còn {{remainingAttempts}} lần thử.', + password_confirmation_session_ended: + 'Phiên của bạn đã kết thúc vì bạn đã đạt đến giới hạn số lần xác nhận mật khẩu. Hãy đăng nhập lại để tiếp tục.', phone_number_exists: undefined, protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts index 63c272ed506..b64eb34f75a 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -2131,9 +2131,9 @@ export const zhCN: LocalizationResource = { requireUppercase: '一个大写字母', sentencePrefix: '您的密码必须包含', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: '密码错误。您还有 1 次尝试机会。', + password_confirmation_attempts_remaining: '密码错误。您还有 {{remainingAttempts}} 次尝试机会。', + password_confirmation_session_ended: '由于密码确认尝试次数已达上限,您的会话已结束。请重新登录以继续。', phone_number_exists: '该电话号码已被使用,请尝试其他号码。', protect_check_aborted: undefined, protect_check_already_resolved: undefined, diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts index ae4bd006ca4..cdc53829599 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -2136,9 +2136,9 @@ export const zhTW: LocalizationResource = { requireUppercase: '一個大寫字母', sentencePrefix: '您的密碼必須包含:', }, - password_confirmation_attempt_remaining: undefined, - password_confirmation_attempts_remaining: undefined, - password_confirmation_session_ended: undefined, + password_confirmation_attempt_remaining: '密碼錯誤。您還有 1 次嘗試機會。', + password_confirmation_attempts_remaining: '密碼錯誤。您還有 {{remainingAttempts}} 次嘗試機會。', + password_confirmation_session_ended: '由於密碼確認嘗試次數已達上限,您的工作階段已結束。請重新登入以繼續。', phone_number_exists: '此電話號碼已被使用,請嘗試其他號碼。', protect_check_aborted: undefined, protect_check_already_resolved: undefined, From 5188de53cc2271856e6c7e92991fb667b6a88074 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 24 Sep 2026 15:25:18 -0400 Subject: [PATCH 05/20] chore(repo): Drop opt-in wording from password confirmation changeset --- .changeset/password-confirmation-attempts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/password-confirmation-attempts.md b/.changeset/password-confirmation-attempts.md index 445fdcc764d..dc973b8baf5 100644 --- a/.changeset/password-confirmation-attempts.md +++ b/.changeset/password-confirmation-attempts.md @@ -4,7 +4,7 @@ '@clerk/ui': minor --- -Show how many password attempts remain when a user enters the wrong current password while changing their password or during reverification, and explain that the session has ended once the attempts run out. This only applies to instances that enable password confirmation protection, which is off by default. +Show how many password attempts remain when a user enters the wrong current password while changing their password or during reverification, and explain that the session has ended once the attempts run out. - `ClerkAPIError.meta.remainingAttempts` holds the attempts left in the current session. `0` means the session has ended. - Customize the messages with the `unstable__errors.password_confirmation_attempt_remaining`, `unstable__errors.password_confirmation_attempts_remaining` (receives `{{remainingAttempts}}`), and `unstable__errors.password_confirmation_session_ended` localization keys. From dda5b3d91e17df7c2e589de8d828eab766202e6e Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 24 Sep 2026 17:49:47 -0400 Subject: [PATCH 06/20] fix(localizations): Clarify Thai password confirmation limit message --- packages/localizations/src/th-TH.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index c4d5cdaaa99..8cf815e1b4b 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -2151,7 +2151,7 @@ export const thTH: LocalizationResource = { password_confirmation_attempt_remaining: 'รหัสผ่านไม่ถูกต้อง คุณเหลือโอกาสอีก 1 ครั้ง', password_confirmation_attempts_remaining: 'รหัสผ่านไม่ถูกต้อง คุณเหลือโอกาสอีก {{remainingAttempts}} ครั้ง', password_confirmation_session_ended: - 'เซสชันของคุณสิ้นสุดลงเนื่องจากคุณยืนยันรหัสผ่านครบจำนวนครั้งที่กำหนดแล้ว โปรดลงชื่อเข้าใช้อีกครั้งเพื่อดำเนินการต่อ', + 'เซสชันของคุณสิ้นสุดลงเนื่องจากคุณพยายามยืนยันรหัสผ่านครบตามจำนวนครั้งที่อนุญาตแล้ว โปรดลงชื่อเข้าใช้อีกครั้งเพื่อดำเนินการต่อ', phone_number_exists: undefined, protect_check_aborted: undefined, protect_check_already_resolved: undefined, From bbf2b01d8607b7f49519755bde91500873afbb29 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 24 Sep 2026 21:11:05 -0400 Subject: [PATCH 07/20] fix(localizations): Use voseo in es-UY password confirmation limit message --- packages/localizations/src/es-UY.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts index f266ab8b1ec..c5d828fb940 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -2167,7 +2167,7 @@ export const esUY: LocalizationResource = { password_confirmation_attempt_remaining: 'Contraseña incorrecta. Te queda 1 intento.', password_confirmation_attempts_remaining: 'Contraseña incorrecta. Te quedan {{remainingAttempts}} intentos.', password_confirmation_session_ended: - 'Tu sesión finalizó porque alcanzaste el límite de intentos de confirmación de contraseña. Vuelve a iniciar sesión para continuar.', + 'Tu sesión finalizó porque alcanzaste el límite de intentos de confirmación de contraseña. Volvé a iniciar sesión para continuar.', phone_number_exists: 'Este número de teléfono ya está en uso. Por favor, probá con otro.', protect_check_aborted: undefined, protect_check_already_resolved: undefined, From df883821464f2e3c3c7a06591278339d23f566e9 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Tue, 29 Sep 2026 17:00:39 -0400 Subject: [PATCH 08/20] Sign out when a password confirmation ends the session --- .changeset/password-confirmation-attempts.md | 3 +- .../clerk-js/src/core/__tests__/clerk.test.ts | 30 +++++++++++++ packages/clerk-js/src/core/clerk.ts | 22 ++++++++-- packages/clerk-js/src/core/resources/Base.ts | 3 ++ .../src/core/resources/__tests__/Base.test.ts | 42 +++++++++++++++++++ 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/.changeset/password-confirmation-attempts.md b/.changeset/password-confirmation-attempts.md index dc973b8baf5..489bad323b7 100644 --- a/.changeset/password-confirmation-attempts.md +++ b/.changeset/password-confirmation-attempts.md @@ -1,4 +1,5 @@ --- +'@clerk/clerk-js': minor '@clerk/localizations': minor '@clerk/shared': minor '@clerk/ui': minor @@ -6,5 +7,5 @@ Show how many password attempts remain when a user enters the wrong current password while changing their password or during reverification, and explain that the session has ended once the attempts run out. -- `ClerkAPIError.meta.remainingAttempts` holds the attempts left in the current session. `0` means the session has ended. +- `ClerkAPIError.meta.remainingAttempts` holds the attempts left in the current session. `0` means the session has ended, and Clerk signs the user out of the current tab and notifies other tabs. - Customize the messages with the `unstable__errors.password_confirmation_attempt_remaining`, `unstable__errors.password_confirmation_attempts_remaining` (receives `{{remainingAttempts}}`), and `unstable__errors.password_confirmation_session_ended` localization keys. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 189f3ab911f..b4216e16c0e 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -4391,6 +4391,36 @@ describe('Clerk singleton', () => { }); }); + describe('__internal_handleSessionEnded', () => { + afterEach(() => { + (window as any).__internal_onAfterSetActive = null; + }); + + it('signs out and notifies other tabs after the server ended the session', async () => { + const mockOnAfterSetActive = vi.fn().mockReturnValue(Promise.resolve()); + (window as any).__internal_onAfterSetActive = mockOnAfterSetActive; + const eventBusSpy = vi.spyOn(eventBus, 'emit'); + const mockSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + lastActiveToken: { getRawString: () => 'token_1' }, + }; + + const sut = new Clerk(productionPublishableKey); + sut.updateClient({ sessions: [mockSession], signedInSessions: [mockSession] } as any); + // The failed confirmation's response carries the client without the ended session. + sut.updateClient({ sessions: [], signedInSessions: [] } as any); + + await sut.__internal_handleSessionEnded(); + + expect(sut.session).toBeNull(); + expect(eventBusSpy).toHaveBeenCalledWith(events.UserSignOut, null); + expect(mockOnAfterSetActive).toHaveBeenCalled(); + eventBusSpy.mockRestore(); + }); + }); + describe('__internal_attemptToEnableEnvironmentSetting', () => { afterEach(() => { mockEnvironmentFetch.mockReset(); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b5ca5d2e97c..1d0cf208440 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -3016,10 +3016,7 @@ export class Clerk implements ClerkInterface { if (this.session) { return; } - if (opts.broadcast) { - eventBus.emit(events.UserSignOut, null); - } - return this.setActive({ session: null }); + return this.#completeSignOut(opts); } catch (err) { // `/client` can fail with either a 401, a 403, 500 or network errors. // 401 is already handled internally in our fetcher. @@ -3033,6 +3030,23 @@ export class Clerk implements ClerkInterface { } }; + /** + * Completes a sign-out that FAPI already performed, such as when a failed password + * confirmation ended the session and the piggybacked client no longer contains it. + * + * @internal + */ + public __internal_handleSessionEnded = (): Promise => { + return this.#completeSignOut({ broadcast: true }); + }; + + #completeSignOut = (opts: { broadcast: boolean }): Promise => { + if (opts.broadcast) { + eventBus.emit(events.UserSignOut, null); + } + return this.setActive({ session: null }); + }; + public __internal_handleUnauthenticatedDevBrowser = async (): Promise => { if (this.#authService) { await this.#authService.handleUnauthenticatedDevBrowser(); diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 4ad63d5d01c..f4827878580 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -157,6 +157,9 @@ export abstract class BaseResource { await BaseResource.clerk.__internal_handleUnauthenticatedDevBrowser(); } else if (status === 401 && code !== 'requires_captcha') { await BaseResource.clerk.handleUnauthenticated(); + } else if (errors?.[0]?.meta?.remaining_attempts === 0) { + // The password confirmation limit ended the session and invalidated this client. + await BaseResource.clerk.__internal_handleSessionEnded(); } assertProductionKeysOnDev(status, errors); diff --git a/packages/clerk-js/src/core/resources/__tests__/Base.test.ts b/packages/clerk-js/src/core/resources/__tests__/Base.test.ts index c67026ffee5..7c40968fb0f 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Base.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Base.test.ts @@ -100,4 +100,46 @@ describe('BaseResource', () => { }), ); }); + it.each([ + [0, 1], + [1, 0], + ])( + 'completes the sign-out when a password confirmation ends the session (remaining attempts: %i)', + async (remainingAttempts, expectedCalls) => { + const updateClient = vi.fn(); + const handleSessionEnded = vi.fn().mockResolvedValue(undefined); + + BaseResource.clerk = { + // @ts-expect-error - We're not about to mock the entire FapiClient + getFapiClient: () => ({ + request: vi.fn().mockResolvedValue({ + payload: { + client: { id: 'client_1', sessions: [] }, + errors: [ + { + code: 'form_password_validation_failed', + message: 'Password is incorrect', + meta: { remaining_attempts: remainingAttempts }, + }, + ], + }, + status: 422, + statusText: 'Unprocessable Entity', + headers: new Headers(), + }), + }), + __internal_setCountry: vi.fn(), + __internal_handleSessionEnded: handleSessionEnded, + updateClient, + } as any; + + const resource = new TestResource(); + await expect(resource.mutate()).rejects.toMatchObject({ + errors: [expect.objectContaining({ meta: expect.objectContaining({ remainingAttempts }) })], + }); + + expect(updateClient).toHaveBeenCalledTimes(1); + expect(handleSessionEnded).toHaveBeenCalledTimes(expectedCalls); + }, + ); }); From 4ed044c319a9e7caea37e3975c0fde0d4ce31307 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Tue, 29 Sep 2026 17:22:09 -0400 Subject: [PATCH 09/20] Only complete the sign-out for 422 password confirmation errors --- packages/clerk-js/src/core/resources/Base.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index f4827878580..6e5baffb446 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -157,7 +157,7 @@ export abstract class BaseResource { await BaseResource.clerk.__internal_handleUnauthenticatedDevBrowser(); } else if (status === 401 && code !== 'requires_captcha') { await BaseResource.clerk.handleUnauthenticated(); - } else if (errors?.[0]?.meta?.remaining_attempts === 0) { + } else if (status === 422 && errors?.[0]?.meta?.remaining_attempts === 0) { // The password confirmation limit ended the session and invalidated this client. await BaseResource.clerk.__internal_handleSessionEnded(); } From 868ce3d2c9f69f4cf1bf7cfebb9155f9b981ad5d Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Tue, 29 Sep 2026 17:23:34 -0400 Subject: [PATCH 10/20] Drop unused ts-expect-error directives and raise the clerk.native.js budget --- packages/clerk-js/src/core/resources/__tests__/Base.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/clerk-js/src/core/resources/__tests__/Base.test.ts b/packages/clerk-js/src/core/resources/__tests__/Base.test.ts index 7c40968fb0f..299116c47c8 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Base.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Base.test.ts @@ -70,7 +70,6 @@ describe('BaseResource', () => { const updateClient = vi.fn(); BaseResource.clerk = { - // @ts-expect-error - We're not about to mock the entire FapiClient getFapiClient: () => ({ request: vi.fn().mockResolvedValue({ payload: { @@ -110,7 +109,6 @@ describe('BaseResource', () => { const handleSessionEnded = vi.fn().mockResolvedValue(undefined); BaseResource.clerk = { - // @ts-expect-error - We're not about to mock the entire FapiClient getFapiClient: () => ({ request: vi.fn().mockResolvedValue({ payload: { From b2e558dba20c13d54e1c64088f28064f83fb3d0d Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Tue, 27 Oct 2026 17:41:29 -0400 Subject: [PATCH 11/20] fix(clerk-js,ui): preserve the password attempt limit explanation after sign-out --- .changeset/password-confirmation-attempts.md | 1 + packages/clerk-js/src/core/resources/Base.ts | 18 ++++- .../src/core/resources/__tests__/Base.test.ts | 19 ++++- packages/clerk-js/src/utils/localStorage.ts | 69 +------------------ .../src/internal/clerk-js/localStorage.ts | 69 +++++++++++++++++++ .../ui/src/components/SignIn/SignInStart.tsx | 13 ++++ .../SignInPasswordConfirmation.test.tsx | 62 +++++++++++++++++ .../ui/src/localization/makeLocalizable.tsx | 7 ++ 8 files changed, 186 insertions(+), 72 deletions(-) create mode 100644 packages/shared/src/internal/clerk-js/localStorage.ts create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx diff --git a/.changeset/password-confirmation-attempts.md b/.changeset/password-confirmation-attempts.md index 489bad323b7..d040dcb7fdd 100644 --- a/.changeset/password-confirmation-attempts.md +++ b/.changeset/password-confirmation-attempts.md @@ -9,3 +9,4 @@ Show how many password attempts remain when a user enters the wrong current pass - `ClerkAPIError.meta.remainingAttempts` holds the attempts left in the current session. `0` means the session has ended, and Clerk signs the user out of the current tab and notifies other tabs. - Customize the messages with the `unstable__errors.password_confirmation_attempt_remaining`, `unstable__errors.password_confirmation_attempts_remaining` (receives `{{remainingAttempts}}`), and `unstable__errors.password_confirmation_session_ended` localization keys. +- After the attempt limit signs a user out, the next prebuilt sign-in form explains why their session ended, including after a page reload. diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 6e5baffb446..d89841f75c7 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -1,5 +1,9 @@ import { isValidBrowserOnline } from '@clerk/shared/browser'; import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error'; +import { + PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY, + SafeLocalStorage, +} from '@clerk/shared/internal/clerk-js/localStorage'; import { isProductionFromPublishableKey } from '@clerk/shared/keys'; import type { ClerkAPIErrorJSON, @@ -128,6 +132,17 @@ export abstract class BaseResource { } const { payload, status, statusText, headers } = fapiResponse; + const errors = payload?.errors ?? []; + const sessionEnded = status === 422 && errors?.[0]?.meta?.remaining_attempts === 0; + + if (sessionEnded) { + const error = new ClerkAPIResponseError(statusText, { data: errors, status }).errors[0]; + SafeLocalStorage.setItem( + `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${this.clerk.publishableKey}`, + error, + 5 * 60 * 1000, + ); + } if (headers) { const country = headers.get('x-country'); @@ -145,7 +160,6 @@ export abstract class BaseResource { } if (status >= 400) { - const errors = payload?.errors as ClerkAPIErrorJSON[]; const message = errors?.[0]?.long_message; const code = errors?.[0]?.code; @@ -157,7 +171,7 @@ export abstract class BaseResource { await BaseResource.clerk.__internal_handleUnauthenticatedDevBrowser(); } else if (status === 401 && code !== 'requires_captcha') { await BaseResource.clerk.handleUnauthenticated(); - } else if (status === 422 && errors?.[0]?.meta?.remaining_attempts === 0) { + } else if (sessionEnded) { // The password confirmation limit ended the session and invalidated this client. await BaseResource.clerk.__internal_handleSessionEnded(); } diff --git a/packages/clerk-js/src/core/resources/__tests__/Base.test.ts b/packages/clerk-js/src/core/resources/__tests__/Base.test.ts index 299116c47c8..79d7247a88e 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Base.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Base.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it, vi } from 'vitest'; +import { + PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY, + SafeLocalStorage, +} from '@clerk/shared/internal/clerk-js/localStorage'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BaseResource } from '../internal'; @@ -21,6 +25,9 @@ class TestResource extends BaseResource { } describe('BaseResource', () => { + beforeEach(() => { + window.localStorage.clear(); + }); it('populates retryAfter on 429 error responses', async () => { BaseResource.clerk = { // @ts-expect-error - We're not about to mock the entire FapiClient @@ -105,10 +112,18 @@ describe('BaseResource', () => { ])( 'completes the sign-out when a password confirmation ends the session (remaining attempts: %i)', async (remainingAttempts, expectedCalls) => { - const updateClient = vi.fn(); + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:pk_test_confirmation`; + const updateClient = vi.fn(() => { + expect(SafeLocalStorage.getItem(key, null)).toEqual( + remainingAttempts === 0 + ? expect.objectContaining({ meta: expect.objectContaining({ remainingAttempts: 0 }) }) + : null, + ); + }); const handleSessionEnded = vi.fn().mockResolvedValue(undefined); BaseResource.clerk = { + publishableKey: 'pk_test_confirmation', getFapiClient: () => ({ request: vi.fn().mockResolvedValue({ payload: { diff --git a/packages/clerk-js/src/utils/localStorage.ts b/packages/clerk-js/src/utils/localStorage.ts index e9298c245c4..95be150e266 100644 --- a/packages/clerk-js/src/utils/localStorage.ts +++ b/packages/clerk-js/src/utils/localStorage.ts @@ -1,68 +1 @@ -const CLERK_PREFIX = '__clerk_'; - -export const CLERK_ENVIRONMENT_STORAGE_ENTRY = 'environment'; - -interface StorageEntry { - value: T; - exp?: number; -} - -const serialize = JSON.stringify; -const parse = JSON.parse; - -/** - * Safe wrapper around localStorage that automatically prefixes keys with 'clerk_' - * and handles potential errors and entry expiration - */ -export class SafeLocalStorage { - private static _key(key: string): string { - return `${CLERK_PREFIX}${key}`; - } - - private static isExpired(entry: StorageEntry): boolean { - return !!entry.exp && Date.now() > entry.exp; - } - - static setItem(key: string, value: unknown, expiresInMs?: number): void { - try { - const entry: StorageEntry = { - value, - ...(expiresInMs && { exp: Date.now() + expiresInMs }), - }; - window.localStorage.setItem(this._key(key), serialize(entry)); - } catch { - // noop - } - } - - static getItem(key: string, defaultValue: T): T { - try { - const item = window.localStorage.getItem(this._key(key)); - if (!item) { - return defaultValue; - } - const entry = parse(item) as unknown as StorageEntry | undefined | null; - - if (!entry) { - return defaultValue; - } - - if (this.isExpired(entry)) { - this.removeItem(key); - return defaultValue; - } - - return entry?.value ?? defaultValue; - } catch { - return defaultValue; - } - } - - static removeItem(key: string): void { - try { - window.localStorage.removeItem(this._key(key)); - } catch { - // noop - } - } -} +export { CLERK_ENVIRONMENT_STORAGE_ENTRY, SafeLocalStorage } from '@clerk/shared/internal/clerk-js/localStorage'; diff --git a/packages/shared/src/internal/clerk-js/localStorage.ts b/packages/shared/src/internal/clerk-js/localStorage.ts new file mode 100644 index 00000000000..9fd8cf746e6 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/localStorage.ts @@ -0,0 +1,69 @@ +const CLERK_PREFIX = '__clerk_'; + +export const CLERK_ENVIRONMENT_STORAGE_ENTRY = 'environment'; +export const PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY = 'password_confirmation_error'; + +interface StorageEntry { + value: T; + exp?: number; +} + +const serialize = JSON.stringify; +const parse = JSON.parse; + +/** + * Safe wrapper around localStorage that automatically prefixes keys with 'clerk_' + * and handles potential errors and entry expiration + */ +export class SafeLocalStorage { + private static _key(key: string): string { + return `${CLERK_PREFIX}${key}`; + } + + private static isExpired(entry: StorageEntry): boolean { + return !!entry.exp && Date.now() > entry.exp; + } + + static setItem(key: string, value: unknown, expiresInMs?: number): void { + try { + const entry: StorageEntry = { + value, + ...(expiresInMs && { exp: Date.now() + expiresInMs }), + }; + window.localStorage.setItem(this._key(key), serialize(entry)); + } catch { + // noop + } + } + + static getItem(key: string, defaultValue: T): T { + try { + const item = window.localStorage.getItem(this._key(key)); + if (!item) { + return defaultValue; + } + const entry = parse(item) as unknown as StorageEntry | undefined | null; + + if (!entry) { + return defaultValue; + } + + if (this.isExpired(entry)) { + this.removeItem(key); + return defaultValue; + } + + return entry?.value ?? defaultValue; + } catch { + return defaultValue; + } + } + + static removeItem(key: string): void { + try { + window.localStorage.removeItem(this._key(key)); + } catch { + // noop + } + } +} diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index d3e9d806bcb..f9635010f69 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -2,6 +2,10 @@ import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePh import { inertProps } from '@clerk/shared/inert'; import { ERROR_CODES, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants'; import { clerkInvalidFAPIResponse } from '@clerk/shared/internal/clerk-js/errors'; +import { + PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY, + SafeLocalStorage, +} from '@clerk/shared/internal/clerk-js/localStorage'; import { getClerkQueryParam, removeClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams'; import { useClerk } from '@clerk/shared/react'; import type { @@ -90,7 +94,16 @@ const useAutoFillPasskey = () => { function SignInStartInternal(): JSX.Element { const card = useCardState(); + const { setError } = card; const clerk = useClerk(); + useEffect(() => { + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${clerk.publishableKey}`; + const error = SafeLocalStorage.getItem(key, null); + if (error) { + SafeLocalStorage.removeItem(key); + setError(error); + } + }, [clerk.publishableKey, setError]); const status = useLoadingStatus(); const { userSettings, authConfig } = useEnvironment(); const signIn = useCoreSignIn(); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx new file mode 100644 index 00000000000..f848992560d --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx @@ -0,0 +1,62 @@ +import { + PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY, + SafeLocalStorage, +} from '@clerk/shared/internal/clerk-js/localStorage'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { SignInStart } from '../SignInStart'; + +const { createFixtures } = bindCreateFixtures('SignIn'); +const terminalMessage = + 'Your session has ended because you reached the password confirmation attempt limit. Sign in again to continue.'; + +describe('sign-in after the password confirmation limit', () => { + beforeEach(() => window.localStorage.clear()); + afterEach(() => vi.restoreAllMocks()); + + it.each(['form_password_incorrect', 'form_password_validation_failed'])( + 'shows a persisted %s reason once on a fresh sign-in mount and honors custom translations', + async code => { + const { wrapper, fixtures } = await createFixtures(f => f.withEmailAddress()); + fixtures.options.localization = { + unstable__errors: { password_confirmation_session_ended: 'Too many attempts. Please sign in again.' }, + }; + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${fixtures.clerk.publishableKey}`; + SafeLocalStorage.setItem( + key, + { + code, + message: 'Password is incorrect', + meta: { remainingAttempts: 0 }, + }, + 300_000, + ); + + const { unmount } = render(, { wrapper }); + await screen.findByText('Too many attempts. Please sign in again.'); + unmount(); + render(, { wrapper }); + expect(screen.queryByText('Too many attempts. Please sign in again.')).not.toBeInTheDocument(); + }, + ); + + it("leaves another instance's reason untouched", async () => { + const { wrapper } = await createFixtures(f => f.withEmailAddress()); + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:another_instance`; + SafeLocalStorage.setItem(key, { code: 'form_password_incorrect', meta: { remainingAttempts: 0 } }, 300_000); + render(, { wrapper }); + expect(screen.queryByText(terminalMessage)).not.toBeInTheDocument(); + expect(SafeLocalStorage.getItem(key, null)).not.toBeNull(); + }); + + it('ignores an expired reason', async () => { + const { wrapper, fixtures } = await createFixtures(f => f.withEmailAddress()); + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${fixtures.clerk.publishableKey}`; + SafeLocalStorage.setItem(key, { code: 'form_password_incorrect', meta: { remainingAttempts: 0 } }, -1); + render(, { wrapper }); + expect(screen.queryByText(terminalMessage)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/localization/makeLocalizable.tsx b/packages/ui/src/localization/makeLocalizable.tsx index 371f8f53351..c331fc64be9 100644 --- a/packages/ui/src/localization/makeLocalizable.tsx +++ b/packages/ui/src/localization/makeLocalizable.tsx @@ -99,6 +99,13 @@ export const useLocalizations = () => { return ''; } + if ( + (code === 'form_password_validation_failed' || code === 'form_password_incorrect') && + meta?.remainingAttempts === 0 + ) { + return t(localizationKeys('unstable__errors.password_confirmation_session_ended')); + } + return ( t(localizationKeys(`unstable__errors.${code}__${paramName}` as any)) || t(localizationKeys(`unstable__errors.${code}` as any)) || From 6786473de7514de959c13bc67903cf8c5dd8cbc7 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Tue, 27 Oct 2026 18:32:19 -0400 Subject: [PATCH 12/20] chore(ui): account for password confirmation messages in bundle budget --- packages/ui/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 3bfffc77315..5996d76567e 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -5,7 +5,7 @@ { "path": "./dist/ui.shared.browser.js", "maxSize": "44KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "139KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "140KB" }, { "path": "./dist/signin*.js", "maxSize": "19KB" }, { "path": "./dist/signup*.js", "maxSize": "13KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, From bb40537b3ba4ab114ac9a3e7fcec9519d690040f Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Tue, 27 Oct 2026 18:33:25 -0400 Subject: [PATCH 13/20] refactor(clerk-js): import shared local storage directly --- packages/clerk-js/src/core/clerk.ts | 2 +- packages/clerk-js/src/utils/__tests__/localStorage.test.ts | 3 +-- packages/clerk-js/src/utils/localStorage.ts | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) delete mode 100644 packages/clerk-js/src/utils/localStorage.ts diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 1d0cf208440..161e41cd072 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -31,6 +31,7 @@ import { ERROR_CODES, } from '@clerk/shared/internal/clerk-js/constants'; import { hasMultipleEnterpriseConnections } from '@clerk/shared/internal/clerk-js/enterpriseSSOFactors'; +import { CLERK_ENVIRONMENT_STORAGE_ENTRY, SafeLocalStorage } from '@clerk/shared/internal/clerk-js/localStorage'; import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls'; import { getTaskEndpoint, @@ -174,7 +175,6 @@ import { stripOrigin, web3, } from '../utils'; -import { CLERK_ENVIRONMENT_STORAGE_ENTRY, SafeLocalStorage } from '../utils/localStorage'; import { memoizeListenerCallback } from '../utils/memoizeStateListenerCallback'; import { AuthCookieService } from './auth/AuthCookieService'; import { CaptchaHeartbeat } from './auth/CaptchaHeartbeat'; diff --git a/packages/clerk-js/src/utils/__tests__/localStorage.test.ts b/packages/clerk-js/src/utils/__tests__/localStorage.test.ts index 64ad8dd0e2f..b5b6469fdaa 100644 --- a/packages/clerk-js/src/utils/__tests__/localStorage.test.ts +++ b/packages/clerk-js/src/utils/__tests__/localStorage.test.ts @@ -1,7 +1,6 @@ +import { SafeLocalStorage } from '@clerk/shared/internal/clerk-js/localStorage'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SafeLocalStorage } from '../localStorage'; - describe('SafeLocalStorage', () => { let mockStorage: { [key: string]: string } = {}; diff --git a/packages/clerk-js/src/utils/localStorage.ts b/packages/clerk-js/src/utils/localStorage.ts deleted file mode 100644 index 95be150e266..00000000000 --- a/packages/clerk-js/src/utils/localStorage.ts +++ /dev/null @@ -1 +0,0 @@ -export { CLERK_ENVIRONMENT_STORAGE_ENTRY, SafeLocalStorage } from '@clerk/shared/internal/clerk-js/localStorage'; From 72f3f83d658563aeae0512330abf36d988b2800d Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Tue, 27 Oct 2026 18:34:55 -0400 Subject: [PATCH 14/20] test(shared): colocate local storage coverage with implementation --- .../src/internal/clerk-js}/__tests__/localStorage.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename packages/{clerk-js/src/utils => shared/src/internal/clerk-js}/__tests__/localStorage.test.ts (98%) diff --git a/packages/clerk-js/src/utils/__tests__/localStorage.test.ts b/packages/shared/src/internal/clerk-js/__tests__/localStorage.test.ts similarity index 98% rename from packages/clerk-js/src/utils/__tests__/localStorage.test.ts rename to packages/shared/src/internal/clerk-js/__tests__/localStorage.test.ts index b5b6469fdaa..64ad8dd0e2f 100644 --- a/packages/clerk-js/src/utils/__tests__/localStorage.test.ts +++ b/packages/shared/src/internal/clerk-js/__tests__/localStorage.test.ts @@ -1,6 +1,7 @@ -import { SafeLocalStorage } from '@clerk/shared/internal/clerk-js/localStorage'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SafeLocalStorage } from '../localStorage'; + describe('SafeLocalStorage', () => { let mockStorage: { [key: string]: string } = {}; From dcf6031de4133a24e4df048d285425746e24709c Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 28 Oct 2026 15:11:59 -0400 Subject: [PATCH 15/20] chore: fix native bundle budget and UserButton test timing --- packages/clerk-js/bundlewatch.config.json | 2 +- .../features/user-button/__tests__/user-button.feature.test.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 076e4eca625..88e61eca714 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "83KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "126KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "324KB" }, - { "path": "./dist/clerk.native.js", "maxSize": "82.5KB" }, + { "path": "./dist/clerk.native.js", "maxSize": "83KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, { "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" }, diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index 4ebb5a240dd..3986ee446f2 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -810,6 +810,7 @@ describe('UserButton', () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const menu = await openAccounts(user); await user.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })); + await waitFor(() => expect(touch.requests).toHaveLength(1)); expect(waiting()).toBe(true); await user.click(trigger()); From 63cea578ea1ff1f62bc95eb63ae5b67d23d57d82 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 29 Oct 2026 09:43:36 -0400 Subject: [PATCH 16/20] refactor: centralize password confirmation session-end handling --- .../clerk-js/src/core/__tests__/clerk.test.ts | 24 +++++++++-- packages/clerk-js/src/core/clerk.ts | 16 ++++++- packages/clerk-js/src/core/resources/Base.ts | 30 ++++--------- .../src/core/resources/__tests__/Base.test.ts | 31 ++++++------- .../internal/clerk-js/passwordConfirmation.ts | 2 + .../ui/src/components/SignIn/SignInStart.tsx | 11 ++--- .../SignInPasswordConfirmation.test.tsx | 43 +++++++------------ .../ui/src/localization/makeLocalizable.tsx | 7 --- .../utils/__tests__/passwordUtils.test.tsx | 6 +++ packages/ui/src/utils/passwordUtils.ts | 3 +- 10 files changed, 88 insertions(+), 85 deletions(-) create mode 100644 packages/shared/src/internal/clerk-js/passwordConfirmation.ts diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index b4216e16c0e..6193e23f274 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -5,6 +5,10 @@ import { EmailLinkErrorCodeStatus, } from '@clerk/shared/error'; import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; +import { + PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY, + SafeLocalStorage, +} from '@clerk/shared/internal/clerk-js/localStorage'; import type { ActiveSessionResource, PendingSessionResource, @@ -4409,10 +4413,22 @@ describe('Clerk singleton', () => { const sut = new Clerk(productionPublishableKey); sut.updateClient({ sessions: [mockSession], signedInSessions: [mockSession] } as any); - // The failed confirmation's response carries the client without the ended session. - sut.updateClient({ sessions: [], signedInSessions: [] } as any); - - await sut.__internal_handleSessionEnded(); + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${sut.publishableKey}`; + const updateClient = sut.updateClient; + const updateClientSpy = vi.spyOn(sut, 'updateClient').mockImplementation(client => { + expect(SafeLocalStorage.getItem(key, false)).toBe(true); + updateClient(client); + }); + + await sut.__internal_handleSessionEnded({ sessions: [], signedInSessions: [] } as any); + + expect(updateClientSpy).toHaveBeenCalledTimes(1); + expect(SafeLocalStorage.getItem(key, false)).toBe(true); + const now = Date.now(); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now + 5 * 60 * 1000 + 1); + expect(SafeLocalStorage.getItem(key, false)).toBe(false); + nowSpy.mockRestore(); + updateClientSpy.mockRestore(); expect(sut.session).toBeNull(); expect(eventBusSpy).toHaveBeenCalledWith(events.UserSignOut, null); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 161e41cd072..9000dcecc0d 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -31,7 +31,11 @@ import { ERROR_CODES, } from '@clerk/shared/internal/clerk-js/constants'; import { hasMultipleEnterpriseConnections } from '@clerk/shared/internal/clerk-js/enterpriseSSOFactors'; -import { CLERK_ENVIRONMENT_STORAGE_ENTRY, SafeLocalStorage } from '@clerk/shared/internal/clerk-js/localStorage'; +import { + CLERK_ENVIRONMENT_STORAGE_ENTRY, + PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY, + SafeLocalStorage, +} from '@clerk/shared/internal/clerk-js/localStorage'; import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls'; import { getTaskEndpoint, @@ -3036,7 +3040,15 @@ export class Clerk implements ClerkInterface { * * @internal */ - public __internal_handleSessionEnded = (): Promise => { + public __internal_handleSessionEnded = (client?: ClientResource): Promise => { + SafeLocalStorage.setItem( + `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${this.publishableKey}`, + true, + 5 * 60 * 1000, + ); + if (client) { + this.updateClient(client); + } return this.#completeSignOut({ broadcast: true }); }; diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index d89841f75c7..2c03e1fec71 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -1,9 +1,6 @@ import { isValidBrowserOnline } from '@clerk/shared/browser'; import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error'; -import { - PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY, - SafeLocalStorage, -} from '@clerk/shared/internal/clerk-js/localStorage'; +import { isPasswordConfirmationError } from '@clerk/shared/internal/clerk-js/passwordConfirmation'; import { isProductionFromPublishableKey } from '@clerk/shared/keys'; import type { ClerkAPIErrorJSON, @@ -133,17 +130,6 @@ export abstract class BaseResource { const { payload, status, statusText, headers } = fapiResponse; const errors = payload?.errors ?? []; - const sessionEnded = status === 422 && errors?.[0]?.meta?.remaining_attempts === 0; - - if (sessionEnded) { - const error = new ClerkAPIResponseError(statusText, { data: errors, status }).errors[0]; - SafeLocalStorage.setItem( - `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${this.clerk.publishableKey}`, - error, - 5 * 60 * 1000, - ); - } - if (headers) { const country = headers.get('x-country'); @@ -151,7 +137,12 @@ export abstract class BaseResource { } // TODO: Link to Client payload piggybacking design document - if ((requestInit.method !== 'GET' || opts.forceUpdateClient) && !opts.skipUpdateClient) { + const shouldUpdateClient = (requestInit.method !== 'GET' || opts.forceUpdateClient) && !opts.skipUpdateClient; + if (status === 422 && isPasswordConfirmationError(errors[0]) && errors[0]?.meta?.remaining_attempts === 0) { + await BaseResource.clerk.__internal_handleSessionEnded( + shouldUpdateClient ? getClientResourceFromPayload(payload) : undefined, + ); + } else if (shouldUpdateClient) { this._updateClient(payload); } @@ -160,8 +151,8 @@ export abstract class BaseResource { } if (status >= 400) { - const message = errors?.[0]?.long_message; - const code = errors?.[0]?.code; + const message = errors[0]?.long_message; + const code = errors[0]?.code; // Handle 401 errors based on the specific error code: // - dev_browser_unauthenticated: reset the dev browser token @@ -171,9 +162,6 @@ export abstract class BaseResource { await BaseResource.clerk.__internal_handleUnauthenticatedDevBrowser(); } else if (status === 401 && code !== 'requires_captcha') { await BaseResource.clerk.handleUnauthenticated(); - } else if (sessionEnded) { - // The password confirmation limit ended the session and invalidated this client. - await BaseResource.clerk.__internal_handleSessionEnded(); } assertProductionKeysOnDev(status, errors); diff --git a/packages/clerk-js/src/core/resources/__tests__/Base.test.ts b/packages/clerk-js/src/core/resources/__tests__/Base.test.ts index 79d7247a88e..4bba4f804ba 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Base.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Base.test.ts @@ -1,7 +1,3 @@ -import { - PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY, - SafeLocalStorage, -} from '@clerk/shared/internal/clerk-js/localStorage'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BaseResource } from '../internal'; @@ -107,19 +103,15 @@ describe('BaseResource', () => { ); }); it.each([ - [0, 1], - [1, 0], + [0, 'form_password_validation_failed', 422, 1], + [0, 'form_password_incorrect', 422, 1], + [1, 'form_password_validation_failed', 422, 0], + [0, 'unrelated_error', 422, 0], + [0, 'form_password_incorrect', 409, 0], ])( 'completes the sign-out when a password confirmation ends the session (remaining attempts: %i)', - async (remainingAttempts, expectedCalls) => { - const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:pk_test_confirmation`; - const updateClient = vi.fn(() => { - expect(SafeLocalStorage.getItem(key, null)).toEqual( - remainingAttempts === 0 - ? expect.objectContaining({ meta: expect.objectContaining({ remainingAttempts: 0 }) }) - : null, - ); - }); + async (remainingAttempts, code, status, expectedCalls) => { + const updateClient = vi.fn(); const handleSessionEnded = vi.fn().mockResolvedValue(undefined); BaseResource.clerk = { @@ -130,13 +122,13 @@ describe('BaseResource', () => { client: { id: 'client_1', sessions: [] }, errors: [ { - code: 'form_password_validation_failed', + code, message: 'Password is incorrect', meta: { remaining_attempts: remainingAttempts }, }, ], }, - status: 422, + status, statusText: 'Unprocessable Entity', headers: new Headers(), }), @@ -151,7 +143,10 @@ describe('BaseResource', () => { errors: [expect.objectContaining({ meta: expect.objectContaining({ remainingAttempts }) })], }); - expect(updateClient).toHaveBeenCalledTimes(1); + expect(updateClient).toHaveBeenCalledTimes(expectedCalls ? 0 : 1); + if (expectedCalls) { + expect(handleSessionEnded).toHaveBeenCalledWith(expect.objectContaining({ id: 'client_1' })); + } expect(handleSessionEnded).toHaveBeenCalledTimes(expectedCalls); }, ); diff --git a/packages/shared/src/internal/clerk-js/passwordConfirmation.ts b/packages/shared/src/internal/clerk-js/passwordConfirmation.ts new file mode 100644 index 00000000000..0f30d2ed83e --- /dev/null +++ b/packages/shared/src/internal/clerk-js/passwordConfirmation.ts @@ -0,0 +1,2 @@ +export const isPasswordConfirmationError = (error: { code: string } | undefined): boolean => + error?.code === 'form_password_incorrect' || error?.code === 'form_password_validation_failed'; diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index f9635010f69..5410c2f661d 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -39,7 +39,7 @@ import { withRedirectToSignInTask, } from '../../common'; import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; -import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; +import { Col, descriptors, Flow, localizationKeys, useLocalizations } from '../../customizables'; import { CaptchaElement } from '../../elements/CaptchaElement'; import { useIsWebAuthnSupported, useLoadingStatus } from '../../hooks'; import { useSupportEmail } from '../../hooks/useSupportEmail'; @@ -95,15 +95,16 @@ const useAutoFillPasskey = () => { function SignInStartInternal(): JSX.Element { const card = useCardState(); const { setError } = card; + const { t } = useLocalizations(); const clerk = useClerk(); useEffect(() => { const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${clerk.publishableKey}`; - const error = SafeLocalStorage.getItem(key, null); - if (error) { + const sessionEnded = SafeLocalStorage.getItem(key, false); + if (sessionEnded) { SafeLocalStorage.removeItem(key); - setError(error); + setError(t(localizationKeys('unstable__errors.password_confirmation_session_ended'))); } - }, [clerk.publishableKey, setError]); + }, [clerk.publishableKey, setError, t]); const status = useLoadingStatus(); const { userSettings, authConfig } = useEnvironment(); const signIn = useCoreSignIn(); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx index f848992560d..19d91f1522a 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx @@ -17,36 +17,25 @@ describe('sign-in after the password confirmation limit', () => { beforeEach(() => window.localStorage.clear()); afterEach(() => vi.restoreAllMocks()); - it.each(['form_password_incorrect', 'form_password_validation_failed'])( - 'shows a persisted %s reason once on a fresh sign-in mount and honors custom translations', - async code => { - const { wrapper, fixtures } = await createFixtures(f => f.withEmailAddress()); - fixtures.options.localization = { - unstable__errors: { password_confirmation_session_ended: 'Too many attempts. Please sign in again.' }, - }; - const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${fixtures.clerk.publishableKey}`; - SafeLocalStorage.setItem( - key, - { - code, - message: 'Password is incorrect', - meta: { remainingAttempts: 0 }, - }, - 300_000, - ); - - const { unmount } = render(, { wrapper }); - await screen.findByText('Too many attempts. Please sign in again.'); - unmount(); - render(, { wrapper }); - expect(screen.queryByText('Too many attempts. Please sign in again.')).not.toBeInTheDocument(); - }, - ); + it('shows the persisted reason once on a fresh sign-in mount and honors custom translations', async () => { + const { wrapper, fixtures } = await createFixtures(f => f.withEmailAddress()); + fixtures.options.localization = { + unstable__errors: { password_confirmation_session_ended: 'Too many attempts. Please sign in again.' }, + }; + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${fixtures.clerk.publishableKey}`; + SafeLocalStorage.setItem(key, true, 300_000); + + const { unmount } = render(, { wrapper }); + await screen.findByText('Too many attempts. Please sign in again.'); + unmount(); + render(, { wrapper }); + expect(screen.queryByText('Too many attempts. Please sign in again.')).not.toBeInTheDocument(); + }); it("leaves another instance's reason untouched", async () => { const { wrapper } = await createFixtures(f => f.withEmailAddress()); const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:another_instance`; - SafeLocalStorage.setItem(key, { code: 'form_password_incorrect', meta: { remainingAttempts: 0 } }, 300_000); + SafeLocalStorage.setItem(key, true, 300_000); render(, { wrapper }); expect(screen.queryByText(terminalMessage)).not.toBeInTheDocument(); expect(SafeLocalStorage.getItem(key, null)).not.toBeNull(); @@ -55,7 +44,7 @@ describe('sign-in after the password confirmation limit', () => { it('ignores an expired reason', async () => { const { wrapper, fixtures } = await createFixtures(f => f.withEmailAddress()); const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${fixtures.clerk.publishableKey}`; - SafeLocalStorage.setItem(key, { code: 'form_password_incorrect', meta: { remainingAttempts: 0 } }, -1); + SafeLocalStorage.setItem(key, true, -1); render(, { wrapper }); expect(screen.queryByText(terminalMessage)).not.toBeInTheDocument(); }); diff --git a/packages/ui/src/localization/makeLocalizable.tsx b/packages/ui/src/localization/makeLocalizable.tsx index c331fc64be9..371f8f53351 100644 --- a/packages/ui/src/localization/makeLocalizable.tsx +++ b/packages/ui/src/localization/makeLocalizable.tsx @@ -99,13 +99,6 @@ export const useLocalizations = () => { return ''; } - if ( - (code === 'form_password_validation_failed' || code === 'form_password_incorrect') && - meta?.remainingAttempts === 0 - ) { - return t(localizationKeys('unstable__errors.password_confirmation_session_ended')); - } - return ( t(localizationKeys(`unstable__errors.${code}__${paramName}` as any)) || t(localizationKeys(`unstable__errors.${code}` as any)) || diff --git a/packages/ui/src/utils/__tests__/passwordUtils.test.tsx b/packages/ui/src/utils/__tests__/passwordUtils.test.tsx index 5d6b443e4b0..b2180b779d3 100644 --- a/packages/ui/src/utils/__tests__/passwordUtils.test.tsx +++ b/packages/ui/src/utils/__tests__/passwordUtils.test.tsx @@ -329,6 +329,12 @@ describe('createPasswordConfirmationError()', () => { expect(res).toBe('Mot de passe incorrect. 3 tentatives restantes.'); }); + it.each([0, 1, 2])('does not interpret attempt metadata on unrelated errors (%i)', async remainingAttempts => { + const result = await renderLocalizations(); + const error = { code: 'unrelated_error', message: 'Unrelated error', meta: { remainingAttempts } }; + expect(createPasswordConfirmationError([error], { t: result.current.t })).toBe(error); + }); + it('returns other errors unchanged', async () => { const result = await renderLocalizations(); const error = { code: 'form_password_validation_failed', message: 'Incorrect password' }; diff --git a/packages/ui/src/utils/passwordUtils.ts b/packages/ui/src/utils/passwordUtils.ts index e36fa108469..3c13330833f 100644 --- a/packages/ui/src/utils/passwordUtils.ts +++ b/packages/ui/src/utils/passwordUtils.ts @@ -1,3 +1,4 @@ +import { isPasswordConfirmationError } from '@clerk/shared/internal/clerk-js/passwordConfirmation'; import type { ClerkAPIError, PasswordSettingsData } from '@clerk/shared/types'; import type { LocalizationKey } from '../localization'; @@ -99,7 +100,7 @@ export const createPasswordConfirmationError = ( localizationConfig: Pick, ) => { const remainingAttempts = errors[0]?.meta?.remainingAttempts; - if (typeof remainingAttempts !== 'number') { + if (!isPasswordConfirmationError(errors[0]) || typeof remainingAttempts !== 'number') { return errors[0]; } From d7c54fc421a4662476e55b867fcdfb3f0d3d49e8 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 29 Oct 2026 11:06:37 -0400 Subject: [PATCH 17/20] fix: carry password confirmation notice across sign-in origins --- .../clerk-js/src/core/__tests__/clerk.test.ts | 50 ++++++++++++++++- packages/clerk-js/src/core/clerk.ts | 27 ++++++++-- .../internal/clerk-js/passwordConfirmation.ts | 12 +++++ .../src/internal/clerk-js/queryParams.ts | 2 + .../ui/src/components/SignIn/SignInStart.tsx | 12 ++++- .../SignInPasswordConfirmation.test.tsx | 53 +++++++++++++++++-- 6 files changed, 144 insertions(+), 12 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 6193e23f274..db3293248d1 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -9,6 +9,10 @@ import { PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY, SafeLocalStorage, } from '@clerk/shared/internal/clerk-js/localStorage'; +import { + PASSWORD_CONFIRMATION_NOTICE_TTL, + PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM, +} from '@clerk/shared/internal/clerk-js/passwordConfirmation'; import type { ActiveSessionResource, PendingSessionResource, @@ -4395,6 +4399,48 @@ describe('Clerk singleton', () => { }); }); + describe('password confirmation sign-in redirects', () => { + beforeEach(() => window.localStorage.clear()); + afterEach(() => window.localStorage.clear()); + + it('carries the notice to hosted sign-in and removes the source marker when redirecting', async () => { + const sut = new Clerk(productionPublishableKey); + await sut.load({ + signInUrl: 'https://accounts.example.com/sign-in?existing=value#/start', + allowedRedirectOrigins: ['https://app.example.com'], + }); + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${sut.publishableKey}`; + const notice = `${sut.publishableKey}:${Date.now()}`; + SafeLocalStorage.setItem(key, notice, PASSWORD_CONFIRMATION_NOTICE_TTL); + const url = new URL(sut.buildSignInUrl({ redirectUrl: 'https://app.example.com/account' })); + expect(url.origin).toBe('https://accounts.example.com'); + expect(url.searchParams.get(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM)).toBe(notice); + expect(url.searchParams.get('existing')).toBe('value'); + expect(url.hash).toContain('/start'); + expect(url.href).toContain(encodeURIComponent('https://app.example.com/account')); + expect(SafeLocalStorage.getItem(key, null)).toBe(notice); + const navigate = vi.spyOn(sut, 'navigate').mockResolvedValue(undefined); + await sut.redirectToSignIn({ redirectUrl: 'https://app.example.com/account' }); + expect(navigate).toHaveBeenCalledWith(url.href); + expect(SafeLocalStorage.getItem(key, null)).toBeNull(); + navigate.mockRestore(); + }); + + it.each(['expired', 'other-instance', 'missing'])('does not forward a %s notice', async kind => { + const sut = new Clerk(productionPublishableKey); + await sut.load({ signInUrl: 'https://accounts.example.com/sign-in' }); + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${sut.publishableKey}`; + if (kind !== 'missing') { + const notice = + kind === 'expired' + ? `${sut.publishableKey}:${Date.now() - PASSWORD_CONFIRMATION_NOTICE_TTL}` + : `another_instance:${Date.now()}`; + SafeLocalStorage.setItem(key, notice, PASSWORD_CONFIRMATION_NOTICE_TTL); + } + expect(new URL(sut.buildSignInUrl()).searchParams.has(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM)).toBe(false); + }); + }); + describe('__internal_handleSessionEnded', () => { afterEach(() => { (window as any).__internal_onAfterSetActive = null; @@ -4416,14 +4462,14 @@ describe('Clerk singleton', () => { const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${sut.publishableKey}`; const updateClient = sut.updateClient; const updateClientSpy = vi.spyOn(sut, 'updateClient').mockImplementation(client => { - expect(SafeLocalStorage.getItem(key, false)).toBe(true); + expect(SafeLocalStorage.getItem(key, null)).toMatch(new RegExp(`^${sut.publishableKey}:\\d+$`)); updateClient(client); }); await sut.__internal_handleSessionEnded({ sessions: [], signedInSessions: [] } as any); expect(updateClientSpy).toHaveBeenCalledTimes(1); - expect(SafeLocalStorage.getItem(key, false)).toBe(true); + expect(SafeLocalStorage.getItem(key, null)).toMatch(new RegExp(`^${sut.publishableKey}:\\d+$`)); const now = Date.now(); const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now + 5 * 60 * 1000 + 1); expect(SafeLocalStorage.getItem(key, false)).toBe(false); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 9000dcecc0d..87ab23c63a8 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -36,6 +36,11 @@ import { PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY, SafeLocalStorage, } from '@clerk/shared/internal/clerk-js/localStorage'; +import { + isPasswordConfirmationNotice, + PASSWORD_CONFIRMATION_NOTICE_TTL, + PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM, +} from '@clerk/shared/internal/clerk-js/passwordConfirmation'; import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls'; import { getTaskEndpoint, @@ -2275,7 +2280,17 @@ export class Clerk implements ClerkInterface { const modifiedOptions = this.isSatellite ? this.#addSyncTriggerToRedirectOptions(options) : options; - return this.#buildUrl('signInUrl', { ...modifiedOptions, redirectUrl }, options?.initialValues); + const signInUrl = this.#buildUrl('signInUrl', { ...modifiedOptions, redirectUrl }, options?.initialValues); + const notice = SafeLocalStorage.getItem( + `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${this.publishableKey}`, + null, + ); + if (!signInUrl || !isPasswordConfirmationNotice(notice, this.publishableKey)) { + return signInUrl; + } + const url = new URL(signInUrl, window.location.href); + url.searchParams.set(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM, notice); + return url.href; } public buildSignUpUrl(options?: SignUpRedirectOptions): string { @@ -2471,7 +2486,11 @@ export class Clerk implements ClerkInterface { public redirectToSignIn = async (options?: SignInRedirectOptions): Promise => { if (inBrowser()) { - return this.navigate(this.buildSignInUrl(options)); + const url = this.buildSignInUrl(options); + if (url && new URL(url, window.location.href).searchParams.has(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM)) { + SafeLocalStorage.removeItem(`${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${this.publishableKey}`); + } + return this.navigate(url); } return; }; @@ -3043,8 +3062,8 @@ export class Clerk implements ClerkInterface { public __internal_handleSessionEnded = (client?: ClientResource): Promise => { SafeLocalStorage.setItem( `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${this.publishableKey}`, - true, - 5 * 60 * 1000, + `${this.publishableKey}:${Date.now()}`, + PASSWORD_CONFIRMATION_NOTICE_TTL, ); if (client) { this.updateClient(client); diff --git a/packages/shared/src/internal/clerk-js/passwordConfirmation.ts b/packages/shared/src/internal/clerk-js/passwordConfirmation.ts index 0f30d2ed83e..f26ca8a5e40 100644 --- a/packages/shared/src/internal/clerk-js/passwordConfirmation.ts +++ b/packages/shared/src/internal/clerk-js/passwordConfirmation.ts @@ -1,2 +1,14 @@ export const isPasswordConfirmationError = (error: { code: string } | undefined): boolean => error?.code === 'form_password_incorrect' || error?.code === 'form_password_validation_failed'; + +export const PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM = '__clerk_password_confirmation_session_ended'; +export const PASSWORD_CONFIRMATION_NOTICE_TTL = 5 * 60 * 1000; + +export const isPasswordConfirmationNotice = (value: unknown, publishableKey: string): value is string => { + if (typeof value !== 'string' || !value.startsWith(`${publishableKey}:`)) { + return false; + } + const issuedAt = Number(value.slice(publishableKey.length + 1)); + const age = Date.now() - issuedAt; + return Number.isFinite(issuedAt) && age >= 0 && age < PASSWORD_CONFIRMATION_NOTICE_TTL; +}; diff --git a/packages/shared/src/internal/clerk-js/queryParams.ts b/packages/shared/src/internal/clerk-js/queryParams.ts index 1a0fcd44dd3..8d6554af7d4 100644 --- a/packages/shared/src/internal/clerk-js/queryParams.ts +++ b/packages/shared/src/internal/clerk-js/queryParams.ts @@ -1,6 +1,7 @@ import type { EmailLinkErrorCodeStatus } from '../../error'; import { CLERK_NETLIFY_CACHE_BUST_PARAM } from '../../netlifyCacheHandler'; import { CLERK_SATELLITE_URL, CLERK_SUFFIXED_COOKIES, CLERK_SYNCED } from './constants'; +import { PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM } from './passwordConfirmation'; const _ClerkQueryParams = [ '__clerk_status', @@ -11,6 +12,7 @@ const _ClerkQueryParams = [ '__clerk_handshake', '__clerk_handshake_nonce', '__clerk_help', + PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM, CLERK_NETLIFY_CACHE_BUST_PARAM, CLERK_SYNCED, CLERK_SATELLITE_URL, diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 5410c2f661d..5d1e566be72 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -6,6 +6,10 @@ import { PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY, SafeLocalStorage, } from '@clerk/shared/internal/clerk-js/localStorage'; +import { + isPasswordConfirmationNotice, + PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM, +} from '@clerk/shared/internal/clerk-js/passwordConfirmation'; import { getClerkQueryParam, removeClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams'; import { useClerk } from '@clerk/shared/react'; import type { @@ -99,8 +103,12 @@ function SignInStartInternal(): JSX.Element { const clerk = useClerk(); useEffect(() => { const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${clerk.publishableKey}`; - const sessionEnded = SafeLocalStorage.getItem(key, false); - if (sessionEnded) { + const notice = getClerkQueryParam(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM); + removeClerkQueryParam(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM); + if ( + isPasswordConfirmationNotice(notice, clerk.publishableKey) || + isPasswordConfirmationNotice(SafeLocalStorage.getItem(key, null), clerk.publishableKey) + ) { SafeLocalStorage.removeItem(key); setError(t(localizationKeys('unstable__errors.password_confirmation_session_ended'))); } diff --git a/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx index 19d91f1522a..31c8532f2fe 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx @@ -2,6 +2,10 @@ import { PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY, SafeLocalStorage, } from '@clerk/shared/internal/clerk-js/localStorage'; +import { + PASSWORD_CONFIRMATION_NOTICE_TTL, + PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM, +} from '@clerk/shared/internal/clerk-js/passwordConfirmation'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; @@ -14,8 +18,12 @@ const terminalMessage = 'Your session has ended because you reached the password confirmation attempt limit. Sign in again to continue.'; describe('sign-in after the password confirmation limit', () => { + const originalUrl = window.location.href; beforeEach(() => window.localStorage.clear()); - afterEach(() => vi.restoreAllMocks()); + afterEach(() => { + vi.restoreAllMocks(); + window.history.replaceState(null, '', originalUrl); + }); it('shows the persisted reason once on a fresh sign-in mount and honors custom translations', async () => { const { wrapper, fixtures } = await createFixtures(f => f.withEmailAddress()); @@ -23,7 +31,7 @@ describe('sign-in after the password confirmation limit', () => { unstable__errors: { password_confirmation_session_ended: 'Too many attempts. Please sign in again.' }, }; const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${fixtures.clerk.publishableKey}`; - SafeLocalStorage.setItem(key, true, 300_000); + SafeLocalStorage.setItem(key, `${fixtures.clerk.publishableKey}:${Date.now()}`, 300_000); const { unmount } = render(, { wrapper }); await screen.findByText('Too many attempts. Please sign in again.'); @@ -35,7 +43,7 @@ describe('sign-in after the password confirmation limit', () => { it("leaves another instance's reason untouched", async () => { const { wrapper } = await createFixtures(f => f.withEmailAddress()); const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:another_instance`; - SafeLocalStorage.setItem(key, true, 300_000); + SafeLocalStorage.setItem(key, `another_instance:${Date.now()}`, 300_000); render(, { wrapper }); expect(screen.queryByText(terminalMessage)).not.toBeInTheDocument(); expect(SafeLocalStorage.getItem(key, null)).not.toBeNull(); @@ -44,8 +52,45 @@ describe('sign-in after the password confirmation limit', () => { it('ignores an expired reason', async () => { const { wrapper, fixtures } = await createFixtures(f => f.withEmailAddress()); const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${fixtures.clerk.publishableKey}`; - SafeLocalStorage.setItem(key, true, -1); + SafeLocalStorage.setItem(key, `${fixtures.clerk.publishableKey}:${Date.now()}`, -1); render(, { wrapper }); expect(screen.queryByText(terminalMessage)).not.toBeInTheDocument(); }); + it('consumes a redirected notice without source-origin storage and preserves the rest of the URL', async () => { + const { wrapper, fixtures } = await createFixtures(f => f.withEmailAddress()); + const url = new URL(window.location.href); + url.searchParams.set(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM, `${fixtures.clerk.publishableKey}:${Date.now()}`); + url.searchParams.set('redirect_url', 'https://app.example.com/account'); + url.hash = '#/start'; + window.history.replaceState({ preserved: true }, '', url); + expect(window.localStorage.length).toBe(0); + const { unmount } = render(, { wrapper }); + await screen.findByText(terminalMessage); + expect(new URL(window.location.href).searchParams.has(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM)).toBe(false); + expect(new URL(window.location.href).searchParams.get('redirect_url')).toBe('https://app.example.com/account'); + expect(window.location.hash).toBe('#/start'); + expect(window.history.state).toEqual({ preserved: true }); + unmount(); + render(, { wrapper }); + expect(screen.queryByText(terminalMessage)).not.toBeInTheDocument(); + }); + + it.each(['expired', 'future', 'other-instance', 'malformed'])( + 'removes but does not display a %s URL notice', + async kind => { + const { wrapper, fixtures } = await createFixtures(f => f.withEmailAddress()); + const values = { + expired: `${fixtures.clerk.publishableKey}:${Date.now() - PASSWORD_CONFIRMATION_NOTICE_TTL}`, + future: `${fixtures.clerk.publishableKey}:${Date.now() + PASSWORD_CONFIRMATION_NOTICE_TTL}`, + 'other-instance': `another_instance:${Date.now()}`, + malformed: `${fixtures.clerk.publishableKey}:invalid`, + }; + const url = new URL(window.location.href); + url.searchParams.set(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM, values[kind]); + window.history.replaceState(null, '', url); + render(, { wrapper }); + expect(screen.queryByText(terminalMessage)).not.toBeInTheDocument(); + expect(new URL(window.location.href).searchParams.has(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM)).toBe(false); + }, + ); }); From a12f970c2cc54a2c4c9f93edd690d66678ca1dca Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 29 Oct 2026 11:35:15 -0400 Subject: [PATCH 18/20] fix: clear stale password notice on session activation --- .../clerk-js/src/core/__tests__/clerk.test.ts | 113 +++++++++++++----- packages/clerk-js/src/core/clerk.ts | 4 + 2 files changed, 86 insertions(+), 31 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index db3293248d1..1ce0a978247 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -4426,6 +4426,51 @@ describe('Clerk singleton', () => { navigate.mockRestore(); }); + it.each(['initialization', 'activation'])( + 'does not replay a direct-builder notice after session %s', + async mode => { + const sut = new Clerk(productionPublishableKey); + const options = { signInUrl: 'https://accounts.example.com/sign-in' }; + await sut.load(options); + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${sut.publishableKey}`; + const otherKey = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:another_instance`; + const notice = `${sut.publishableKey}:${Date.now()}`; + SafeLocalStorage.setItem(key, notice, PASSWORD_CONFIRMATION_NOTICE_TTL); + SafeLocalStorage.setItem(otherKey, 'other-notice', PASSWORD_CONFIRMATION_NOTICE_TTL); + + expect(new URL(sut.buildSignInUrl()).searchParams.get(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM)).toBe(notice); + expect(SafeLocalStorage.getItem(key, null)).toBe(notice); + + const session = { + id: 'session_new', + status: 'active', + user: { id: 'user_1' }, + touch: vi.fn().mockResolvedValue(undefined), + getToken: vi.fn().mockResolvedValue('token_new'), + lastActiveToken: { getRawString: () => 'token_new' }, + }; + const client = { sessions: [session], signedInSessions: [session], lastActiveSessionId: session.id }; + let returningClerk = sut; + if (mode === 'initialization') { + mockClientFetch.mockResolvedValue(client); + returningClerk = new Clerk(productionPublishableKey); + await returningClerk.load(options); + } else { + sut.updateClient(client as any); + expect(SafeLocalStorage.getItem(key, null)).toBe(notice); + await sut.setActive({ session: session.id }); + } + + expect(returningClerk.session?.id).toBe(session.id); + expect(SafeLocalStorage.getItem(key, null)).toBeNull(); + expect(SafeLocalStorage.getItem(otherKey, null)).toBe('other-notice'); + await returningClerk.setActive({ session: null }); + expect( + new URL(returningClerk.buildSignInUrl()).searchParams.has(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM), + ).toBe(false); + }, + ); + it.each(['expired', 'other-instance', 'missing'])('does not forward a %s notice', async kind => { const sut = new Clerk(productionPublishableKey); await sut.load({ signInUrl: 'https://accounts.example.com/sign-in' }); @@ -4446,41 +4491,47 @@ describe('Clerk singleton', () => { (window as any).__internal_onAfterSetActive = null; }); - it('signs out and notifies other tabs after the server ended the session', async () => { - const mockOnAfterSetActive = vi.fn().mockReturnValue(Promise.resolve()); - (window as any).__internal_onAfterSetActive = mockOnAfterSetActive; - const eventBusSpy = vi.spyOn(eventBus, 'emit'); - const mockSession = { - id: 'session_1', - status: 'active', - user: { id: 'user_1' }, - lastActiveToken: { getRawString: () => 'token_1' }, - }; + it.each([false, true])( + 'preserves the notice and signs out when the response retains the active session: %s', + async retainsSession => { + const mockOnAfterSetActive = vi.fn().mockReturnValue(Promise.resolve()); + (window as any).__internal_onAfterSetActive = mockOnAfterSetActive; + const eventBusSpy = vi.spyOn(eventBus, 'emit'); + const mockSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + lastActiveToken: { getRawString: () => 'token_1' }, + }; - const sut = new Clerk(productionPublishableKey); - sut.updateClient({ sessions: [mockSession], signedInSessions: [mockSession] } as any); - const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${sut.publishableKey}`; - const updateClient = sut.updateClient; - const updateClientSpy = vi.spyOn(sut, 'updateClient').mockImplementation(client => { - expect(SafeLocalStorage.getItem(key, null)).toMatch(new RegExp(`^${sut.publishableKey}:\\d+$`)); - updateClient(client); - }); + const sut = new Clerk(productionPublishableKey); + sut.updateClient({ sessions: [mockSession], signedInSessions: [mockSession] } as any); + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${sut.publishableKey}`; + const updateClient = sut.updateClient; + const updateClientSpy = vi.spyOn(sut, 'updateClient').mockImplementation(client => { + expect(SafeLocalStorage.getItem(key, null)).toMatch(new RegExp(`^${sut.publishableKey}:\\d+$`)); + updateClient(client); + }); - await sut.__internal_handleSessionEnded({ sessions: [], signedInSessions: [] } as any); + await sut.__internal_handleSessionEnded({ + sessions: retainsSession ? [mockSession] : [], + signedInSessions: retainsSession ? [mockSession] : [], + } as any); - expect(updateClientSpy).toHaveBeenCalledTimes(1); - expect(SafeLocalStorage.getItem(key, null)).toMatch(new RegExp(`^${sut.publishableKey}:\\d+$`)); - const now = Date.now(); - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now + 5 * 60 * 1000 + 1); - expect(SafeLocalStorage.getItem(key, false)).toBe(false); - nowSpy.mockRestore(); - updateClientSpy.mockRestore(); + expect(updateClientSpy).toHaveBeenCalledTimes(1); + expect(SafeLocalStorage.getItem(key, null)).toMatch(new RegExp(`^${sut.publishableKey}:\\d+$`)); + const now = Date.now(); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now + 5 * 60 * 1000 + 1); + expect(SafeLocalStorage.getItem(key, false)).toBe(false); + nowSpy.mockRestore(); + updateClientSpy.mockRestore(); - expect(sut.session).toBeNull(); - expect(eventBusSpy).toHaveBeenCalledWith(events.UserSignOut, null); - expect(mockOnAfterSetActive).toHaveBeenCalled(); - eventBusSpy.mockRestore(); - }); + expect(sut.session).toBeNull(); + expect(eventBusSpy).toHaveBeenCalledWith(events.UserSignOut, null); + expect(mockOnAfterSetActive).toHaveBeenCalled(); + eventBusSpy.mockRestore(); + }, + ); }); describe('__internal_attemptToEnableEnvironmentSetting', () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 87ab23c63a8..13e569efb13 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -3885,6 +3885,10 @@ export class Clerk implements ClerkInterface { #updateAccessors = (session?: SignedInSessionResource | null, options?: { dangerouslySkipEmit?: boolean }) => { const { session: newSession, organization, user } = this.#getAccessorsFromSession(session); + if (newSession?.status === 'active' && (this.session?.id !== newSession.id || this.session?.status !== 'active')) { + SafeLocalStorage.removeItem(`${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${this.publishableKey}`); + } + this.session = newSession; this.organization = organization; this.user = user; From 3960aeecc2476682b7acd4b2646145f02a095759 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 29 Oct 2026 11:36:13 -0400 Subject: [PATCH 19/20] chore(clerk-js): update bundle budgets for password notice handoff --- packages/clerk-js/bundlewatch.config.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 88e61eca714..88eb5647b81 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,8 +1,8 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "556KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "83KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "126KB" }, + { "path": "./dist/clerk.js", "maxSize": "558KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "85KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "128KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "324KB" }, { "path": "./dist/clerk.native.js", "maxSize": "83KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, From 4f58354ad3572cb860553e302e0a03e057766c2a Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 30 Oct 2026 09:40:12 -0400 Subject: [PATCH 20/20] fix(ui): consume session-ended notice once per mount --- .../ui/src/components/SignIn/SignInStart.tsx | 5 +++ .../SignInPasswordConfirmation.test.tsx | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 5d1e566be72..40c0785f511 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -101,7 +101,12 @@ function SignInStartInternal(): JSX.Element { const { setError } = card; const { t } = useLocalizations(); const clerk = useClerk(); + const hasCheckedSessionEndedNotice = useRef(false); useEffect(() => { + if (hasCheckedSessionEndedNotice.current) { + return; + } + hasCheckedSessionEndedNotice.current = true; const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${clerk.publishableKey}`; const notice = getClerkQueryParam(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM); removeClerkQueryParam(PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx index 31c8532f2fe..60859c66008 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx @@ -49,6 +49,37 @@ describe('sign-in after the password confirmation limit', () => { expect(SafeLocalStorage.getItem(key, null)).not.toBeNull(); }); + it('does not consume notices added after the initial mount', async () => { + const { wrapper, fixtures } = await createFixtures(f => f.withEmailAddress()); + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${fixtures.clerk.publishableKey}`; + const read = vi.spyOn(SafeLocalStorage, 'getItem'); + const { rerender } = render(, { wrapper }); + const readsAfterMount = read.mock.calls.filter(([entry]) => entry === key).length; + expect(readsAfterMount).toBe(1); + + SafeLocalStorage.setItem(key, `${fixtures.clerk.publishableKey}:${Date.now()}`, PASSWORD_CONFIRMATION_NOTICE_TTL); + for (let i = 0; i < 5; i++) { + rerender(); + } + + expect(read.mock.calls.filter(([entry]) => entry === key)).toHaveLength(readsAfterMount); + expect(screen.queryByText(terminalMessage)).not.toBeInTheDocument(); + expect(SafeLocalStorage.getItem(key, null)).not.toBeNull(); + }); + + it('does not repeatedly display a notice when storage removal fails', async () => { + const { wrapper, fixtures } = await createFixtures(f => f.withEmailAddress()); + const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${fixtures.clerk.publishableKey}`; + SafeLocalStorage.setItem(key, `${fixtures.clerk.publishableKey}:${Date.now()}`, PASSWORD_CONFIRMATION_NOTICE_TTL); + const read = vi.spyOn(SafeLocalStorage, 'getItem'); + vi.spyOn(SafeLocalStorage, 'removeItem').mockImplementation(() => undefined); + + const { rerender } = render(, { wrapper }); + await screen.findByText(terminalMessage); + rerender(); + expect(read.mock.calls.filter(([entry]) => entry === key)).toHaveLength(1); + }); + it('ignores an expired reason', async () => { const { wrapper, fixtures } = await createFixtures(f => f.withEmailAddress()); const key = `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${fixtures.clerk.publishableKey}`;