feat(auth): implement all 16 homepage/login/register review items

- 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

File diff suppressed because it is too large. Load diff
+18
View File
@@ -48,6 +48,7 @@ export function LoginForm({
const [code, setCode] = useState("");
const [needs2fa, setNeeds2fa] = useState(false);
const [error, setError] = useState<string | null>(null);
const [unverified, setUnverified] = useState(false);
const [pending, setPending] = useState(false);
const showFooter = variant === "page";
@@ -56,6 +57,7 @@ export function LoginForm({
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setUnverified(false);
setPending(true);
try {
if (!needs2fa) {
@@ -71,6 +73,7 @@ export function LoginForm({
}
if (pre === "unverified") {
setError(t("errorUnverified"));
setUnverified(true);
return;
}
if (pre === "twofactor") {
@@ -220,6 +223,21 @@ export function LoginForm({
</p>
)}
{unverified && (
<p
className="m-0 text-center text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<Link
href="/verify"
className="font-extrabold transition-colors hover:underline"
style={{ color: "var(--color-primary)" }}
>
{t("verifyEmailCta")}
</Link>
</p>
)}
<button
type="submit"
disabled={pending}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,99 @@
"use client";
import { Mail } from "lucide-react";
import { useTranslations } from "next-intl";
import { useActionState, useId } from "react";
import {
type ResendVerificationState,
resendVerification,
} from "@/actions/verify";
const INITIAL_STATE: ResendVerificationState = { ok: false, error: null };
/**
* Lets a visitor whose verification token expired or was mangled request a
* fresh mail straight from the invalid card of /verify. The server answers
* identically for unknown addresses, so no registered address can be probed;
* the live feedback is limited to "we tried" vs "throttled".
*/
export function ResendVerificationForm() {
const t = useTranslations("pages.verify");
const fieldId = useId();
const [state, formAction, isPending] = useActionState(
resendVerification,
INITIAL_STATE,
);
return (
<form action={formAction} className="mt-1 w-full space-y-2 text-left">
{state.ok ? (
<p
role="status"
className="auth-alert auth-alert--success m-0 animate-fade-in-up"
>
{t("resendSent")}
</p>
) : state.error ? (
<p role="alert" className="auth-alert m-0 animate-fade-in-up">
{t("resendFailed")}
</p>
) : null}
<label htmlFor={fieldId} className="auth-label">
{t("resendEmail")}
</label>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative flex-1">
<Mail
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id={fieldId}
name="email"
type="email"
required
autoComplete="email"
placeholder={t("resendEmail")}
className="auth-input has-icon"
/>
</div>
<button
type="submit"
disabled={isPending}
className="btn-brand btn-shine mb-0.5 shrink-0 px-5 py-3 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60"
>
{isPending ? (
<span className="inline-flex items-center justify-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>
{t("resendButton")}
</span>
) : (
t("resendButton")
)}
</button>
</div>
</form>
);
}
@@ -4,17 +4,17 @@ import { describe, expect, it } from "vitest";
const compactCallSites = new Map<string, number>([
["src/components/top-header.tsx", 1],
// /login and /register render their rosters through this shared frame.
["src/components/auth/auth-page-frame.tsx", 1],
["src/app/(site)/friends/page.tsx", 1],
["src/app/(site)/guilds/[id]/page.tsx", 1],
["src/app/(site)/leaderboard/page.tsx", 1],
["src/app/(site)/login/page.tsx", 2],
["src/app/(site)/me/page.tsx", 1],
["src/app/(site)/messages/page.tsx", 2],
["src/app/(site)/page.tsx", 2],
["src/app/(site)/radio/leaderboard/page.tsx", 1],
["src/app/(site)/radio/shouts/page.tsx", 1],
["src/app/(site)/rankings/page.tsx", 1],
["src/app/(site)/register/page.tsx", 2],
["src/app/(site)/search/page.tsx", 1],
["src/app/(site)/u/[username]/page.tsx", 2],
]);
+9
View File
@@ -16,6 +16,15 @@ export function TypewriterText({
useEffect(() => {
setDisplayed(text[0] ?? "");
setDone(text.length <= 1);
// Respect "reduce motion": show the full text instead of animating the
// letters in, since the blinking cursor is pure CSS-driven decoration.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setDisplayed(text);
setDone(true);
return;
}
let i = 1;
const id = setInterval(() => {
if (i < text.length) {