mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(auth): say a refused sign-in in the sign-in page's own words
The sign-in refusals carry only their status's generic code, so once the sign-in page read codes, a wrong password, a wrong second-factor code and a refused security key all said "Your session has ended", and a lockout said "too many requests just now". localizedSignInError reads a generic 401 as the step's own failure and a generic 429 as "Too many attempts. Wait a few minutes and try again."; a code the step names itself keeps its sentence. The password, passkey, code and security-key steps use it. The rate-limit sentence moves from auth.claim to auth.signInRateLimited, since the claim page and the sign-in page now share it, and the Korean one takes the polite form the rest of the catalogue uses.
This commit is contained in:
1 parent
367ba65bf6
commit
1dde3034c6
16 files changed
+95
-32
No files matched your search
+2
-2
@@ -669,7 +669,6 @@
|
||||
"submit": "Übernehmen",
|
||||
"submitting": "Wird übernommen…",
|
||||
"errorTaken": "Dieser Benutzername oder diese E-Mail-Adresse ist schon vergeben.",
|
||||
"errorRateLimit": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut.",
|
||||
"errorFailed": "Das hat nicht geklappt. Versuche es gleich noch einmal.",
|
||||
"errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut.",
|
||||
"ssoContinue": "Weiter mit {provider}",
|
||||
@@ -691,7 +690,8 @@
|
||||
"toLogin": "Zur Anmeldung"
|
||||
},
|
||||
"deletionPending": "Dieses Konto wird am {date} gelöscht und ist bis dahin gesperrt. Ein Admin dieses Servers kann die Löschung vor diesem Datum rückgängig machen.",
|
||||
"deletionPendingNoDate": "Dieses Konto ist zur Löschung vorgemerkt und gesperrt. Ein Admin dieses Servers kann die Löschung rückgängig machen, bis sie abgeschlossen ist."
|
||||
"deletionPendingNoDate": "Dieses Konto ist zur Löschung vorgemerkt und gesperrt. Ein Admin dieses Servers kann die Löschung rückgängig machen, bis sie abgeschlossen ist.",
|
||||
"signInRateLimited": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
+2
-2
@@ -669,7 +669,6 @@
|
||||
"submit": "Take over",
|
||||
"submitting": "Taking over…",
|
||||
"errorTaken": "That username or email address is already in use.",
|
||||
"errorRateLimit": "Too many attempts. Wait a few minutes and try again.",
|
||||
"errorFailed": "That did not work. Try again in a moment.",
|
||||
"errorOffline": "That did not reach the server. Check your connection and try again.",
|
||||
"ssoContinue": "Continue with {provider}",
|
||||
@@ -691,7 +690,8 @@
|
||||
"toLogin": "Go to sign in"
|
||||
},
|
||||
"deletionPending": "This account is scheduled for deletion on {date} and is locked until then. An admin of this server can undo the deletion before that date.",
|
||||
"deletionPendingNoDate": "This account is scheduled for deletion and is locked. An admin of this server can undo the deletion until it completes."
|
||||
"deletionPendingNoDate": "This account is scheduled for deletion and is locked. An admin of this server can undo the deletion until it completes.",
|
||||
"signInRateLimited": "Too many attempts. Wait a few minutes and try again."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
+2
-2
@@ -669,7 +669,6 @@
|
||||
"submit": "Asumir",
|
||||
"submitting": "Asumiendo…",
|
||||
"errorTaken": "Ese nombre de usuario o correo electrónico ya está en uso.",
|
||||
"errorRateLimit": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo.",
|
||||
"errorFailed": "No ha funcionado. Vuelve a intentarlo en un momento.",
|
||||
"errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo.",
|
||||
"ssoContinue": "Continuar con {provider}",
|
||||
@@ -691,7 +690,8 @@
|
||||
"toLogin": "Ir a iniciar sesión"
|
||||
},
|
||||
"deletionPending": "Esta cuenta se eliminará el {date} y está bloqueada hasta entonces. Un administrador de este servidor puede deshacer la eliminación antes de esa fecha.",
|
||||
"deletionPendingNoDate": "Esta cuenta está programada para eliminarse y está bloqueada. Un administrador de este servidor puede deshacer la eliminación hasta que se complete."
|
||||
"deletionPendingNoDate": "Esta cuenta está programada para eliminarse y está bloqueada. Un administrador de este servidor puede deshacer la eliminación hasta que se complete.",
|
||||
"signInRateLimited": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
|
||||
+2
-2
@@ -669,7 +669,6 @@
|
||||
"submit": "Reprendre",
|
||||
"submitting": "Reprise en cours…",
|
||||
"errorTaken": "Ce nom d'utilisateur ou cette adresse e-mail est déjà utilisé.",
|
||||
"errorRateLimit": "Trop de tentatives. Attendez quelques minutes puis réessayez.",
|
||||
"errorFailed": "Cela n'a pas fonctionné. Réessayez dans un instant.",
|
||||
"errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez.",
|
||||
"ssoContinue": "Continuer avec {provider}",
|
||||
@@ -691,7 +690,8 @@
|
||||
"toLogin": "Aller à la connexion"
|
||||
},
|
||||
"deletionPending": "Ce compte sera supprimé le {date} et reste verrouillé d’ici là. Un administrateur de ce serveur peut annuler la suppression avant cette date.",
|
||||
"deletionPendingNoDate": "Ce compte est programmé pour suppression et il est verrouillé. Un administrateur de ce serveur peut annuler la suppression tant qu’elle n’est pas terminée."
|
||||
"deletionPendingNoDate": "Ce compte est programmé pour suppression et il est verrouillé. Un administrateur de ce serveur peut annuler la suppression tant qu’elle n’est pas terminée.",
|
||||
"signInRateLimited": "Trop de tentatives. Attendez quelques minutes puis réessayez."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
|
||||
+2
-2
@@ -669,7 +669,6 @@
|
||||
"submit": "Prendi in carico",
|
||||
"submitting": "Presa in carico…",
|
||||
"errorTaken": "Questo nome utente o indirizzo email è già in uso.",
|
||||
"errorRateLimit": "Troppi tentativi. Attendi qualche minuto e riprova.",
|
||||
"errorFailed": "Non ha funzionato. Riprova tra un momento.",
|
||||
"errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova.",
|
||||
"ssoContinue": "Continua con {provider}",
|
||||
@@ -691,7 +690,8 @@
|
||||
"toLogin": "Vai all’accesso"
|
||||
},
|
||||
"deletionPending": "Questo account verrà eliminato il {date} ed è bloccato fino ad allora. Un amministratore di questo server può annullare l’eliminazione prima di quella data.",
|
||||
"deletionPendingNoDate": "Questo account è in attesa di eliminazione ed è bloccato. Un amministratore di questo server può annullare l’eliminazione finché non è completata."
|
||||
"deletionPendingNoDate": "Questo account è in attesa di eliminazione ed è bloccato. Un amministratore di questo server può annullare l’eliminazione finché non è completata.",
|
||||
"signInRateLimited": "Troppi tentativi. Attendi qualche minuto e riprova."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Cruscotto",
|
||||
|
||||
+2
-2
@@ -669,7 +669,6 @@
|
||||
"submit": "넘겨받기",
|
||||
"submitting": "넘겨받는 중…",
|
||||
"errorTaken": "이미 사용 중인 사용자 이름 또는 이메일 주소입니다.",
|
||||
"errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.",
|
||||
"errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.",
|
||||
"errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요.",
|
||||
"ssoContinue": "{provider}(으)로 계속",
|
||||
@@ -691,7 +690,8 @@
|
||||
"toLogin": "로그인으로 이동"
|
||||
},
|
||||
"deletionPending": "이 계정은 {date}에 삭제될 예정이며 그때까지 잠겨 있어요. 그 전에는 이 서버의 관리자가 삭제를 취소할 수 있어요.",
|
||||
"deletionPendingNoDate": "이 계정은 삭제 예정이라 잠겨 있어요. 삭제가 끝나기 전까지는 이 서버의 관리자가 취소할 수 있어요."
|
||||
"deletionPendingNoDate": "이 계정은 삭제 예정이라 잠겨 있어요. 삭제가 끝나기 전까지는 이 서버의 관리자가 취소할 수 있어요.",
|
||||
"signInRateLimited": "시도 횟수가 너무 많아요. 몇 분 후 다시 시도해 주세요."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "대시보드",
|
||||
|
||||
+2
-2
@@ -669,7 +669,6 @@
|
||||
"submit": "Overnemen",
|
||||
"submitting": "Overnemen…",
|
||||
"errorTaken": "Die gebruikersnaam of dat e-mailadres is al in gebruik.",
|
||||
"errorRateLimit": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw.",
|
||||
"errorFailed": "Dat is niet gelukt. Probeer het zo opnieuw.",
|
||||
"errorOffline": "De server is niet bereikt. Controleer je verbinding en probeer het opnieuw.",
|
||||
"ssoContinue": "Doorgaan met {provider}",
|
||||
@@ -691,7 +690,8 @@
|
||||
"toLogin": "Naar inloggen"
|
||||
},
|
||||
"deletionPending": "Dit account wordt op {date} verwijderd en is tot dan vergrendeld. Een beheerder van deze server kan de verwijdering vóór die datum ongedaan maken.",
|
||||
"deletionPendingNoDate": "Dit account staat gepland voor verwijdering en is vergrendeld. Een beheerder van deze server kan de verwijdering ongedaan maken tot die voltooid is."
|
||||
"deletionPendingNoDate": "Dit account staat gepland voor verwijdering en is vergrendeld. Een beheerder van deze server kan de verwijdering ongedaan maken tot die voltooid is.",
|
||||
"signInRateLimited": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
+2
-2
@@ -669,7 +669,6 @@
|
||||
"submit": "Przejmij",
|
||||
"submitting": "Przejmowanie…",
|
||||
"errorTaken": "Ta nazwa użytkownika lub adres e-mail jest już zajęty.",
|
||||
"errorRateLimit": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.",
|
||||
"errorFailed": "Nie udało się. Spróbuj ponownie za chwilę.",
|
||||
"errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie.",
|
||||
"ssoContinue": "Kontynuuj przez {provider}",
|
||||
@@ -691,7 +690,8 @@
|
||||
"toLogin": "Przejdź do logowania"
|
||||
},
|
||||
"deletionPending": "To konto zostanie usunięte {date} i do tego czasu jest zablokowane. Administrator tego serwera może cofnąć usunięcie przed tą datą.",
|
||||
"deletionPendingNoDate": "To konto czeka na usunięcie i jest zablokowane. Administrator tego serwera może cofnąć usunięcie, dopóki się nie zakończy."
|
||||
"deletionPendingNoDate": "To konto czeka na usunięcie i jest zablokowane. Administrator tego serwera może cofnąć usunięcie, dopóki się nie zakończy.",
|
||||
"signInRateLimited": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Pulpit",
|
||||
|
||||
+2
-2
@@ -669,7 +669,6 @@
|
||||
"submit": "Assumir",
|
||||
"submitting": "Assumindo…",
|
||||
"errorTaken": "Esse nome de usuário ou e-mail já está em uso.",
|
||||
"errorRateLimit": "Muitas tentativas. Aguarde alguns minutos e tente novamente.",
|
||||
"errorFailed": "Isso não funcionou. Tente novamente em instantes.",
|
||||
"errorOffline": "Não foi possível alcançar o servidor. Verifique sua conexão e tente novamente.",
|
||||
"ssoContinue": "Continuar com {provider}",
|
||||
@@ -691,7 +690,8 @@
|
||||
"toLogin": "Ir para o login"
|
||||
},
|
||||
"deletionPending": "Esta conta será excluída em {date} e está bloqueada até lá. Um administrador deste servidor pode desfazer a exclusão antes dessa data.",
|
||||
"deletionPendingNoDate": "Esta conta está programada para exclusão e está bloqueada. Um administrador deste servidor pode desfazer a exclusão até que ela seja concluída."
|
||||
"deletionPendingNoDate": "Esta conta está programada para exclusão e está bloqueada. Um administrador deste servidor pode desfazer a exclusão até que ela seja concluída.",
|
||||
"signInRateLimited": "Muitas tentativas. Aguarde alguns minutos e tente novamente."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Painel",
|
||||
|
||||
+2
-2
@@ -669,7 +669,6 @@
|
||||
"submit": "Принять",
|
||||
"submitting": "Принимаем…",
|
||||
"errorTaken": "Это имя пользователя или адрес электронной почты уже используется.",
|
||||
"errorRateLimit": "Слишком много попыток. Подождите несколько минут и попробуйте снова.",
|
||||
"errorFailed": "Не сработало. Попробуйте через минуту.",
|
||||
"errorOffline": "Не дошло до сервера. Проверьте подключение и попробуйте снова.",
|
||||
"ssoContinue": "Продолжить с {provider}",
|
||||
@@ -691,7 +690,8 @@
|
||||
"toLogin": "Перейти ко входу"
|
||||
},
|
||||
"deletionPending": "Этот аккаунт запланирован к удалению на {date} и заблокирован до тех пор. Администратор этого сервера может отменить удаление до этой даты.",
|
||||
"deletionPendingNoDate": "Этот аккаунт запланирован к удалению и заблокирован. Администратор этого сервера может отменить удаление до его завершения."
|
||||
"deletionPendingNoDate": "Этот аккаунт запланирован к удалению и заблокирован. Администратор этого сервера может отменить удаление до его завершения.",
|
||||
"signInRateLimited": "Слишком много попыток. Подождите несколько минут и попробуйте снова."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Сводка",
|
||||
|
||||
@@ -25,7 +25,7 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
import { sanitizeSameOriginPath } from "@/lib/url-safety";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { localizedSignInError } from "@/lib/api/localized-error";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
InsecureTransportBanner,
|
||||
@@ -263,10 +263,10 @@ export default function LoginPage() {
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
if (noteRefusal(err)) return;
|
||||
// Route rejections carry the envelope error verbatim; everything
|
||||
// else (WebAuthn ceremony failures) keeps the descriptive mapping.
|
||||
// A route's refusal is named by its code; a WebAuthn ceremony failure
|
||||
// keeps the descriptive mapping.
|
||||
if (err instanceof ApiError) {
|
||||
setError(localizedApiError(err, t, "auth.loginFailed"));
|
||||
setError(localizedSignInError(err, t, "auth.loginFailed"));
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
@@ -310,7 +310,7 @@ export default function LoginPage() {
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
if (noteRefusal(err)) return;
|
||||
setError(localizedApiError(err, t, "auth.loginFailed"));
|
||||
setError(localizedSignInError(err, t, "auth.loginFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ export function ClaimProfile({
|
||||
title={t("auth.claim.title")}
|
||||
description={t(
|
||||
previewStatus === 429
|
||||
? "auth.claim.errorRateLimit"
|
||||
? "auth.signInRateLimited"
|
||||
: "auth.claim.errorFailed",
|
||||
)}
|
||||
/>
|
||||
@@ -548,6 +548,6 @@ export function claimErrorKey(err: unknown): string {
|
||||
: "auth.claim.errorTaken";
|
||||
}
|
||||
if (err.status === 403) return "auth.claim.oidcOnly";
|
||||
if (err.status === 429) return "auth.claim.errorRateLimit";
|
||||
if (err.status === 429) return "auth.signInRateLimited";
|
||||
return "auth.claim.errorFailed";
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { localizedSignInError } from "@/lib/api/localized-error";
|
||||
import { currentClientTransport } from "@/lib/auth/client-transport";
|
||||
|
||||
/** The server's refusal for a page whose sign-in cookie cannot stick. */
|
||||
@@ -74,7 +74,7 @@ export function MfaLoginStep({
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
|
||||
? t("auth.insecureTransport.title")
|
||||
: localizedApiError(err, t, "auth.mfa.verifyFailed"),
|
||||
: localizedSignInError(err, t, "auth.mfa.verifyFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -101,7 +101,7 @@ export function MfaLoginStep({
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(localizedApiError(err, t, "auth.mfa.verifyFailed"));
|
||||
setError(localizedSignInError(err, t, "auth.mfa.verifyFailed"));
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("claimErrorKey", () => {
|
||||
"auth.claim.signedInTitle",
|
||||
],
|
||||
[new ApiError("x", 403, {}), "auth.claim.oidcOnly"],
|
||||
[new ApiError("x", 429, {}), "auth.claim.errorRateLimit"],
|
||||
[new ApiError("x", 429, {}), "auth.signInRateLimited"],
|
||||
[new ApiError("x", 500, {}), "auth.claim.errorFailed"],
|
||||
[new Error("offline"), "auth.claim.errorOffline"],
|
||||
])("maps %o", (err, key) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ApiError } from "../api-fetch";
|
||||
import { localizedApiError } from "../localized-error";
|
||||
import { localizedApiError, localizedSignInError } from "../localized-error";
|
||||
import de from "../../../../messages/de.json";
|
||||
import en from "../../../../messages/en.json";
|
||||
import es from "../../../../messages/es.json";
|
||||
@@ -234,3 +234,46 @@ describe("localizedApiError", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("localizedSignInError", () => {
|
||||
const enT = translator(en as Catalog);
|
||||
|
||||
it("reads a wrong password or code as the step's failure, not an ended session", () => {
|
||||
const error = new ApiError("Invalid credentials", 401, {
|
||||
errorCode: "http.unauthenticated",
|
||||
});
|
||||
expect(localizedSignInError(error, enT, "auth.loginFailed")).toBe(
|
||||
"Login failed",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["de", de],
|
||||
["en", en],
|
||||
["es", es],
|
||||
["fr", fr],
|
||||
["it", itCatalog],
|
||||
["ko", ko],
|
||||
["nl", nl],
|
||||
["pl", pl],
|
||||
["pt-BR", ptBR],
|
||||
["ru", ru],
|
||||
])("reads a lockout as one in %s", (_, locale) => {
|
||||
const localT = translator(locale as Catalog);
|
||||
const error = new ApiError("Too many login attempts", 429, {
|
||||
errorCode: "http.rate_limited",
|
||||
});
|
||||
const sentence = localizedSignInError(error, localT, "auth.loginFailed");
|
||||
expect(sentence).toBe(localT("auth.signInRateLimited"));
|
||||
expect(sentence).not.toBe("auth.signInRateLimited");
|
||||
});
|
||||
|
||||
it("keeps the sentence of a code the step names itself", () => {
|
||||
const error = new ApiError("raw English server prose", 401, {
|
||||
errorCode: "auth.reproof.failed",
|
||||
});
|
||||
expect(localizedSignInError(error, enT, "auth.loginFailed")).toMatch(
|
||||
/did not verify/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,3 +24,23 @@ export function localizedApiError(
|
||||
: defaultErrorCodeForStatus(error.status);
|
||||
return translateCode(t, "apiErrors", errorCode, fallbackKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused sign-in step (password, second factor, passkey). These refusals
|
||||
* mostly carry only their status's generic code, and here a 401 means a
|
||||
* wrong password or code, not an ended session, and a 429 a lockout of
|
||||
* several minutes. A code of the step's own keeps its sentence.
|
||||
*/
|
||||
export function localizedSignInError(
|
||||
error: unknown,
|
||||
t: Translate,
|
||||
fallbackKey: string,
|
||||
): string {
|
||||
if (error instanceof ApiError) {
|
||||
const code = error.meta?.errorCode;
|
||||
const generic = typeof code !== "string" || code.startsWith("http.");
|
||||
if (generic && error.status === 429) return t("auth.signInRateLimited");
|
||||
if (generic && error.status === 401) return t(fallbackKey);
|
||||
}
|
||||
return localizedApiError(error, t, fallbackKey);
|
||||
}
|
||||
Reference in new issue
Block a user