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:
Marc-André Bombeck committed 2026-11-12 13:54:34 +01:00
1 parent 367ba65bf6
commit 1dde3034c6
16 files changed
+95 -32

No files matched your search

+5 -5
View File
@@ -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);
}
+2 -2
View File
@@ -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";
}
+3 -3
View File
@@ -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) => {
+44 -1
View File
@@ -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/,
);
});
});
+20
View File
@@ -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);
}