diff --git a/.changeset/password-confirmation-attempts.md b/.changeset/password-confirmation-attempts.md new file mode 100644 index 00000000000..d040dcb7fdd --- /dev/null +++ b/.changeset/password-confirmation-attempts.md @@ -0,0 +1,12 @@ +--- +'@clerk/clerk-js': minor +'@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. + +- `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/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 076e4eca625..88eb5647b81 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,10 +1,10 @@ { "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": "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/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 189f3ab911f..1ce0a978247 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -5,6 +5,14 @@ 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 { + PASSWORD_CONFIRMATION_NOTICE_TTL, + PASSWORD_CONFIRMATION_SESSION_ENDED_PARAM, +} from '@clerk/shared/internal/clerk-js/passwordConfirmation'; import type { ActiveSessionResource, PendingSessionResource, @@ -4391,6 +4399,141 @@ 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(['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' }); + 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; + }); + + 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); + }); + + 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(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..13e569efb13 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -31,6 +31,16 @@ import { ERROR_CODES, } from '@clerk/shared/internal/clerk-js/constants'; import { hasMultipleEnterpriseConnections } from '@clerk/shared/internal/clerk-js/enterpriseSSOFactors'; +import { + CLERK_ENVIRONMENT_STORAGE_ENTRY, + 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, @@ -174,7 +184,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'; @@ -2271,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 { @@ -2467,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; }; @@ -3016,10 +3039,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 +3053,31 @@ 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 = (client?: ClientResource): Promise => { + SafeLocalStorage.setItem( + `${PASSWORD_CONFIRMATION_ERROR_STORAGE_ENTRY}:${this.publishableKey}`, + `${this.publishableKey}:${Date.now()}`, + PASSWORD_CONFIRMATION_NOTICE_TTL, + ); + if (client) { + this.updateClient(client); + } + 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(); @@ -3840,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; diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 4ad63d5d01c..2c03e1fec71 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -1,5 +1,6 @@ import { isValidBrowserOnline } from '@clerk/shared/browser'; import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error'; +import { isPasswordConfirmationError } from '@clerk/shared/internal/clerk-js/passwordConfirmation'; import { isProductionFromPublishableKey } from '@clerk/shared/keys'; import type { ClerkAPIErrorJSON, @@ -128,7 +129,7 @@ export abstract class BaseResource { } const { payload, status, statusText, headers } = fapiResponse; - + const errors = payload?.errors ?? []; if (headers) { const country = headers.get('x-country'); @@ -136,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); } @@ -145,9 +151,8 @@ export abstract class BaseResource { } if (status >= 400) { - const errors = payload?.errors as ClerkAPIErrorJSON[]; - 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 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..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,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BaseResource } from '../internal'; @@ -11,12 +11,19 @@ class TestResource extends BaseResource { return this._baseGet(); } + mutate() { + return BaseResource._fetch({ method: 'PATCH', path: '/test' }); + } + fromJSON() { return this; } } 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 @@ -58,4 +65,89 @@ 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 = { + 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 }), + ]), + }), + ); + }); + it.each([ + [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, code, status, expectedCalls) => { + const updateClient = vi.fn(); + const handleSessionEnded = vi.fn().mockResolvedValue(undefined); + + BaseResource.clerk = { + publishableKey: 'pk_test_confirmation', + getFapiClient: () => ({ + request: vi.fn().mockResolvedValue({ + payload: { + client: { id: 'client_1', sessions: [] }, + errors: [ + { + code, + message: 'Password is incorrect', + meta: { remaining_attempts: remainingAttempts }, + }, + ], + }, + status, + 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(expectedCalls ? 0 : 1); + if (expectedCalls) { + expect(handleSessionEnded).toHaveBeenCalledWith(expect.objectContaining({ id: 'client_1' })); + } + expect(handleSessionEnded).toHaveBeenCalledTimes(expectedCalls); + }, + ); }); diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index c707911a13b..6dac83774ad 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -2146,6 +2146,10 @@ export const arSA: LocalizationResource = { requireUppercase: 'حرف كبير', sentencePrefix: 'كلمة السر يجب أن تحتوي على', }, + 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 db7b9ca5650..3d332e62699 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -2157,6 +2157,10 @@ export const beBY: LocalizationResource = { requireUppercase: 'літару ў верхнім рэгістры', sentencePrefix: 'Ваш пароль павінен утрымліваць', }, + 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 fc88d5a762e..38b17de844a 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -2150,6 +2150,10 @@ export const bgBG: LocalizationResource = { requireUppercase: 'главна буква', sentencePrefix: 'Вашата парола трябва да съдържа', }, + 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 7385f976b99..249bfbd9383 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -2175,6 +2175,10 @@ export const bnIN: LocalizationResource = { requireUppercase: 'একটি বড় হাতের অক্ষর', sentencePrefix: 'আপনার পাসওয়ার্ডে অবশ্যই থাকতে হবে', }, + 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 9225a3c42c5..f1e4a935fe7 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -2163,6 +2163,10 @@ export const caES: LocalizationResource = { requireUppercase: 'una lletra majúscula', sentencePrefix: 'La teva contrasenya ha de contenir', }, + 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 cc16a2a3d70..b96bc73eaa2 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -2162,6 +2162,10 @@ export const csCZ: LocalizationResource = { requireUppercase: 'velké písmeno', sentencePrefix: 'Vaše heslo musí obsahovat', }, + 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 362723d2869..f098b63dcd3 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -2147,6 +2147,10 @@ export const daDK: LocalizationResource = { requireUppercase: 'et stort bogstav', sentencePrefix: 'Din adgangskode skal indeholde', }, + 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 52ecf5ecb8b..28b8e460914 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -2180,6 +2180,10 @@ export const deDE: LocalizationResource = { requireUppercase: 'einen Großbuchstaben enthalten', sentencePrefix: 'Das Passwort muss', }, + 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 5013b35ca5b..2b5b7307e41 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -2171,6 +2171,11 @@ export const elGR: LocalizationResource = { requireUppercase: 'ένα κεφαλαίο γράμμα', sentencePrefix: 'Ο κωδικός πρόσβασής σας πρέπει να περιέχει', }, + 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 a038681c897..8fbdc8a1f45 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -2154,6 +2154,10 @@ export const enGB: 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: '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..30292412b05 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -2162,6 +2162,10 @@ export const esCR: LocalizationResource = { requireUppercase: 'al menos una letra mayúscula', sentencePrefix: 'Tu contraseña debe contener', }, + 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 cff65d2b6a5..af7a759d093 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -2163,6 +2163,10 @@ export const esES: LocalizationResource = { requireUppercase: 'Al menos una letra mayúscula', sentencePrefix: 'Tu contraseña debe contener:', }, + 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 2e6d7e4256d..ec3f9e7fbef 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -2163,6 +2163,10 @@ export const esMX: LocalizationResource = { requireUppercase: 'al menos una letra mayúscula', sentencePrefix: 'Tu contraseña debe contener', }, + 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 395d82d04e4..c5d828fb940 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -2164,6 +2164,10 @@ export const esUY: LocalizationResource = { requireUppercase: 'una letra mayúscula', sentencePrefix: 'Tu contraseña debe contener', }, + 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. 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, diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts index 38ce6f62bba..1747a93118b 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -2161,6 +2161,10 @@ export const faIR: LocalizationResource = { requireUppercase: 'یک حرف بزرگ', sentencePrefix: 'رمز عبور شما باید حاوی', }, + 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 5e68744f92e..5cbd0fdc638 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -2177,6 +2177,10 @@ export const fiFI: LocalizationResource = { requireUppercase: 'iso kirjain', sentencePrefix: 'Salasanan on sisällettävä', }, + 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 b10aa5e3a60..47c2ea3e894 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -2172,6 +2172,10 @@ export const frFR: LocalizationResource = { requireUppercase: 'une lettre majuscule', sentencePrefix: 'Votre mot de passe doit contenir', }, + 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 76d4e71749c..9bcdafc486d 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -2138,6 +2138,10 @@ export const heIL: LocalizationResource = { requireUppercase: 'אות גדולה', sentencePrefix: 'הסיסמה שלך חייבת להכיל', }, + 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 a15280fb922..eaad6dd8be9 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -2176,6 +2176,10 @@ export const hiIN: LocalizationResource = { requireUppercase: 'एक अपरकेस अक्षर', sentencePrefix: 'आपके पासवर्ड में होना चाहिए', }, + 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 898e1668daf..6001c5c1207 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -2181,6 +2181,10 @@ export const hrHR: LocalizationResource = { requireUppercase: 'veliko slovo', sentencePrefix: 'Vaša lozinka mora sadržavati', }, + 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 ba75bd80414..938b50ae53b 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -2180,6 +2180,10 @@ export const huHU: LocalizationResource = { requireUppercase: 'egy nagybetű', sentencePrefix: 'A jelszavadnak tartalmaznia kell', }, + 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 7b35077a8b8..e3fee76584e 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -2160,6 +2160,11 @@ export const idID: LocalizationResource = { requireUppercase: 'huruf besar', sentencePrefix: 'Kata sandi Anda harus mengandung', }, + 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 f015eed4f00..e95989ebeaf 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -2178,6 +2178,10 @@ export const isIS: LocalizationResource = { requireUppercase: 'hástaf', sentencePrefix: 'Lykilorðið þitt verður að innihalda', }, + 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 4044db1665a..ab392b1329b 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -2159,6 +2159,10 @@ export const itIT: LocalizationResource = { requireUppercase: undefined, sentencePrefix: 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 e0298371964..fe6cc09f7d6 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -2174,6 +2174,10 @@ export const jaJP: LocalizationResource = { requireUppercase: '大文字を含む', sentencePrefix: 'パスワードは次の条件を満たす必要があります:', }, + 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 8a51cb18eca..9b256fcf3d1 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -2140,6 +2140,10 @@ export const kkKZ: LocalizationResource = { requireUppercase: 'бас әріп', sentencePrefix: 'Құпия сөзде болуы керек:', }, + 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 abec99a1dff..df86e61230e 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -2147,6 +2147,10 @@ export const koKR: LocalizationResource = { requireUppercase: '대문자', sentencePrefix: '비밀번호에는 다음이 꼭 포함돼야 해요', }, + 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 64c73cef2a1..d7323c21936 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -2152,6 +2152,10 @@ export const mnMN: LocalizationResource = { requireUppercase: 'том үсэг', sentencePrefix: 'таны нууц үг агуулсан байх ёстой', }, + 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 99c926f3155..9ac8a48332f 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -2185,6 +2185,10 @@ export const msMY: LocalizationResource = { requireUppercase: 'huruf besar', sentencePrefix: 'Kata laluan anda mesti mengandungi', }, + 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 0ebbf9b3315..ee58dd8d26e 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -2178,6 +2178,10 @@ export const nbNO: LocalizationResource = { requireUppercase: 'en stor bokstav', sentencePrefix: 'Passordet ditt må inneholde', }, + 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 a82b79edd58..bc64a02add1 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -2151,6 +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: '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 cf305556832..ed56dfa4a64 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -2151,6 +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: '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 19eed5d20d3..643fc94fcbf 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -2159,6 +2159,10 @@ export const plPL: LocalizationResource = { requireUppercase: 'wielką literę', sentencePrefix: 'Twoje hasło musi zawierać', }, + 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 0b265682603..9ef745a208d 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -2169,6 +2169,10 @@ export const ptBR: LocalizationResource = { requireUppercase: 'uma letra maiúscula', sentencePrefix: 'Sua senha deve conter', }, + 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 72e81ceabb0..9597fba8fd4 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -2175,6 +2175,10 @@ export const ptPT: LocalizationResource = { requireUppercase: 'uma letra maiúscula', sentencePrefix: 'A sua palavra-passe deve conter', }, + 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 d8515f1b2ae..c2fa7162b62 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -2170,6 +2170,10 @@ export const roRO: LocalizationResource = { requireUppercase: 'o literă mare', sentencePrefix: 'Parola ta trebuie să conțină', }, + 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 3af75fb03eb..4ca946984cb 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -2167,6 +2167,10 @@ export const ruRU: LocalizationResource = { requireUppercase: 'букву в верхнем регистре', sentencePrefix: 'Ваш пароль должен содержать', }, + 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 1dcc2f679d6..f3bd06a9847 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -2158,6 +2158,10 @@ export const skSK: LocalizationResource = { requireUppercase: 'veľké písmeno', sentencePrefix: 'Vaše heslo musí obsahovať', }, + 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 77c45b2f883..51c01e779d0 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -2150,6 +2150,10 @@ export const srRS: LocalizationResource = { requireUppercase: 'veliko slovo', sentencePrefix: 'Tvoja lozinka mora sadržati', }, + 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 cec06c14be5..08c715b9040 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -2153,6 +2153,10 @@ export const svSE: LocalizationResource = { requireUppercase: 'Kräver stora bokstäver', sentencePrefix: 'Lösenordet måste innehålla', }, + 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 5dbb4287d9c..6db7a04f946 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -2186,6 +2186,11 @@ export const taIN: LocalizationResource = { requireUppercase: 'ஒரு பெரிய எழுத்து', sentencePrefix: 'உங்கள் கடவுச்சொல்லில் இருக்க வேண்டும்', }, + 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 341a072a8c1..27ad7bf077a 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -2180,6 +2180,10 @@ export const teIN: LocalizationResource = { requireUppercase: 'ఒక పెద్ద అక్షరం', sentencePrefix: 'మీ పాస్‌వర్డ్‌లో ఉండాలి', }, + 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 51dcfa5b0e9..8cf815e1b4b 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -2148,6 +2148,10 @@ export const thTH: LocalizationResource = { requireUppercase: 'ตัวอักษรพิมพ์ใหญ่', sentencePrefix: 'รหัสผ่านของคุณต้องมี', }, + 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 50d01c57240..61a63b4a0c7 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -2154,6 +2154,10 @@ export const trTR: LocalizationResource = { requireUppercase: 'bir büyük harf içermeli', sentencePrefix: 'Şifreniz;', }, + 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 9125e2b878c..bd0ba8d32dd 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -2148,6 +2148,10 @@ export const ukUA: LocalizationResource = { requireUppercase: 'букву у верхньому регістрі', sentencePrefix: 'Ваш пароль повинен містити', }, + 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 1b5a87abe44..2db7a3c44f9 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -2171,6 +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: '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 3e65172457b..b64eb34f75a 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: '密码错误。您还有 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 3d905a96077..cdc53829599 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: '密碼錯誤。您還有 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/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()); 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/clerk-js/src/utils/__tests__/localStorage.test.ts b/packages/shared/src/internal/clerk-js/__tests__/localStorage.test.ts similarity index 100% rename from packages/clerk-js/src/utils/__tests__/localStorage.test.ts rename to packages/shared/src/internal/clerk-js/__tests__/localStorage.test.ts diff --git a/packages/clerk-js/src/utils/localStorage.ts b/packages/shared/src/internal/clerk-js/localStorage.ts similarity index 94% rename from packages/clerk-js/src/utils/localStorage.ts rename to packages/shared/src/internal/clerk-js/localStorage.ts index e9298c245c4..9fd8cf746e6 100644 --- a/packages/clerk-js/src/utils/localStorage.ts +++ b/packages/shared/src/internal/clerk-js/localStorage.ts @@ -1,6 +1,7 @@ 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; 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..f26ca8a5e40 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/passwordConfirmation.ts @@ -0,0 +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/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/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" }, diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index d3e9d806bcb..40c0785f511 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -2,6 +2,14 @@ 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 { + 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 { @@ -35,7 +43,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'; @@ -90,7 +98,26 @@ const useAutoFillPasskey = () => { function SignInStartInternal(): JSX.Element { const card = useCardState(); + 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); + if ( + isPasswordConfirmationNotice(notice, clerk.publishableKey) || + isPasswordConfirmationNotice(SafeLocalStorage.getItem(key, null), clerk.publishableKey) + ) { + SafeLocalStorage.removeItem(key); + setError(t(localizationKeys('unstable__errors.password_confirmation_session_ended'))); + } + }, [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 new file mode 100644 index 00000000000..60859c66008 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInPasswordConfirmation.test.tsx @@ -0,0 +1,127 @@ +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'; +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', () => { + const originalUrl = window.location.href; + beforeEach(() => window.localStorage.clear()); + 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()); + 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, `${fixtures.clerk.publishableKey}:${Date.now()}`, 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, `another_instance:${Date.now()}`, 300_000); + render(, { wrapper }); + expect(screen.queryByText(terminalMessage)).not.toBeInTheDocument(); + 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}`; + 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); + }, + ); +}); 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/utils/__tests__/passwordUtils.test.tsx b/packages/ui/src/utils/__tests__/passwordUtils.test.tsx index d1e596d93ac..b2180b779d3 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,59 @@ 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.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' }; + + 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..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'; @@ -92,6 +93,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 (!isPasswordConfirmationError(errors[0]) || 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}.`; };