diff --git a/messages/de.json b/messages/de.json
index 737f6dce7..16b566b1a 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -61,6 +61,9 @@
"signIn": "Anmelden"
},
"apiErrors": {
+ "http": {
+ "rate_limited": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut."
+ },
"measurement": {
"create": {
"invalid": "Prüfe die Messdaten und versuche es erneut."
diff --git a/messages/en.json b/messages/en.json
index 8877c35d5..064f8b06b 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -61,6 +61,9 @@
"signIn": "Sign in"
},
"apiErrors": {
+ "http": {
+ "rate_limited": "Too many attempts. Wait a few minutes and try again."
+ },
"measurement": {
"create": {
"invalid": "Check the measurement details and try again."
diff --git a/messages/es.json b/messages/es.json
index ad30b5020..c12a57a21 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -61,6 +61,9 @@
"signIn": "Iniciar sesión"
},
"apiErrors": {
+ "http": {
+ "rate_limited": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo."
+ },
"measurement": {
"create": {
"invalid": "Comprueba los datos de la medición e inténtalo de nuevo."
diff --git a/messages/fr.json b/messages/fr.json
index ed15c1527..821f7d145 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -61,6 +61,9 @@
"signIn": "Se connecter"
},
"apiErrors": {
+ "http": {
+ "rate_limited": "Trop de tentatives. Attendez quelques minutes puis réessayez."
+ },
"measurement": {
"create": {
"invalid": "Vérifiez les données de la mesure et réessayez."
diff --git a/messages/it.json b/messages/it.json
index bb602f60a..f80a5f26f 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -61,6 +61,9 @@
"signIn": "Accedi"
},
"apiErrors": {
+ "http": {
+ "rate_limited": "Troppi tentativi. Attendi qualche minuto e riprova."
+ },
"measurement": {
"create": {
"invalid": "Controlla i dati della misurazione e riprova."
diff --git a/messages/ko.json b/messages/ko.json
index a7baf05aa..088f3936d 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -61,6 +61,9 @@
"signIn": "로그인"
},
"apiErrors": {
+ "http": {
+ "rate_limited": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요."
+ },
"measurement": {
"create": {
"invalid": "측정 내용을 확인하고 다시 시도해 주세요."
diff --git a/messages/nl.json b/messages/nl.json
index 67807173c..697d99ab5 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -61,6 +61,9 @@
"signIn": "Aanmelden"
},
"apiErrors": {
+ "http": {
+ "rate_limited": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw."
+ },
"measurement": {
"create": {
"invalid": "Controleer de gegevens van de meting en probeer het opnieuw."
diff --git a/messages/pl.json b/messages/pl.json
index 6bc8109e9..c9664179d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -61,6 +61,9 @@
"signIn": "Zaloguj się"
},
"apiErrors": {
+ "http": {
+ "rate_limited": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie."
+ },
"measurement": {
"create": {
"invalid": "Sprawdź dane pomiaru i spróbuj ponownie."
diff --git a/messages/pt-BR.json b/messages/pt-BR.json
index 5efe0f404..82b1d7fca 100644
--- a/messages/pt-BR.json
+++ b/messages/pt-BR.json
@@ -61,6 +61,9 @@
"signIn": "Entrar"
},
"apiErrors": {
+ "http": {
+ "rate_limited": "Muitas tentativas. Aguarde alguns minutos e tente novamente."
+ },
"measurement": {
"create": {
"invalid": "Verifique os dados da medição e tente novamente."
diff --git a/messages/ru.json b/messages/ru.json
index 412c87fa9..74d83a863 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -61,6 +61,9 @@
"signIn": "Вход"
},
"apiErrors": {
+ "http": {
+ "rate_limited": "Слишком много попыток. Подождите несколько минут и попробуйте снова."
+ },
"measurement": {
"create": {
"invalid": "Проверьте данные измерения и попробуйте снова."
diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json
index 89b8b678b..a99344b92 100644
--- a/src/__tests__/baselines/version-stamp-comments.json
+++ b/src/__tests__/baselines/version-stamp-comments.json
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2892,
- "total": 9217,
+ "total": 9216,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -557,7 +557,7 @@
"app/api/workouts/route.ts": 2,
"app/auth/claim/page.tsx": 1,
"app/auth/login/page.tsx": 5,
- "app/auth/register/page.tsx": 4,
+ "app/auth/register/page.tsx": 3,
"app/c/[token]/__tests__/page.test.tsx": 2,
"app/c/[token]/d/[id]/__tests__/route.test.ts": 1,
"app/c/[token]/d/[id]/route.ts": 1,
diff --git a/src/app/auth/login/__tests__/page.browser.test.tsx b/src/app/auth/login/__tests__/page.browser.test.tsx
index c20442d4c..c4b163fde 100644
--- a/src/app/auth/login/__tests__/page.browser.test.tsx
+++ b/src/app/auth/login/__tests__/page.browser.test.tsx
@@ -5,6 +5,7 @@
* starts on the way back, so the previous account's offline copies have to
* be gone before the page is left. A sign-in that stays in the app replaces
* the sign-in page in the history, so Back does not show the form again.
+ * A refusal is said in the reader's language, never in the server's words.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -25,6 +26,8 @@ vi.mock("@/lib/pwa/query-persister", () => ({
}));
vi.mock("@/lib/navigation/load-document", () => ({ loadDocument }));
+import { MfaLoginStep } from "@/components/auth/mfa-login-step";
+
import LoginPage from "../page";
let api: FetchStub;
@@ -79,3 +82,66 @@ describe("sign-in page, password", () => {
expect(history.pushes()).toEqual([]);
});
});
+
+describe("sign-in refusals", () => {
+ async function signInWithPassword() {
+ const { screen } = await renderWithApp(, { user: null });
+ await screen.getByTestId("login-use-password").click();
+ await screen.getByTestId("login-email").fill("someone");
+ await page
+ .elementLocator(document.querySelector("#password")!)
+ .fill("a long enough passphrase");
+ await screen.getByTestId("login-password-submit").click();
+ return screen;
+ }
+
+ it("says the sign-in failed instead of the server's words", async () => {
+ api.on("POST", "/api/auth/login", {
+ status: 401,
+ error: "Invalid credentials",
+ meta: { errorCode: "http.unauthenticated" },
+ });
+ const screen = await signInWithPassword();
+ await expect.element(screen.getByText("Login failed")).toBeVisible();
+ expect(document.body.textContent).not.toContain("Invalid credentials");
+ });
+
+ it("says to wait when the limit is reached", async () => {
+ api.on("POST", "/api/auth/login", {
+ status: 429,
+ error: "Too many login attempts. Please try again later.",
+ meta: { errorCode: "http.rate_limited" },
+ });
+ const screen = await signInWithPassword();
+ await expect
+ .element(
+ screen.getByText(
+ "Too many attempts. Wait a few minutes and try again.",
+ ),
+ )
+ .toBeVisible();
+ });
+
+ it("does the same for the second factor", async () => {
+ api.on("POST", "/api/auth/mfa/verify", {
+ status: 401,
+ error: "Invalid code",
+ meta: { errorCode: "http.unauthenticated" },
+ });
+ const { screen } = await renderWithApp(
+ {}}
+ onCancel={() => {}}
+ />,
+ { user: null },
+ );
+ await screen.getByTestId("mfa-code-input").fill("123456");
+ await screen.getByTestId("mfa-code-submit").click();
+ await expect
+ .element(screen.getByText("Verification failed. Please try again."))
+ .toBeVisible();
+ expect(document.body.textContent).not.toContain("Invalid code");
+ });
+});
diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx
index 60eb3b53b..e07989fdc 100644
--- a/src/app/auth/login/page.tsx
+++ b/src/app/auth/login/page.tsx
@@ -25,6 +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 { formatDate } from "@/lib/format";
import {
InsecureTransportBanner,
@@ -265,7 +266,7 @@ export default function LoginPage() {
// Route rejections carry the envelope error verbatim; everything
// else (WebAuthn ceremony failures) keeps the descriptive mapping.
if (err instanceof ApiError) {
- setError(err.message || t("auth.loginFailed"));
+ setError(localizedApiError(err, t, "auth.loginFailed"));
} else {
const { key, params } = describePasskeyError(err);
setError(t(key, params));
@@ -309,11 +310,7 @@ export default function LoginPage() {
navigateAfterLogin();
} catch (err) {
if (noteRefusal(err)) return;
- setError(
- err instanceof ApiError && err.message
- ? err.message
- : t("auth.loginFailed"),
- );
+ setError(localizedApiError(err, t, "auth.loginFailed"));
} finally {
setLoading(false);
}
diff --git a/src/app/auth/register/__tests__/page.browser.test.tsx b/src/app/auth/register/__tests__/page.browser.test.tsx
index be17d6e25..4a66bbd99 100644
--- a/src/app/auth/register/__tests__/page.browser.test.tsx
+++ b/src/app/auth/register/__tests__/page.browser.test.tsx
@@ -70,3 +70,40 @@ describe("registration page, already signed in", () => {
expect(window.location.search).toBe("?invite=hlv_x");
});
});
+
+describe("registration refusals", () => {
+ async function register(
+ response: Parameters["on"]>[2],
+ ) {
+ stubFetch()
+ .on("GET", "/api/auth/me", { status: 401, error: "Unauthorized" })
+ .on("POST", "/api/auth/register", response);
+ const { screen } = await renderWithApp(, { user: null });
+ await fill("#email", "new@example.com");
+ await fill("#username", "newcomer");
+ await fill("#password", "a long enough passphrase");
+ await screen.getByRole("button", { name: "Sign up" }).click();
+ return screen;
+ }
+
+ it("says the registration failed instead of the server's words", async () => {
+ const screen = await register({
+ status: 409,
+ error: "Username or email already taken",
+ meta: { errorCode: "http.conflict" },
+ });
+ await expect.element(screen.getByText("Registration failed")).toBeVisible();
+ expect(document.body.textContent).not.toContain("already taken");
+ });
+
+ it("shows the password feedback, which the server words for the reader", async () => {
+ const screen = await register({
+ status: 422,
+ error: "Füge ein oder zwei Wörter hinzu.",
+ meta: { errorCode: "http.invalid" },
+ });
+ await expect
+ .element(screen.getByText("Füge ein oder zwei Wörter hinzu."))
+ .toBeVisible();
+ });
+});
diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx
index 592917c88..8832b0f50 100644
--- a/src/app/auth/register/page.tsx
+++ b/src/app/auth/register/page.tsx
@@ -13,6 +13,7 @@ import { PasswordStrength } from "@/components/ui/password-strength";
import { useTranslations } from "@/lib/i18n/context";
import { detectBrowserTimezone } from "@/lib/tz/format";
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
+import { localizedApiError } from "@/lib/api/localized-error";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
export default function RegisterPage() {
@@ -59,11 +60,8 @@ export default function RegisterPage() {
setLoading(true);
try {
- // v1.4.25 — ship the browser-detected timezone with the
- // signup payload. Server validates against the IANA list and
- // falls back to the admin server-default if the value is
- // bogus, so a tampered or proxied request can never poison
- // the user's stored zone.
+ // The server checks the zone against the IANA list and falls back to
+ // its default, so a tampered value never reaches the account.
const timezone = detectBrowserTimezone();
await apiPost("/api/auth/register", {
email,
@@ -76,10 +74,11 @@ export default function RegisterPage() {
queryClient.clear(); // nothing cached before belongs to the new account
router.replace("/");
} catch (err) {
+ // A 422 says what to fix (the password feedback comes localized).
setError(
- err instanceof ApiError && err.message
+ err instanceof ApiError && err.status === 422 && err.message
? err.message
- : t("auth.registerFailed"),
+ : localizedApiError(err, t, "auth.registerFailed"),
);
} finally {
setLoading(false);
diff --git a/src/components/auth/mfa-login-step.tsx b/src/components/auth/mfa-login-step.tsx
index a136ecd8d..bbaa82bb8 100644
--- a/src/components/auth/mfa-login-step.tsx
+++ b/src/components/auth/mfa-login-step.tsx
@@ -9,6 +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 { currentClientTransport } from "@/lib/auth/client-transport";
/** The server's refusal for a page whose sign-in cookie cannot stick. */
@@ -73,9 +74,7 @@ export function MfaLoginStep({
err instanceof ApiError &&
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
? t("auth.insecureTransport.title")
- : err instanceof ApiError && err.message
- ? err.message
- : t("auth.mfa.verifyFailed"),
+ : localizedApiError(err, t, "auth.mfa.verifyFailed"),
);
} finally {
setLoading(false);
@@ -102,7 +101,7 @@ export function MfaLoginStep({
onSuccess();
} catch (err) {
if (err instanceof ApiError) {
- setError(err.message || t("auth.mfa.verifyFailed"));
+ setError(localizedApiError(err, t, "auth.mfa.verifyFailed"));
} else {
const { key, params } = describePasskeyError(err);
setError(t(key, params));