fix(auth): say a refused sign-in in the reader's language

The sign-in page (password and passkey), the second-factor step and
registration printed the server's English error text, so a German or
French page said "Invalid credentials" or "Invalid code". They now
resolve the error code through localizedApiError like the other forms
and fall back to the page's own sentence. Registration still shows a
422 as sent: that is the password feedback, which the server already
words in the reader's language.

A 429 carries the generic `http.rate_limited` code, which had no
sentence, so a person locked out by the limiter would have read only
"Login failed". The catalog now has one, reusing the wording of the
profile take-over's rate-limit message.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:21:20 +01:00
1 parent a4766824ef
commit ecb219f346
16 files changed
+147 -19

No files matched your search

+3
View File
@@ -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."
+3
View File
@@ -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."
+3
View File
@@ -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."
+3
View File
@@ -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."
+3
View File
@@ -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."
+3
View File
@@ -61,6 +61,9 @@
"signIn": "로그인"
},
"apiErrors": {
"http": {
"rate_limited": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요."
},
"measurement": {
"create": {
"invalid": "측정 내용을 확인하고 다시 시도해 주세요."
+3
View File
@@ -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."
+3
View File
@@ -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."
+3
View File
@@ -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."
+3
View File
@@ -61,6 +61,9 @@
"signIn": "Вход"
},
"apiErrors": {
"http": {
"rate_limited": "Слишком много попыток. Подождите несколько минут и попробуйте снова."
},
"measurement": {
"create": {
"invalid": "Проверьте данные измерения и попробуйте снова."
@@ -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,
@@ -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(<LoginPage />, { 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(
<MfaLoginStep
mfaTicket="ticket"
methods={["totp"]}
onSuccess={() => {}}
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");
});
});
+3 -6
View File
@@ -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);
}
@@ -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<ReturnType<typeof stubFetch>["on"]>[2],
) {
stubFetch()
.on("GET", "/api/auth/me", { status: 401, error: "Unauthorized" })
.on("POST", "/api/auth/register", response);
const { screen } = await renderWithApp(<RegisterPage />, { 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();
});
});
+6 -7
View File
@@ -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);
+3 -4
View File
@@ -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));