diff --git a/src/actions/password-reset.ts b/src/actions/password-reset.ts index ac6203b7..3e760333 100644 --- a/src/actions/password-reset.ts +++ b/src/actions/password-reset.ts @@ -86,7 +86,7 @@ export async function resetPassword(formData: FormData): Promise { } let error: string | null = null; - if (password.length < 6) error = "Password must be at least 6 characters"; + if (password.length < 12) error = "Password must be at least 12 characters"; if (!error) { try { diff --git a/src/actions/register.test.ts b/src/actions/register.test.ts index 3c4bfe95..3937c02b 100644 --- a/src/actions/register.test.ts +++ b/src/actions/register.test.ts @@ -184,6 +184,7 @@ describe("register", () => { expect(result).toEqual({ error: "Username must be at least 3 characters", ok: false, + code: "usernameMinLength", }); expect(state.insert).not.toHaveBeenCalled(); }); @@ -191,6 +192,7 @@ describe("register", () => { it("rejects usernames containing characters outside the allowed set", async () => { const result = await register(PREV, buildForm({ username: "bad name!" })); expect(result.error).toContain("letters, numbers, underscore and hyphen"); + expect(result.code).toBe("usernamePattern"); expect(state.insert).not.toHaveBeenCalled(); }); @@ -199,6 +201,7 @@ describe("register", () => { expect(result).toEqual({ error: "Enter a valid email address", ok: false, + code: "emailValid", }); }); @@ -207,6 +210,7 @@ describe("register", () => { expect(result).toEqual({ error: "Password must be at least 12 characters", ok: false, + code: "passwordMinLength", }); expect(state.insert).not.toHaveBeenCalled(); }); @@ -220,6 +224,7 @@ describe("register", () => { }), ); expect(result.error).toContain("uppercase"); + expect(result.code).toBe("passwordUpper"); }); it("rejects passwords without a digit", async () => { @@ -231,6 +236,7 @@ describe("register", () => { }), ); expect(result.error).toContain("digit"); + expect(result.code).toBe("passwordDigit"); }); it("rejects passwords without a special character", async () => { @@ -242,6 +248,7 @@ describe("register", () => { }), ); expect(result.error).toContain("special"); + expect(result.code).toBe("passwordSpecial"); }); it("rejects mismatched password confirmations", async () => { @@ -249,13 +256,18 @@ describe("register", () => { PREV, buildForm({ password_confirmation: "Different1" }), ); - expect(result).toEqual({ error: "Passwords do not match", ok: false }); + expect(result).toEqual({ + error: "Passwords do not match", + ok: false, + code: "passwordsMatch", + }); }); it("throttles sign-ups per IP", async () => { state.rateLimit.mockResolvedValueOnce({ ok: false, retryAfter: 120 }); const result = await runValidRegistration(); expect(result.error).toContain("Too many sign-up attempts"); + expect(result.code).toBe("rateLimited"); expect(state.insert).not.toHaveBeenCalled(); }); @@ -273,6 +285,7 @@ describe("register", () => { expect(result).toEqual({ error: "Captcha verification failed. Please try again.", ok: false, + code: "captchaFailed", }); expect(state.verifyCaptcha).toHaveBeenCalledWith("token", "203.0.113.9"); expect(state.insert).not.toHaveBeenCalled(); @@ -290,6 +303,7 @@ describe("register", () => { expect(result).toEqual({ error: "You must accept the terms and conditions to register.", ok: false, + code: "termsRequired", }); expect(state.insert).not.toHaveBeenCalled(); }); @@ -298,7 +312,11 @@ describe("register", () => { state.checkVpn.mockResolvedValue({ blocked: true }); state.siteGet.mockResolvedValueOnce("Custom VPN message"); const result = await runValidRegistration(); - expect(result).toEqual({ error: "Custom VPN message", ok: false }); + expect(result).toEqual({ + error: "Custom VPN message", + ok: false, + code: "vpnBlocked", + }); expect(state.insert).not.toHaveBeenCalled(); }); @@ -317,6 +335,7 @@ describe("register", () => { state.countTotal = 2; const result = await runValidRegistration(); expect(result.error).toContain("maximum number of accounts"); + expect(result.code).toBe("maxAccountsPerIp"); expect(state.insert).not.toHaveBeenCalled(); }); @@ -340,6 +359,7 @@ describe("register", () => { expect(result).toEqual({ error: "That username is already taken", ok: false, + code: "usernameTaken", }); expect(state.insert).not.toHaveBeenCalled(); }); @@ -350,6 +370,7 @@ describe("register", () => { expect(result).toEqual({ error: "Registration is temporarily unavailable", ok: false, + code: "unavailable", }); expect(state.logger.warn).toHaveBeenCalledWith( "Username uniqueness check failed during registration", @@ -365,6 +386,7 @@ describe("register", () => { expect(result).toEqual({ error: "That username is already taken", ok: false, + code: "usernameTaken", }); expect(state.logger.error).not.toHaveBeenCalled(); }); @@ -373,6 +395,7 @@ describe("register", () => { state.insert.mockRejectedValueOnce(new Error("db exploded")); const result = await runValidRegistration(); expect(result.error).toContain("Could not create the account"); + expect(result.code).toBe("createFailed"); expect(state.logger.error).toHaveBeenCalledWith( "Account creation failed", expect.objectContaining({ message: "db exploded" }), diff --git a/src/actions/register.ts b/src/actions/register.ts index a36e859d..3b4ce25e 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -121,19 +121,72 @@ const registerSchema = z path: ["passwordConfirmation"], }); +/** + * Stable, locale-independent reason for a failed sign-up. The client maps these + * onto `pages.register.` so the form speaks the visitor's language; the + * English `error` string stays as a fallback and for API/log consumers. + */ +export type RegisterErrorCode = + | "usernameMinLength" + | "usernameMaxLength" + | "usernamePattern" + | "usernameReserved" + | "usernameTaken" + | "emailValid" + | "emailDisposable" + | "passwordMinLength" + | "passwordMaxLength" + | "passwordUpper" + | "passwordLower" + | "passwordDigit" + | "passwordSpecial" + | "passwordsMatch" + | "termsRequired" + | "captchaFailed" + | "rateLimited" + | "vpnBlocked" + | "maxAccountsPerIp" + | "unavailable" + | "createFailed" + | "invalidInput"; + +/** Maps the schema's English messages onto locale-independent codes. */ +const ZOD_MESSAGE_CODES: Record = { + "Username must be at least 3 characters": "usernameMinLength", + "Username must be at most 25 characters": "usernameMaxLength", + "Username may only contain letters, numbers, underscore and hyphen": + "usernamePattern", + "This username is reserved": "usernameReserved", + "Enter a valid email address": "emailValid", + "Temporary email domains are not allowed": "emailDisposable", + "Password must be at least 12 characters": "passwordMinLength", + "Password is too long": "passwordMaxLength", + "Password must contain at least one uppercase letter": "passwordUpper", + "Password must contain at least one lowercase letter": "passwordLower", + "Password must contain at least one digit": "passwordDigit", + "Password must contain at least one special character": "passwordSpecial", + "Passwords do not match": "passwordsMatch", +}; + // 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; + /** Locale-independent reason, present on every failure. */ + code?: RegisterErrorCode; } export async function register( _prevState: RegisterState, formData: FormData, ): Promise { - const fail = (error: string): RegisterState => ({ error, ok: false }); + const fail = (error: string, code: RegisterErrorCode): RegisterState => ({ + error, + ok: false, + code, + }); const raw = { username: String(formData.get("username") ?? "") .normalize("NFC") @@ -155,7 +208,8 @@ export async function register( const parsed = registerSchema.safeParse(raw); if (!parsed.success) { - return fail(parsed.error.issues[0]?.message ?? "Invalid input"); + const message = parsed.error.issues[0]?.message ?? "Invalid input"; + return fail(message, ZOD_MESSAGE_CODES[message] ?? "invalidInput"); } const { username, mail, password, look } = parsed.data; @@ -166,6 +220,7 @@ export async function register( if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) { return fail( "Too many sign-up attempts. Please wait a few minutes and try again.", + "rateLimited", ); } @@ -174,18 +229,25 @@ export async function register( if (cfg.provider !== "none") { const token = String(formData.get(cfg.field) ?? "").normalize("NFC"); if (!(await verifyCaptcha(token, ip))) - return fail("Captcha verification failed. Please try again."); + return fail( + "Captcha verification failed. Please try again.", + "captchaFailed", + ); } // Terms acceptance check. if (!raw.termsAccepted) - return fail("You must accept the terms and conditions to register."); + return fail( + "You must accept the terms and conditions to register.", + "termsRequired", + ); // VPN/proxy block (only when enabled in /admin/vpn). if ((await checkVpn(ip)).blocked) { return fail( (await siteSettings.get("vpn_block_message", "")) || "Registrations from VPN/proxy connections are not allowed.", + "vpnBlocked", ); } @@ -200,6 +262,7 @@ export async function register( if (Number(row?.total ?? 0) >= max) return fail( "You have reached the maximum number of accounts for your connection.", + "maxAccountsPerIp", ); } @@ -210,10 +273,11 @@ export async function register( .from(User) .where(eq(User.username, username)) .limit(1); - if (existing) return fail("That username is already taken"); + if (existing) + return fail("That username is already taken", "usernameTaken"); } catch { logger.warn("Username uniqueness check failed during registration"); - return fail("Registration is temporarily unavailable"); + return fail("Registration is temporarily unavailable", "unavailable"); } const now = Math.floor(Date.now() / 1000); @@ -233,7 +297,7 @@ export async function register( } catch (err) { const code = (err as { cause?: { code?: string } }).cause?.code; if (code === "ER_DUP_ENTRY") { - return fail("That username is already taken"); + return fail("That username is already taken", "usernameTaken"); } logger.error("Account creation failed", { code, @@ -241,6 +305,7 @@ export async function register( }); return fail( "Could not create the account. Please try again or contact staff.", + "createFailed", ); } diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index ee6ca9c4..99cd006e 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -6,7 +6,7 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import type { CSSProperties, ReactNode } from "react"; import { AnimatedCounter } from "@/components/animated-counter"; -import { HomeLoginForm } from "@/components/auth/home-login-form"; +import { LoginForm } from "@/components/auth/login-form"; import { Clock } from "@/components/clock"; import { LanguageSwitcher } from "@/components/language-switcher"; import Link from "@/components/link"; @@ -870,7 +870,9 @@ export default async function Home() { icon={ICON_NAV_ME} bodyClassName="p-3.5 sm:p-4" > - (null); - const [pending, setPending] = useState(false); - - async function onSubmit(e: FormEvent) { - e.preventDefault(); - setError(null); - setPending(true); - try { - if (!needs2fa) { - const captchaToken = readCaptchaToken(e.currentTarget, captcha); - const pre = await precheckLogin(username, password, captchaToken); - if (pre === "invalid") { - setError("Invalid username or password"); - return; - } - if (pre === "captcha") { - setError("Captcha verification failed. Please try again."); - return; - } - if (pre === "unverified") { - setError("Please verify your email before signing in."); - return; - } - if (pre === "twofactor") { - setNeeds2fa(true); - return; - } - } - const res = await signInWithTransientRetry(() => - signIn("credentials", { - username, - password, - code, - redirect: false, - }), - ); - if (!res || res.error) { - setError( - needs2fa ? "Invalid 2FA code" : "Invalid username or password", - ); - return; - } - window.location.href = "/"; - } catch { - setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password"); - } finally { - setPending(false); - } - } - - return ( -
-
- - setUsername(e.target.value)} - placeholder="Your username" - autoComplete="username" - disabled={needs2fa} - className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" - style={{ - backgroundColor: "var(--color-background)", - color: "var(--color-text-readable)", - borderColor: needs2fa - ? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" - : "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", - }} - required - /> -
- -
-
- -
-
- setPassword(e.target.value)} - placeholder="Your password" - autoComplete="current-password" - disabled={needs2fa} - className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" - style={{ - backgroundColor: "var(--color-background)", - color: "var(--color-text-readable)", - borderColor: needs2fa - ? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" - : "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", - }} - required - /> - -
-
- - {needs2fa ? ( -
- - setCode(e.target.value)} - placeholder="Enter your 2FA code" - inputMode="numeric" - autoComplete="one-time-code" - className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" - style={{ - backgroundColor: "var(--color-background)", - color: "var(--color-text-readable)", - borderColor: - "color-mix(in srgb, var(--color-primary) 30%, transparent)", - }} - /> -
- ) : ( - - )} - - {error ? ( -

- {error} -

- ) : null} - - - - ); -} diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx index e293e94d..41b6aa8b 100644 --- a/src/components/auth/login-form.tsx +++ b/src/components/auth/login-form.tsx @@ -2,7 +2,7 @@ import { signIn } from "next-auth/react"; import { useTranslations } from "next-intl"; -import { type FormEvent, useState } from "react"; +import { type FormEvent, useId, useState } from "react"; import { precheckLogin } from "@/actions/auth-precheck"; import { type CaptchaPublicConfig, @@ -12,14 +12,34 @@ import { import Link from "@/components/link"; import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry"; +export type LoginFormVariant = + /** Full page: labelled fields, plus the register / forgot-password footer. */ + | "page" + /** Homepage sidebar: visible labels, no footer, tighter spacing. */ + | "compact"; + +export interface LoginFormProps { + captcha?: CaptchaPublicConfig; + nonce?: string; + variant?: LoginFormVariant; + /** Where to land after a successful sign-in. */ + redirectTo?: string; +} + +/** + * The single sign-in form for both `/login` and the homepage sidebar. It runs a + * cheap server-side precheck first so the visitor gets a specific reason + * (bad password, unverified email, 2FA required, captcha) instead of NextAuth's + * generic failure, then completes the credentials sign-in. + */ export function LoginForm({ captcha = { provider: "none" }, nonce, -}: { - captcha?: CaptchaPublicConfig; - nonce?: string; -}) { + variant = "page", + redirectTo = "/me", +}: LoginFormProps = {}) { const t = useTranslations("pages.login"); + const fieldId = useId(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); @@ -28,6 +48,10 @@ export function LoginForm({ const [error, setError] = useState(null); const [pending, setPending] = useState(false); + const showFooter = variant === "page"; + const labelled = variant === "compact"; + const lockCredentials = needs2fa ? "opacity-50 pointer-events-none" : ""; + async function onSubmit(e: FormEvent) { e.preventDefault(); setError(null); @@ -67,7 +91,7 @@ export function LoginForm({ ); return; } - window.location.href = "/me"; + window.location.href = redirectTo; } catch { setError(needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials")); } finally { @@ -79,60 +103,112 @@ export function LoginForm({ <> {needs2fa && (

{t("subtitle2fa")}

)} -
-
+ + +
+ setUsername(e.target.value)} placeholder={t("usernamePlaceholder")} autoComplete="username" disabled={needs2fa} + required className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" style={{ backgroundColor: "var(--color-background)", color: "var(--color-text-readable)", - borderColor: - "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", + borderColor: needs2fa + ? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" + : "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", }} />
-
+ +
+ setPassword(e.target.value)} placeholder={t("passwordPlaceholder")} autoComplete="current-password" disabled={needs2fa} + required className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" style={{ backgroundColor: "var(--color-background)", color: "var(--color-text-readable)", - borderColor: - "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", + borderColor: needs2fa + ? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" + : "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", }} />
+ {needs2fa ? ( -
+
+ setCode(e.target.value)} placeholder={t("codePlaceholder")} @@ -153,7 +229,9 @@ export function LoginForm({ {error && (

{error} @@ -163,7 +241,7 @@ export function LoginForm({