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
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:
1 parent
3d828a61ab
commit
6bffc53779
33 files changed
+1756
-842
No files matched your search
@@ -1,229 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import {
|
||||
type CaptchaPublicConfig,
|
||||
CaptchaWidget,
|
||||
readCaptchaToken,
|
||||
} from "@/components/auth/captcha-widget";
|
||||
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||
|
||||
export function HomeLoginForm({
|
||||
captcha = { provider: "none" },
|
||||
nonce,
|
||||
}: {
|
||||
captcha?: CaptchaPublicConfig;
|
||||
nonce?: string;
|
||||
} = {}) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [needs2fa, setNeeds2fa] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
if (!needs2fa) {
|
||||
const captchaToken = readCaptchaToken(e.currentTarget, captcha);
|
||||
const pre = await precheckLogin(username, password, captchaToken);
|
||||
if (pre === "invalid") {
|
||||
setError("Invalid username or password");
|
||||
return;
|
||||
}
|
||||
if (pre === "captcha") {
|
||||
setError("Captcha verification failed. Please try again.");
|
||||
return;
|
||||
}
|
||||
if (pre === "unverified") {
|
||||
setError("Please verify your email before signing in.");
|
||||
return;
|
||||
}
|
||||
if (pre === "twofactor") {
|
||||
setNeeds2fa(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const res = await signInWithTransientRetry(() =>
|
||||
signIn("credentials", {
|
||||
username,
|
||||
password,
|
||||
code,
|
||||
redirect: false,
|
||||
}),
|
||||
);
|
||||
if (!res || res.error) {
|
||||
setError(
|
||||
needs2fa ? "Invalid 2FA code" : "Invalid username or password",
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.location.href = "/";
|
||||
} catch {
|
||||
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="relative flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="login-username"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Your username"
|
||||
autoComplete="username"
|
||||
disabled={needs2fa}
|
||||
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: needs2fa
|
||||
? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)"
|
||||
: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Your password"
|
||||
autoComplete="current-password"
|
||||
disabled={needs2fa}
|
||||
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: needs2fa
|
||||
? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)"
|
||||
: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<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"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needs2fa ? (
|
||||
<div className="space-y-1 animate-scale-in">
|
||||
<label
|
||||
htmlFor="login-2fa"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
2FA Code
|
||||
</label>
|
||||
<input
|
||||
id="login-2fa"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="Enter your 2FA code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
className="animate-fade-in-up m-0 text-center text-sm font-semibold"
|
||||
style={{ color: "var(--color-danger)" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="btn-shine w-full cursor-pointer 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"
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow:
|
||||
"0 4px 20px color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
}}
|
||||
>
|
||||
{pending ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Please wait...
|
||||
</span>
|
||||
) : needs2fa ? (
|
||||
"Verify"
|
||||
) : (
|
||||
"Login"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import Script from "next/script";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import { type RegisterState, register } from "@/actions/register";
|
||||
import {
|
||||
type RegisterErrorCode,
|
||||
type RegisterState,
|
||||
register,
|
||||
} from "@/actions/register";
|
||||
import Link from "@/components/link";
|
||||
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||
|
||||
@@ -16,7 +20,10 @@ interface RegisterFormProps {
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
function passwordStrength(pw: string): {
|
||||
function passwordStrength(
|
||||
pw: string,
|
||||
t: (key: string) => string,
|
||||
): {
|
||||
score: number;
|
||||
label: string;
|
||||
color: string;
|
||||
@@ -28,10 +35,10 @@ function passwordStrength(pw: string): {
|
||||
if (/\d/.test(pw)) score += 1;
|
||||
if (/[^a-zA-Z0-9]/.test(pw)) score += 1;
|
||||
|
||||
if (score <= 1) return { score, label: "Weak", color: "#ef4444" };
|
||||
if (score <= 2) return { score, label: "Fair", color: "#f59e0b" };
|
||||
if (score <= 3) return { score, label: "Good", color: "#22c55e" };
|
||||
return { score: 5, label: "Strong", color: "#16a34a" };
|
||||
if (score <= 1) return { score, label: t("strengthWeak"), color: "#ef4444" };
|
||||
if (score <= 2) return { score, label: t("strengthFair"), color: "#f59e0b" };
|
||||
if (score <= 3) return { score, label: t("strengthGood"), color: "#22c55e" };
|
||||
return { score: 5, label: t("strengthStrong"), color: "#16a34a" };
|
||||
}
|
||||
|
||||
export function RegisterForm({
|
||||
@@ -46,6 +53,21 @@ export function RegisterForm({
|
||||
RegisterState,
|
||||
FormData
|
||||
>(register, { error: null, ok: false });
|
||||
|
||||
/**
|
||||
* Prefer the locale-independent `code` so the message follows the visitor's
|
||||
* language, falling back to the server's English string when a code has no
|
||||
* translation yet.
|
||||
*/
|
||||
const translateErrorCode = (code: RegisterErrorCode | undefined) => {
|
||||
if (!code) return null;
|
||||
try {
|
||||
return t(code);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const errorMessage = error ?? translateErrorCode(state.code) ?? state.error;
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [termsError, setTermsError] = useState(false);
|
||||
const termsRef = useRef<HTMLDivElement>(null);
|
||||
@@ -58,7 +80,7 @@ export function RegisterForm({
|
||||
);
|
||||
const autoLoginStartedRef = useRef(false);
|
||||
const [referralCode, setReferralCode] = useState("");
|
||||
const strength = passwordStrength(password);
|
||||
const strength = passwordStrength(password, t);
|
||||
|
||||
// The invite link is `/register?ref=<username>`; read it client-side so the
|
||||
// attribution travels with the sign-up form without server state.
|
||||
@@ -186,12 +208,14 @@ export function RegisterForm({
|
||||
borderRadius: "0 0 12px 12px",
|
||||
}}
|
||||
>
|
||||
{(error || state.error) && (
|
||||
{errorMessage && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="animate-fade-in-up p-3 rounded-lg text-sm font-semibold text-primary-foreground"
|
||||
style={{ backgroundColor: "var(--color-danger)" }}
|
||||
>
|
||||
{error || state.error}
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user