Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/password-confirmation-attempts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- `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.
30 changes: 30 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3815,6 +3815,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();
Expand Down
22 changes: 18 additions & 4 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2992,10 +2992,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.
Expand All @@ -3009,6 +3006,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<void> => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return this.#completeSignOut({ broadcast: true });
};

#completeSignOut = (opts: { broadcast: boolean }): Promise<void> => {
if (opts.broadcast) {
eventBus.emit(events.UserSignOut, null);
}
return this.setActive({ session: null });
};

public __internal_handleUnauthenticatedDevBrowser = async (): Promise<void> => {
if (this.#authService) {
await this.#authService.handleUnauthenticatedDevBrowser();
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/resources/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (status === 422 && 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);
Expand Down
82 changes: 82 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/Base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ class TestResource extends BaseResource {
return this._baseGet();
}

mutate() {
return BaseResource._fetch({ method: 'PATCH', path: '/test' });
}

fromJSON() {
return this;
}
Expand Down Expand Up @@ -58,4 +62,82 @@ 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, 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 = {
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);
},
);
});
4 changes: 4 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2101,6 +2101,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2112,6 +2112,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2105,6 +2105,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bn-IN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2130,6 +2130,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ca-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2118,6 +2118,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/cs-CZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2117,6 +2117,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/da-DK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2102,6 +2102,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/de-DE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2135,6 +2135,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,
Expand Down
5 changes: 5 additions & 0 deletions packages/localizations/src/el-GR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2126,6 +2126,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/en-GB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2109,6 +2109,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2161,6 +2161,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/es-CR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2117,6 +2117,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2118,6 +2118,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/es-MX.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2118,6 +2118,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/es-UY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2119,6 +2119,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,
Expand Down
Loading
Loading