diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index 101f1952..bcde8e1a 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -1,24 +1,14 @@ -import { desc, eq } from "drizzle-orm"; import type { Metadata } from "next"; -import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { AuthAlert } from "@/components/auth/auth-alert"; import { - AuthPageFrame, - AuthUsersCards, -} from "@/components/auth/auth-page-frame"; -import { AuthPromoCard } from "@/components/auth/auth-promo-card"; + AuthLandingView, + loadAuthLanding, +} from "@/components/auth/auth-landing"; import { LoginForm } from "@/components/auth/login-form"; 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 { cachedOnlineCount } from "@/lib/services/public-counters"; -import { siteSettings } from "@/lib/services/site-settings"; export async function generateMetadata(): Promise { const t = await getTranslations("pages.login"); @@ -43,13 +33,6 @@ export default async function LoginPage({ }>; }) { const t = await getTranslations("pages.login"); - const [hotelName, cfg, logo] = await Promise.all([ - resolveHotelName(), - captchaConfig(), - siteSettings.get("cms_logo", ""), - ]); - const nonce = (await headers()).get("x-nonce") ?? undefined; - const sp = await searchParams; const redirectTo = safeRedirectPath(sp.from); @@ -58,81 +41,44 @@ export default async function LoginPage({ const session = await auth(); if (session?.user?.id) redirect(redirectTo); - const [online, recentUsers, latestUsers] = await Promise.all([ - cachedOnlineCount().catch(() => 0), - cached( - "auth_online_users", - 10_000, - () => - db - .select({ id: User.id, 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({ id: User.id, username: User.username, look: User.look }) - .from(User) - .orderBy(desc(User.accountCreated)) - .limit(8), - { staleMs: 60000 }, - ).catch(() => []), - ]); + const data = await loadAuthLanding(); // `/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. + // exists. `?reset=1` comes from a successful password reset; saying so + // matters because a silent form reads like the reset failed. const notice = sp.registered === "1" ? ( {t("registeredSuccess")} ) : sp.reset === "1" ? ( - // `?reset=1` comes from a successful password reset; saying so matters - // because the visitor just changed their password and a silent form - // reads like the reset failed. {t("passwordChanged")} ) : undefined; return ( - } - > - - - - + /> ); } diff --git a/src/app/(site)/register/page.tsx b/src/app/(site)/register/page.tsx index 7da8a818..c81b64bc 100644 --- a/src/app/(site)/register/page.tsx +++ b/src/app/(site)/register/page.tsx @@ -1,22 +1,12 @@ -import { desc, eq } from "drizzle-orm"; import type { Metadata } from "next"; -import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { - AuthPageFrame, - AuthUsersCards, -} from "@/components/auth/auth-page-frame"; -import { AuthPromoCard } from "@/components/auth/auth-promo-card"; + AuthLandingView, + loadAuthLanding, +} from "@/components/auth/auth-landing"; import { RegisterForm } from "@/components/auth/register-form"; import { auth } from "@/lib/auth"; -import { cached } from "@/lib/cache"; - -import { db, User } from "@/lib/db"; -import { resolveHotelName } from "@/lib/hotel-name"; -import { captchaConfig } from "@/lib/services/captcha"; -import { cachedOnlineCount } from "@/lib/services/public-counters"; -import { siteSettings } from "@/lib/services/site-settings"; export async function generateMetadata(): Promise { const t = await getTranslations("pages.register"); @@ -30,77 +20,38 @@ export async function generateMetadata(): Promise { } export default async function RegisterPage() { - const tpr = await getTranslations("pages.register"); - const [hotelName, cfg, logo] = await Promise.all([ - resolveHotelName(), - captchaConfig(), - siteSettings.get("cms_logo", ""), - ]); - const nonce = (await headers()).get("x-nonce") ?? undefined; + const t = await getTranslations("pages.register"); // An account already exists in this browser — there is nothing to sign up. const session = await auth(); if (session?.user?.id) redirect("/me"); - const [online, recentUsers, latestUsers] = await Promise.all([ - cachedOnlineCount().catch(() => 0), - cached( - "auth_online_users", - 10_000, - () => - db - .select({ id: User.id, 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({ id: User.id, username: User.username, look: User.look }) - .from(User) - .orderBy(desc(User.accountCreated)) - .limit(8), - { staleMs: 60000 }, - ).catch(() => []), - ]); + const data = await loadAuthLanding(); return ( - + {t("title")} —{" "} + {data.hotelName} + + ), + promoSubtitle: t("subtitle"), + usersOnlineLabel: t("usersOnline", { count: data.online }), + whoIsOnlineLabel: t("whoIsOnline"), + newestCitizensLabel: t("newestCitizens"), + }} form={ } - > - - {tpr("title")} — {hotelName} - - } - subtitle={tpr("subtitle")} - /> - - - + /> ); } diff --git a/src/components/auth/auth-field.tsx b/src/components/auth/auth-field.tsx new file mode 100644 index 00000000..d27e1297 --- /dev/null +++ b/src/components/auth/auth-field.tsx @@ -0,0 +1,106 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import type { CSSProperties, ReactNode, Ref } from "react"; + +const ICON_CLASS = + "pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"; + +export interface AuthFieldProps { + id: string; + name: string; + label: string; + icon: LucideIcon; + type?: string; + /** Controlled value; omit both `value` and `onChange` for an uncontrolled field. */ + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + autoComplete?: string; + inputMode?: + | "none" + | "text" + | "tel" + | "url" + | "email" + | "numeric" + | "decimal" + | "search"; + required?: boolean; + disabled?: boolean; + /** Marks the field with `aria-invalid` and a danger border. */ + invalid?: boolean; + /** id of the element describing this field (e.g. an error banner). */ + describedBy?: string; + iconColor?: string; + inputStyle?: CSSProperties; + /** Absolutely positioned control inside the input, e.g. `PasswordToggle`. */ + trailing?: ReactNode; + /** Content rendered below the field: strength meter, policy hints, mismatch. */ + children?: ReactNode; + /** Extra classes on the wrapping `space-y` container. */ + className?: string; + inputRef?: Ref; +} + +/** + * One labelled, icon-prefixed auth input. Login and register repeat this markup + * seven times; centralising it keeps the icon, focus and `aria-describedby` + * behaviour identical everywhere. + */ +export function AuthField({ + id, + name, + label, + icon: Icon, + type = "text", + value, + onChange, + placeholder, + autoComplete, + inputMode, + required, + disabled, + invalid, + describedBy, + iconColor = "var(--color-text-muted)", + inputStyle, + trailing, + children, + className = "", + inputRef, +}: AuthFieldProps) { + return ( +
+ +
+
+ {children} +
+ ); +} diff --git a/src/components/auth/auth-landing.tsx b/src/components/auth/auth-landing.tsx new file mode 100644 index 00000000..e7624099 --- /dev/null +++ b/src/components/auth/auth-landing.tsx @@ -0,0 +1,124 @@ +import { desc, eq } from "drizzle-orm"; +import { headers } from "next/headers"; +import type { ReactNode } from "react"; +import { + AuthPageFrame, + AuthUsersCards, + type PublicUser, +} from "@/components/auth/auth-page-frame"; +import { AuthPromoCard } from "@/components/auth/auth-promo-card"; +import type { CaptchaPublicConfig } from "@/components/auth/captcha-widget"; +import { cached } from "@/lib/cache"; +import { db, User } from "@/lib/db"; +import { resolveHotelName } from "@/lib/hotel-name"; +import { captchaConfig } from "@/lib/services/captcha"; +import { cachedOnlineCount } from "@/lib/services/public-counters"; +import { siteSettings } from "@/lib/services/site-settings"; + +export interface AuthLandingData { + hotelName: string; + logo: string | null; + nonce?: string; + /** Ready to spread onto a form's `captcha` prop (secrets never included). */ + captcha: CaptchaPublicConfig; + online: number; + recentUsers: PublicUser[]; + latestUsers: PublicUser[]; +} + +/** + * Everything `/login` and `/register` need around their form: hotel identity, + * captcha config, the CSP nonce and the "who is online" / "newest citizens" + * rails. The rosters are memoised, so both pages share one cache entry. Kept in + * one place because the two landing pages are otherwise identical. + */ +export async function loadAuthLanding(): Promise { + const [hotelName, cfg, logo] = await Promise.all([ + resolveHotelName(), + captchaConfig(), + siteSettings.get("cms_logo", ""), + ]); + const captcha: CaptchaPublicConfig = { + provider: cfg.provider, + siteKey: cfg.siteKey || undefined, + field: cfg.field || undefined, + }; + const nonce = (await headers()).get("x-nonce") ?? undefined; + + const [online, recentUsers, latestUsers] = await Promise.all([ + cachedOnlineCount().catch(() => 0), + cached( + "auth_online_users", + 10_000, + () => + db + .select({ id: User.id, 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({ id: User.id, username: User.username, look: User.look }) + .from(User) + .orderBy(desc(User.accountCreated)) + .limit(8), + { staleMs: 60000 }, + ).catch(() => []), + ]); + + return { hotelName, captcha, logo, nonce, online, recentUsers, latestUsers }; +} + +/** Locale-resolved strings the landing frame needs, keyed to the page. */ +export interface AuthLandingCopy { + title: string; + subtitle: ReactNode; + /** Optional success notice rendered above the form (e.g. "registered"). */ + notice?: ReactNode; + promoTitle: ReactNode; + promoSubtitle: ReactNode; + usersOnlineLabel: string; + whoIsOnlineLabel: string; + newestCitizensLabel: string; +} + +interface AuthLandingViewProps { + data: AuthLandingData; + copy: AuthLandingCopy; + form: ReactNode; +} + +/** + * Presentational shell shared by the auth landing pages: top bar, promo panel + * and user rails around a caller-supplied form. All data and copy come from the + * page so this component stays free of queries and translations. + */ +export function AuthLandingView({ data, copy, form }: AuthLandingViewProps) { + return ( + + + + + ); +} diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx index a0c50d6a..2f1719e9 100644 --- a/src/components/auth/login-form.tsx +++ b/src/components/auth/login-form.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { type FormEvent, useEffect, useId, useRef, useState } from "react"; import { precheckLogin } from "@/actions/auth-precheck"; import { AuthAlert } from "@/components/auth/auth-alert"; +import { AuthField } from "@/components/auth/auth-field"; import { type CaptchaPublicConfig, CaptchaWidget, @@ -145,98 +146,69 @@ export function LoginForm({ aria-label={t("signIn")} className={`relative flex flex-col ${compact ? "gap-3" : "gap-4"}`} > -
- -
-
-
+ -
- -
-
-
+ } + /> {needs2fa ? ( -
- -
-
-
+ ) : ( )} diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx index d6f3aaab..15c3515e 100644 --- a/src/components/auth/register-form.tsx +++ b/src/components/auth/register-form.tsx @@ -4,6 +4,7 @@ import { Check, KeyRound, Lock, + type LucideIcon, Mail, ShieldCheck, UserRound, @@ -19,6 +20,7 @@ import { register, } from "@/actions/register"; import { AuthAlert } from "@/components/auth/auth-alert"; +import { AuthField } from "@/components/auth/auth-field"; import { PasswordToggle } from "@/components/auth/password-toggle"; import Link from "@/components/link"; import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry"; @@ -51,6 +53,51 @@ function passwordStrength( return { score: 5, label: t("strengthStrong"), color: "#16a34a" }; } +/** The numbered section rule above each group of fields ("01 Account", ...). */ +function AuthSectionHeader({ + icon: Icon, + label, + step, +}: { + icon: LucideIcon; + label: string; + step: string; +}) { + const accent = "var(--color-primary-readable, var(--color-primary))"; + return ( +
+ + + {label} + + + +
+ ); +} + export function RegisterForm({ hotelName, captcha, @@ -205,156 +252,61 @@ export function RegisterForm({ {/* Username & Email */}
-
- - - {t("accountDetails")} - - - -
+
-
- -
-
-
-
- -
-
-
+ +
{/* Password & Confirm */}
-
- - - {t("credentials")} - - - -
+
-
- -
-
+ } + > {password.length > 0 && (
@@ -410,35 +362,27 @@ export function RegisterForm({ ))} -
-
- -
-
+ } + > {mismatch && (

)} -

+