diff --git a/messages/de.json b/messages/de.json index 7ed96899a..d53149d8a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 6c67dc6c5..62b3b54d1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 02a1df1e7..cf7d035f6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 0577bd9f1..5d0a91f0a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index a2791af23..c2f16d754 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 6fa577571..e89c585c2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -669,7 +669,6 @@ "submit": "넘겨받기", "submitting": "넘겨받는 중…", "errorTaken": "이미 사용 중인 사용자 이름 또는 이메일 주소입니다.", - "errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.", "errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.", "errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요.", "ssoContinue": "{provider}(으)로 계속", @@ -691,7 +690,8 @@ "toLogin": "로그인으로 이동" }, "deletionPending": "이 계정은 {date}에 삭제될 예정이며 그때까지 잠겨 있어요. 그 전에는 이 서버의 관리자가 삭제를 취소할 수 있어요.", - "deletionPendingNoDate": "이 계정은 삭제 예정이라 잠겨 있어요. 삭제가 끝나기 전까지는 이 서버의 관리자가 취소할 수 있어요." + "deletionPendingNoDate": "이 계정은 삭제 예정이라 잠겨 있어요. 삭제가 끝나기 전까지는 이 서버의 관리자가 취소할 수 있어요.", + "signInRateLimited": "시도 횟수가 너무 많아요. 몇 분 후 다시 시도해 주세요." }, "dashboard": { "title": "대시보드", diff --git a/messages/nl.json b/messages/nl.json index 8d7ee8224..742d09797 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 0ca051a4d..e76aaabaf 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index d546d0448..f34cf7977 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -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", diff --git a/messages/ru.json b/messages/ru.json index f4ad47846..e18b00a52 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -669,7 +669,6 @@ "submit": "Принять", "submitting": "Принимаем…", "errorTaken": "Это имя пользователя или адрес электронной почты уже используется.", - "errorRateLimit": "Слишком много попыток. Подождите несколько минут и попробуйте снова.", "errorFailed": "Не сработало. Попробуйте через минуту.", "errorOffline": "Не дошло до сервера. Проверьте подключение и попробуйте снова.", "ssoContinue": "Продолжить с {provider}", @@ -691,7 +690,8 @@ "toLogin": "Перейти ко входу" }, "deletionPending": "Этот аккаунт запланирован к удалению на {date} и заблокирован до тех пор. Администратор этого сервера может отменить удаление до этой даты.", - "deletionPendingNoDate": "Этот аккаунт запланирован к удалению и заблокирован. Администратор этого сервера может отменить удаление до его завершения." + "deletionPendingNoDate": "Этот аккаунт запланирован к удалению и заблокирован. Администратор этого сервера может отменить удаление до его завершения.", + "signInRateLimited": "Слишком много попыток. Подождите несколько минут и попробуйте снова." }, "dashboard": { "title": "Сводка", diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index e07989fdc..bcc84657b 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -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); } diff --git a/src/components/auth/claim-profile.tsx b/src/components/auth/claim-profile.tsx index 0a3b60c9e..540fa04ea 100644 --- a/src/components/auth/claim-profile.tsx +++ b/src/components/auth/claim-profile.tsx @@ -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"; } diff --git a/src/components/auth/mfa-login-step.tsx b/src/components/auth/mfa-login-step.tsx index bbaa82bb8..ef63de07b 100644 --- a/src/components/auth/mfa-login-step.tsx +++ b/src/components/auth/mfa-login-step.tsx @@ -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)); diff --git a/src/components/settings/access/__tests__/managed-profile-handover-errors.test.ts b/src/components/settings/access/__tests__/managed-profile-handover-errors.test.ts index b4e4b479a..3fa89e080 100644 --- a/src/components/settings/access/__tests__/managed-profile-handover-errors.test.ts +++ b/src/components/settings/access/__tests__/managed-profile-handover-errors.test.ts @@ -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) => { diff --git a/src/lib/api/__tests__/localized-error.test.ts b/src/lib/api/__tests__/localized-error.test.ts index 3be2a4630..650bfe552 100644 --- a/src/lib/api/__tests__/localized-error.test.ts +++ b/src/lib/api/__tests__/localized-error.test.ts @@ -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/, + ); + }); +}); diff --git a/src/lib/api/localized-error.ts b/src/lib/api/localized-error.ts index ee102ad73..7e1b85403 100644 --- a/src/lib/api/localized-error.ts +++ b/src/lib/api/localized-error.ts @@ -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); +}