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
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:
1 parent
8561c3f85e
commit
3933214953
43 files changed
+1037
-490
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user