feat(auth): implement all 16 homepage/login/register review items
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m47s
CI / tests-ui (push) Successful in 2m30s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m56s

- add countArticles() (published-only, mirrors news-list) and warm total_articles
- localize homepage metadata; bind articleCount to both stats; unique photo alts
- drop duplicate news date and the mascot preload priorities
- extract shared AuthPageFrame/AuthUsersCards used by /login and /register
- login: localized noindex metadata, session redirect via safeRedirectPath,
  ?from passthrough from proxy, unified auth roster cache keys, registered notice
- register: localized metadata, session redirect to /me, unified cache keys
- add resend-verification flow on /verify with rate-limited non-enumerable action
- add safeRedirectPath() with unit tests
- register form: live requirements checklist + password mismatch guard
- login form: unverified state with resend-link CTA
- honour prefers-reduced-motion in TypewriterText
- add 6 translations across all 25 locales
This commit is contained in:
openhands committed 2026-10-08 18:49:27 +02:00
1 parent 8561c3f85e
commit 3933214953
43 files changed
+1037 -490

No files matched your search

+65 -3
View File
@@ -1,6 +1,14 @@
"use client";
import { KeyRound, Lock, Mail, ShieldCheck, UserRound } from "lucide-react";
import {
Check,
KeyRound,
Lock,
Mail,
ShieldCheck,
UserRound,
X,
} from "lucide-react";
import Script from "next/script";
import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl";
@@ -73,6 +81,7 @@ export function RegisterForm({
const [termsError, setTermsError] = useState(false);
const termsRef = useRef<HTMLDivElement>(null);
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [signingIn, setSigningIn] = useState(false);
@@ -83,6 +92,21 @@ export function RegisterForm({
const [referralCode, setReferralCode] = useState("");
const strength = passwordStrength(password, t);
/**
* The password policy the server enforces, evaluated live so the visitor can
* see which rule they are still missing instead of submitting and reading a
* single error back.
*/
const requirements = [
{ label: t("passwordMinLength"), met: password.length >= 12 },
{ label: t("passwordUpper"), met: /[A-Z]/.test(password) },
{ label: t("passwordLower"), met: /[a-z]/.test(password) },
{ label: t("passwordDigit"), met: /\d/.test(password) },
{ label: t("passwordSpecial"), met: /[^A-Za-z0-9]/.test(password) },
];
/** Shown as soon as the confirmation is filled in and disagrees. */
const mismatch = confirmPassword.length > 0 && confirmPassword !== password;
// The invite link is `/register?ref=<username>`; read it client-side so the
// attribution travels with the sign-up form without server state.
useEffect(() => {
@@ -146,9 +170,14 @@ export function RegisterForm({
{/* Form */}
<form
action={(fd) => {
const passwordValue = String(fd.get("password") ?? "");
const confirmation = String(fd.get("password_confirmation") ?? "");
// A mismatch is reported live under the field — no round-trip
// needed just to hear it again from the server.
if (passwordValue !== confirmation) return;
credentialsRef.current = {
username: String(fd.get("username") ?? "").trim(),
password: String(fd.get("password") ?? ""),
password: passwordValue,
};
formAction(fd);
}}
@@ -368,6 +397,26 @@ export function RegisterForm({
</div>
</div>
)}
<ul aria-label={t("password")} className="space-y-1 pt-1">
{requirements.map((r) => (
<li
key={r.label}
className="flex items-center gap-1.5 text-[11px] font-semibold transition-colors duration-200"
style={{
color: r.met
? "var(--color-success)"
: "var(--color-text-muted)",
}}
>
{r.met ? (
<Check className="h-3 w-3 shrink-0" aria-hidden="true" />
) : (
<X className="h-3 w-3 shrink-0" aria-hidden="true" />
)}
{r.label}
</li>
))}
</ul>
</div>
<div className="space-y-1.5">
<label htmlFor="password_confirmation" className="auth-label">
@@ -385,6 +434,8 @@ export function RegisterForm({
type={showConfirm ? "text" : "password"}
placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="auth-input has-icon has-action"
required
/>
@@ -395,6 +446,14 @@ export function RegisterForm({
labelHide={t("hidePassword")}
/>
</div>
{mismatch && (
<p
className="animate-fade-in-up text-xs font-bold"
style={{ color: "var(--color-danger)" }}
>
{t("passwordsDontMatch")}
</p>
)}
</div>
</div>
</div>
@@ -521,8 +580,11 @@ export function RegisterForm({
<button
type="submit"
disabled={isPending || signingIn}
onClick={() => {
onClick={(e) => {
if (!termsAccepted) {
// Don't fire a doomed server round-trip: shake the
// terms box and stay put until it is checked.
e.preventDefault();
setTermsError(true);
termsRef.current?.scrollIntoView({
behavior: "smooth",