refactor(auth): merge the duplicate login form and localize the auth screens
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 1m8s
CI / tests-integration (push) Successful in 1m53s
CI / tests-unit (push) Successful in 1m59s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m33s

`home-login-form.tsx` and `login-form.tsx` were two ~240-line near-identical
components. Delete the former and give `LoginForm` a `variant` prop:

- `variant="page"`   sr-only labels plus the register/forgot footer (/login)
- `variant="compact"` visible labels, no footer (homepage sidebar)

Field ids now come from `useId()`, so the two usages can never collide, and the
hardcoded "Show"/"Hide"/"Loading" strings are translated.

Localization of the login and register screens:

- `home-login-form.tsx` was entirely hardcoded English.
- `passwordStrength()` returned hardcoded "Weak"/"Fair"/"Good"/"Strong".
- `register.ts` returned only English strings. It now returns a
  locale-independent `code` next to the message, and the form renders
  `t(code)` with the English string as a fallback.
- Backfilled the new keys across all 25 locales, plus the login/register
  strings that were still English in most of them. `ar`, `fi` and `ja` had
  their entire login/register namespace in English and are now filled in.
  Locale parity stays at 0 missing keys, as `i18n:check` requires.

Copy that did not match the enforced rules: the UI advertised "min 8 chars"
(EN) / "min 6 tekens" (NL) while registration requires 12 characters plus an
uppercase, a lowercase, a digit and a special character. Corrected in every
locale. `password-reset.ts` enforced only 6 characters and is raised to 12 to
match registration.

Accessibility: `login-form.tsx` had no `<label>`, no `id` and no `required` on
any field. All three are now present, and error banners are announced with
`role="alert"`.

Adds `src/i18n/auth-messages.test.ts`, which asserts every `RegisterErrorCode`
resolves to a non-empty message in all 25 locales; verified it fails when a key
is removed. The existing register tests now also assert the error `code`.
This commit is contained in:
openhands committed 2026-10-04 18:50:23 +02:00
1 parent 3d828a61ab
commit 6bffc53779
33 files changed
+1756 -842

No files matched your search

+123 -43
View File
@@ -2,7 +2,7 @@
import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl";
import { type FormEvent, useState } from "react";
import { type FormEvent, useId, useState } from "react";
import { precheckLogin } from "@/actions/auth-precheck";
import {
type CaptchaPublicConfig,
@@ -12,14 +12,34 @@ import {
import Link from "@/components/link";
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
export type LoginFormVariant =
/** Full page: labelled fields, plus the register / forgot-password footer. */
| "page"
/** Homepage sidebar: visible labels, no footer, tighter spacing. */
| "compact";
export interface LoginFormProps {
captcha?: CaptchaPublicConfig;
nonce?: string;
variant?: LoginFormVariant;
/** Where to land after a successful sign-in. */
redirectTo?: string;
}
/**
* The single sign-in form for both `/login` and the homepage sidebar. It runs a
* cheap server-side precheck first so the visitor gets a specific reason
* (bad password, unverified email, 2FA required, captcha) instead of NextAuth's
* generic failure, then completes the credentials sign-in.
*/
export function LoginForm({
captcha = { provider: "none" },
nonce,
}: {
captcha?: CaptchaPublicConfig;
nonce?: string;
}) {
variant = "page",
redirectTo = "/me",
}: LoginFormProps = {}) {
const t = useTranslations("pages.login");
const fieldId = useId();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
@@ -28,6 +48,10 @@ export function LoginForm({
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const showFooter = variant === "page";
const labelled = variant === "compact";
const lockCredentials = needs2fa ? "opacity-50 pointer-events-none" : "";
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
@@ -67,7 +91,7 @@ export function LoginForm({
);
return;
}
window.location.href = "/me";
window.location.href = redirectTo;
} catch {
setError(needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"));
} finally {
@@ -79,60 +103,112 @@ export function LoginForm({
<>
{needs2fa && (
<p
className="animate-fade-in-up text-sm font-semibold mb-4"
className="animate-fade-in-up mb-4 text-sm font-semibold"
style={{ color: "var(--color-text-muted)" }}
>
{t("subtitle2fa")}
</p>
)}
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div className={needs2fa ? "opacity-50 pointer-events-none" : ""}>
<form
onSubmit={onSubmit}
aria-label={t("signIn")}
className="relative flex flex-col gap-4"
>
<div className={labelled ? "space-y-1" : lockCredentials}>
<label
htmlFor={`${fieldId}-username`}
className={
labelled
? "block text-xs font-bold uppercase tracking-wider"
: "sr-only"
}
style={{ color: "var(--color-text-muted)" }}
>
{t("username")}
</label>
<input
id={`${fieldId}-username`}
name="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
autoComplete="username"
disabled={needs2fa}
required
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
borderColor: needs2fa
? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)"
: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/>
</div>
<div
className={`${needs2fa ? "opacity-50 pointer-events-none" : ""} relative`}
>
<div className={`${labelled ? "space-y-1" : lockCredentials} relative`}>
<label
htmlFor={`${fieldId}-password`}
className={
labelled
? "block text-xs font-bold uppercase tracking-wider"
: "sr-only"
}
style={{ color: "var(--color-text-muted)" }}
>
{t("password")}
</label>
<input
id={`${fieldId}-password`}
name="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
autoComplete="current-password"
disabled={needs2fa}
required
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
borderColor: needs2fa
? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)"
: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 transition-opacity hover:opacity-100"
style={{ color: "var(--color-text-muted)" }}
tabIndex={-1}
aria-label={showPassword ? t("hidePassword") : t("showPassword")}
aria-pressed={showPassword}
>
{showPassword ? "Hide" : "Show"}
{showPassword ? t("hidePassword") : t("showPassword")}
</button>
</div>
{needs2fa ? (
<div className="animate-scale-in">
<div className={`animate-scale-in ${labelled ? "space-y-1" : ""}`}>
<label
htmlFor={`${fieldId}-2fa`}
className={
labelled
? "block text-xs font-bold uppercase tracking-wider"
: "sr-only"
}
style={{ color: "var(--color-text-muted)" }}
>
{t("codePlaceholder")}
</label>
<input
id={`${fieldId}-2fa`}
name="code"
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("codePlaceholder")}
@@ -153,7 +229,9 @@ export function LoginForm({
{error && (
<p
className="animate-fade-in-up text-sm text-center font-semibold"
role="alert"
aria-live="polite"
className="m-0 animate-fade-in-up text-center text-sm font-semibold"
style={{ color: "var(--color-danger)" }}
>
{error}
@@ -163,7 +241,7 @@ export function LoginForm({
<button
type="submit"
disabled={pending}
className="btn-shine w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg disabled:opacity-60"
className="btn-shine w-full cursor-pointer rounded-xl py-3.5 text-sm font-extrabold shadow-lg transition-all duration-200 hover:scale-[1.02] active:scale-95 disabled:opacity-60"
style={{
background: "var(--color-primary)",
color: "var(--color-primary-foreground)",
@@ -174,11 +252,11 @@ export function LoginForm({
{pending ? (
<span className="inline-flex items-center justify-center gap-2">
<svg
className="animate-spin h-4 w-4"
className="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
role="img"
aria-label="Loading"
aria-label={t("pleaseWait")}
>
<circle
className="opacity-25"
@@ -204,27 +282,29 @@ export function LoginForm({
</button>
</form>
<p
className="text-xs text-center mt-6"
style={{ color: "var(--color-text-muted)" }}
>
{t("noAccount")}{" "}
<Link
href="/register"
className="font-extrabold hover:underline"
style={{ color: "var(--color-primary)" }}
{showFooter && (
<p
className="mt-6 text-center text-xs"
style={{ color: "var(--color-text-muted)" }}
>
{t("createOne")}
</Link>{" "}
·{" "}
<Link
href="/forgot"
className="font-extrabold hover:underline"
style={{ color: "var(--color-primary)" }}
>
{t("forgotPassword")}
</Link>
</p>
{t("noAccount")}{" "}
<Link
href="/register"
className="font-extrabold hover:underline"
style={{ color: "var(--color-primary)" }}
>
{t("createOne")}
</Link>{" "}
·{" "}
<Link
href="/forgot"
className="font-extrabold hover:underline"
style={{ color: "var(--color-primary)" }}
>
{t("forgotPassword")}
</Link>
</p>
)}
</>
);
}