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));