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 ( -
-
+ + } + > +