From f25a26a93e3915bc4e9835ec6c27a4fa218d960a Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 26 Aug 2026 14:57:51 +0200 Subject: [PATCH] Register: auto sign-in to /me and speed/cleanup improvements - Send the verification email after the response via after() so it never blocks sign-up - Invalidate the cached login lookup right after account creation so the automatic sign-in always finds the fresh row - Auto sign in with the submitted credentials and go straight to /me, with a fallback to /login?registered=1 if sign-in is refused (e.g. email verification required) - Cache the register page's online/latest user queries to cut DB load under traffic - Fix terms checkbox label double-toggle cancelling the selection - Add pages.register.redirecting translation to all locales --- src/actions/register.ts | 70 +++++++++++++++--------- src/app/(site)/register/page.tsx | 26 ++++----- src/components/auth/register-form.tsx | 78 +++++++++++++++++++++------ src/messages/bg.json | 1 + src/messages/cs.json | 1 + src/messages/da.json | 1 + src/messages/de.json | 1 + src/messages/el.json | 1 + src/messages/en.json | 1 + src/messages/es.json | 1 + src/messages/fr.json | 1 + src/messages/hr.json | 1 + src/messages/hu.json | 1 + src/messages/it.json | 1 + src/messages/nl.json | 1 + src/messages/no.json | 1 + src/messages/pl.json | 1 + src/messages/pt.json | 1 + src/messages/ro.json | 1 + src/messages/ru.json | 1 + src/messages/sk.json | 1 + src/messages/sr.json | 1 + src/messages/sv.json | 1 + src/messages/tr.json | 1 + src/messages/uk.json | 1 + 25 files changed, 143 insertions(+), 53 deletions(-) diff --git a/src/actions/register.ts b/src/actions/register.ts index eccc4fa2cc..3a25946ebb 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -1,10 +1,11 @@ "use server"; import { count, eq } from "drizzle-orm"; -import { redirect } from "next/navigation"; +import { after } from "next/server"; import { z } from "zod"; import { sendVerification } from "@/actions/email-verify"; import { hashPassword } from "@/lib/auth/password"; +import { invalidateKey } from "@/lib/cached-db"; import { db, User } from "@/lib/db"; import { logger } from "@/lib/logger"; import { clientIp, rateLimit } from "@/lib/rate-limit"; @@ -36,10 +37,16 @@ const registerSchema = z.object({ // A valid starter Habbo figure so the avatar renders in-client immediately. const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62"; +export interface RegisterState { + error: string | null; + ok: boolean; +} + export async function register( - _prevState: string | null, + _prevState: RegisterState, formData: FormData, -): Promise { +): Promise { + const fail = (error: string): RegisterState => ({ error, ok: false }); const raw = { username: String(formData.get("username") ?? "") .normalize("NFC") @@ -61,11 +68,11 @@ export async function register( const parsed = registerSchema.safeParse(raw); if (!parsed.success) { - return parsed.error.issues[0]?.message ?? "Invalid input"; + return fail(parsed.error.issues[0]?.message ?? "Invalid input"); } if (parsed.data.password !== parsed.data.passwordConfirmation) { - return "Passwords do not match"; + return fail("Passwords do not match"); } const { username, mail, password, look } = parsed.data; @@ -74,7 +81,9 @@ export async function register( // Throttle sign-ups per IP (5 per 10 minutes) to curb account spam. if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) { - return "Too many sign-up attempts. Please wait a few minutes and try again."; + return fail( + "Too many sign-up attempts. Please wait a few minutes and try again.", + ); } // CAPTCHA (Turnstile / reCAPTCHA) — only enforced when configured in settings. @@ -82,18 +91,18 @@ export async function register( if (cfg.provider !== "none") { const token = String(formData.get(cfg.field) ?? "").normalize("NFC"); if (!(await verifyCaptcha(token, ip))) - return "Captcha verification failed. Please try again."; + return fail("Captcha verification failed. Please try again."); } // Terms acceptance check. if (!raw.termsAccepted) - return "You must accept the terms and conditions to register."; + return fail("You must accept the terms and conditions to register."); // VPN/proxy block (only when enabled in /admin/vpn). if ((await checkVpn(ip)).blocked) { - return ( + return fail( (await siteSettings.get("vpn_block_message", "")) || - "Registrations from VPN/proxy connections are not allowed." + "Registrations from VPN/proxy connections are not allowed.", ); } @@ -106,7 +115,9 @@ export async function register( .where(eq(User.ipRegister, ip)) .catch(() => [{ total: 0 }]); if (Number(row?.total ?? 0) >= max) - return "You have reached the maximum number of accounts for your connection."; + return fail( + "You have reached the maximum number of accounts for your connection.", + ); } // Uniqueness check. @@ -116,10 +127,10 @@ export async function register( .from(User) .where(eq(User.username, username)) .limit(1); - if (existing) return "That username is already taken"; + if (existing) return fail("That username is already taken"); } catch { logger.warn("Username uniqueness check failed during registration"); - return "Registration is temporarily unavailable"; + return fail("Registration is temporarily unavailable"); } const now = Math.floor(Date.now() / 1000); @@ -134,25 +145,36 @@ export async function register( look, termsAccepted: raw.termsAccepted, }); - - if (hasEmail) { - try { - await sendVerification(mail); - } catch { - logger.warn("Failed to send verification email after registration"); - } - } } catch (err) { const code = (err as { cause?: { code?: string } }).cause?.code; if (code === "ER_DUP_ENTRY") { - return "That username is already taken"; + return fail("That username is already taken"); } logger.error("Account creation failed", { code, message: err instanceof Error ? err.message : String(err), }); - return "Could not create the account. Please try again or contact staff."; + return fail( + "Could not create the account. Please try again or contact staff.", + ); } - redirect("/login?registered=1"); + // The login lookup is cached for 15s — drop any stale entry so the + // immediate auto sign-in sees the fresh row. + await invalidateKey(`login:user:${username}`); + + // Verification email must never block the sign-up response — it is sent + // after the response is flushed (no-op when mail is unconfigured). + if (hasEmail) { + after(async () => { + try { + await sendVerification(mail); + } catch { + logger.warn("Failed to send verification email after registration"); + } + }); + } + + // Client auto signs in with these credentials and navigates to /me. + return { error: null, ok: true }; } diff --git a/src/app/(site)/register/page.tsx b/src/app/(site)/register/page.tsx index bb5bdc3f44..8827c81482 100644 --- a/src/app/(site)/register/page.tsx +++ b/src/app/(site)/register/page.tsx @@ -33,18 +33,20 @@ export default async function RegisterPage() { .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("register_online_users", 10_000, () => + db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.online, "1")) + .limit(8), + ).catch(() => []), + cached("register_latest_users", 30_000, () => + db + .select({ username: User.username, look: User.look }) + .from(User) + .orderBy(desc(User.accountCreated)) + .limit(8), + ).catch(() => []), ]); return ( diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx index 09c6415234..398bf968b5 100644 --- a/src/components/auth/register-form.tsx +++ b/src/components/auth/register-form.tsx @@ -3,9 +3,11 @@ import Image from "next/image"; import Link from "next/link"; import Script from "next/script"; +import { signIn } from "next-auth/react"; import { useTranslations } from "next-intl"; -import { useActionState, useState } from "react"; -import { register } from "@/actions/register"; +import { useActionState, useEffect, useRef, useState } from "react"; +import { type RegisterState, register } from "@/actions/register"; +import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry"; interface RegisterFormProps { hotelName: string; @@ -40,13 +42,56 @@ export function RegisterForm({ }: RegisterFormProps) { const t = useTranslations("pages.register"); const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey; - const [serverError, formAction, isPending] = useActionState(register, null); + const [state, formAction, isPending] = useActionState< + RegisterState, + FormData + >(register, { error: null, ok: false }); const [termsAccepted, setTermsAccepted] = useState(false); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [showConfirm, setShowConfirm] = useState(false); + const [signingIn, setSigningIn] = useState(false); + const credentialsRef = useRef<{ username: string; password: string } | null>( + null, + ); + const autoLoginStartedRef = useRef(false); const strength = passwordStrength(password); + // Successful sign-up: sign in with the submitted credentials and go + // straight to /me. If the automatic sign-in is refused (e.g. email + // verification required), fall back to the regular login page. + useEffect(() => { + if (!state.ok || autoLoginStartedRef.current) return; + autoLoginStartedRef.current = true; + + const creds = credentialsRef.current; + if (!creds) { + window.location.href = "/login?registered=1"; + return; + } + + let cancelled = false; + setSigningIn(true); + (async () => { + try { + const res = await signInWithTransientRetry(() => + signIn("credentials", { ...creds, code: "", redirect: false }), + ); + if (!cancelled && res && !res.error) { + window.location.href = "/me"; + return; + } + } catch { + /* fall through to fallback below */ + } + if (!cancelled) window.location.href = "/login?registered=1"; + })(); + + return () => { + cancelled = true; + }; + }, [state.ok]); + return (
{showCaptcha && captcha.provider === "turnstile" ? ( @@ -112,7 +157,13 @@ export function RegisterForm({ {/* Form */}
{ + credentialsRef.current = { + username: String(fd.get("username") ?? "").trim(), + password: String(fd.get("password") ?? ""), + }; + formAction(fd); + }} className="p-6 flex flex-col gap-6" style={{ backgroundColor: @@ -120,12 +171,12 @@ export function RegisterForm({ borderRadius: "0 0 12px 12px", }} > - {(error || serverError) && ( + {(error || state.error) && (
- {error || serverError} + {error || state.error}
)} @@ -301,14 +352,7 @@ export function RegisterForm({ /> @@ -332,7 +376,7 @@ export function RegisterForm({ {/* Submit */}