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

+183
View File
@@ -0,0 +1,183 @@
import type { ReactNode } from "react";
import { AuthTopBar } from "@/components/auth/auth-top-bar";
import { Reveal } from "@/components/motion-reveal";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card";
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
export interface PublicUser {
username: string;
look: string;
}
/**
* The decorative aurora blobs that sit behind the auth top bar on `/login` and
* `/register`. Purely visual, hence aria-hidden, and shared so both pages keep
* the exact same tint and delay.
*/
export function AuthAurora() {
return (
<div
aria-hidden="true"
className="pointer-events-none absolute -inset-x-16 -top-24 overflow-hidden"
>
<div
className="aurora-blob -left-20 top-0 h-80 w-80"
style={{
background:
"color-mix(in srgb, var(--color-primary) 34%, transparent)",
opacity: 0.45,
}}
/>
<div
className="aurora-blob -right-20 top-6 h-72 w-72"
style={{
background:
"color-mix(in srgb, var(--color-accent) 30%, transparent)",
animationDelay: "-7s",
opacity: 0.4,
}}
/>
</div>
);
}
interface AuthUsersCardsProps {
recentUsers: PublicUser[];
latestUsers: PublicUser[];
/** Title of the online-users card. */
recentTitle: string;
/** Title of the newest-accounts card. */
latestTitle: string;
}
/**
* The "who is online" + "newest citizens" avatar cards both auth pages show in
* their left column. Cards with no users render nothing, so a cold database
* degrades to just the intro panel instead of an empty grid.
*/
export function AuthUsersCards({
recentUsers,
latestUsers,
recentTitle,
latestTitle,
}: AuthUsersCardsProps) {
return (
<>
{recentUsers.length > 0 && (
<SurfaceCard
title={recentTitle}
icon={ICON_NAV_GOODY}
bodyClassName="p-4"
className="card-glow"
>
<AvatarGrid users={recentUsers} online />
</SurfaceCard>
)}
{latestUsers.length > 0 && (
<SurfaceCard
title={latestTitle}
icon={ICON_FRIENDS}
bodyClassName="p-4"
className="card-glow"
>
<AvatarGrid users={latestUsers} />
</SurfaceCard>
)}
</>
);
}
function AvatarGrid({
users,
online,
}: {
users: PublicUser[];
online?: boolean;
}) {
return (
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
{users.map((u) => (
<div key={u.username} title={u.username} className="avatar-tile">
<div className="relative">
<UserAvatarThumbnail
figure={u.look}
alt=""
options={{ direction: 2 }}
className="rounded-lg"
/>
{online && (
<span className="avatar-online absolute -bottom-0.5 -right-0.5" />
)}
</div>
<span className="avatar-name">{u.username}</span>
</div>
))}
</div>
);
}
interface AuthPageFrameProps {
hotelName: string;
logo?: string | null;
/** Title of the form card on the right. */
title: string;
/** One-line intro above the form. */
subtitle: ReactNode;
/** Optional notice rendered at the top of the form card (e.g. "registered"). */
notice?: ReactNode;
/** The form itself. */
form: ReactNode;
/** The left column: intro panel plus `AuthUsersCards`. */
children: ReactNode;
}
/**
* Shared skeleton for the public auth pages: aurora + top bar on top, a fixed
* left column for the promo/roster panels and a sticky right column holding the
* form. Both `/login` and `/register` used to duplicate all of it.
*/
export function AuthPageFrame({
hotelName,
logo,
title,
subtitle,
notice,
form,
children,
}: AuthPageFrameProps) {
return (
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
<div className="relative">
<AuthAurora />
<AuthTopBar hotelName={hotelName} logo={logo} />
</div>
<Reveal>
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
<div className="lg:w-96 shrink-0 space-y-4">{children}</div>
<div className="flex-1 lg:sticky lg:top-6">
<div className="gradient-border">
<SurfaceCard
title={title}
icon={ICON_NAV_ME}
bodyClassName="p-6 sm:p-7"
className="card-glow"
>
{notice}
<p
className="text-sm mb-6 leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{subtitle}
</p>
{form}
</SurfaceCard>
</div>
</div>
</div>
</Reveal>
</div>
);
}
+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}
+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",
@@ -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) {