From 393321495393ba769ecdafb8aae8c595c753621c Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 8 Oct 2026 18:49:27 +0200 Subject: [PATCH] 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 --- src/actions/verify.ts | 54 +++ src/app/(site)/login/page.tsx | 373 ++++++++---------- src/app/(site)/page.tsx | 46 ++- src/app/(site)/register/page.tsx | 316 ++++++--------- src/app/(site)/verify/page.tsx | 2 + src/app/globals.css | 11 + src/components/auth/auth-page-frame.tsx | 183 +++++++++ src/components/auth/login-form.tsx | 18 + src/components/auth/register-form.tsx | 68 +++- .../auth/resend-verification-form.tsx | 99 +++++ .../shared/public-avatar-contract.test.ts | 4 +- src/components/typewriter-text.tsx | 9 + src/lib/auth/safe-redirect.test.ts | 30 ++ src/lib/auth/safe-redirect.ts | 17 + src/lib/services/cache-warmup.ts | 6 + src/lib/services/public-counters.test.ts | 10 + src/lib/services/public-counters.ts | 25 +- src/messages/ar.json | 10 +- src/messages/bg.json | 10 +- src/messages/cs.json | 10 +- src/messages/da.json | 10 +- src/messages/de.json | 10 +- src/messages/el.json | 10 +- src/messages/en.json | 10 +- src/messages/es.json | 10 +- src/messages/fi.json | 10 +- src/messages/fr.json | 10 +- src/messages/hr.json | 10 +- src/messages/hu.json | 10 +- src/messages/it.json | 10 +- src/messages/ja.json | 10 +- src/messages/nl.json | 10 +- src/messages/no.json | 10 +- src/messages/pl.json | 10 +- src/messages/pt.json | 10 +- src/messages/ro.json | 10 +- src/messages/ru.json | 10 +- src/messages/sk.json | 10 +- src/messages/sr.json | 10 +- src/messages/sv.json | 10 +- src/messages/tr.json | 10 +- src/messages/uk.json | 10 +- src/proxy.ts | 6 +- 43 files changed, 1037 insertions(+), 490 deletions(-) create mode 100644 src/actions/verify.ts create mode 100644 src/components/auth/auth-page-frame.tsx create mode 100644 src/components/auth/resend-verification-form.tsx create mode 100644 src/lib/auth/safe-redirect.test.ts create mode 100644 src/lib/auth/safe-redirect.ts diff --git a/src/actions/verify.ts b/src/actions/verify.ts new file mode 100644 index 00000000..a950034e --- /dev/null +++ b/src/actions/verify.ts @@ -0,0 +1,54 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { sendVerification } from "@/lib/auth/email-verification"; +import { db, User } from "@/lib/db"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; + +export interface ResendVerificationState { + ok: boolean; + error: string | null; +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Re-send a verification e-mail for an address the visitor typed on /verify. + * + * Deliberately reports success even when no matching unverified account exists: + * a distinct failure would let anyone probe which addresses are registered. The + * identical-privacy behaviour also applies to the e-mail templates, which are + * only sent for real accounts. Rate limiting is the spam defence. + */ +export async function resendVerification( + _prevState: ResendVerificationState, + formData: FormData, +): Promise { + const email = String(formData.get("email") ?? "") + .normalize("NFC") + .trim() + .toLowerCase(); + if (!EMAIL_RE.test(email)) { + return { ok: false, error: "invalid" }; + } + + const ip = await clientIp(); + if (!(await rateLimit(`verify:resend:${ip}`, 3, 10 * 60_000)).ok) { + return { ok: false, error: "rateLimited" }; + } + + try { + const [user] = await db + .select({ id: User.id, mailVerified: User.mailVerified }) + .from(User) + .where(eq(User.mail, email)) + .limit(1); + if (user && user.mailVerified !== "1") { + await sendVerification(email); + } + } catch { + return { ok: false, error: "unavailable" }; + } + + return { ok: true, error: null }; +} diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index ad88f3ec..063ba05e 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -1,20 +1,41 @@ import { count, desc, eq } from "drizzle-orm"; +import type { Metadata } from "next"; import { headers } from "next/headers"; import Image from "next/image"; +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; -import { AuthTopBar } from "@/components/auth/auth-top-bar"; +import { + AuthPageFrame, + AuthUsersCards, +} from "@/components/auth/auth-page-frame"; import { LoginForm } from "@/components/auth/login-form"; -import { Reveal } from "@/components/motion-reveal"; -import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { SurfaceCard } from "@/components/surface-card"; +import { auth } from "@/lib/auth"; +import { safeRedirectPath } from "@/lib/auth/safe-redirect"; import { cached } from "@/lib/cache"; import { db, User } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; import { captchaConfig } from "@/lib/services/captcha"; import { siteSettings } from "@/lib/services/site-settings"; -import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons"; -export default async function LoginPage() { +export async function generateMetadata(): Promise { + const t = await getTranslations("pages.login"); + const title = t("title"); + const description = t("subtitle"); + return { + title, + description, + // Sign-in is a dead end for crawlers and duplicates the homepage copy. + robots: { index: false, follow: false }, + openGraph: { title, description, type: "website" }, + }; +} + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ from?: string; registered?: string }>; +}) { const t = await getTranslations("pages.login"); const [hotelName, cfg, logo] = await Promise.all([ resolveHotelName(), @@ -23,6 +44,14 @@ export default async function LoginPage() { ]); const nonce = (await headers()).get("x-nonce") ?? undefined; + const sp = await searchParams; + const redirectTo = safeRedirectPath(sp.from); + + // Already signed in: the form has nothing to do here. Honour `from` first so + // an admin bounced off /admin lands back where they were heading. + const session = await auth(); + if (session?.user?.id) redirect(redirectTo); + const [online, recentUsers, latestUsers] = await Promise.all([ cached("online_count", 10_000, () => db @@ -31,227 +60,153 @@ export default async function LoginPage() { .where(eq(User.online, "1")) .then((rows) => rows[0]?.total ?? 0), ).catch(() => 0), - db - .select({ username: User.username, look: User.look }) - .from(User) - .where(eq(User.online, "1")) - .limit(8) - .catch(() => []), - db - .select({ username: User.username, look: User.look }) - .from(User) - .orderBy(desc(User.accountCreated)) - .limit(8) - .catch(() => []), + cached( + "auth_online_users", + 10_000, + () => + db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.online, "1")) + .limit(8), + { staleMs: 30000 }, + ).catch(() => []), + cached( + "auth_latest_users", + 30_000, + () => + db + .select({ username: User.username, look: User.look }) + .from(User) + .orderBy(desc(User.accountCreated)) + .limit(8), + { staleMs: 60000 }, + ).catch(() => []), ]); + // `/login?registered=1` is where the sign-up form lands when it could not + // auto sign-in (e-mail verification still pending). Without this notice the + // visitor would only see an empty login form and no sign their account + // exists. + const notice = + sp.registered === "1" ? ( +

+ {t("registeredSuccess")} +

+ ) : undefined; + return ( -
-
+ + } + > +