diff --git a/.nvmrc b/.nvmrc index 91d2624e..60bb1e60 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -26.7.0 +26.8.1 diff --git a/package.json b/package.json index 8c73c3bf..a3af572b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "engines": { - "node": ">=26.7.0 <27" + "node": ">=26.8.1 <27" }, "packageManager": "pnpm@11.24.0", "scripts": { diff --git a/src/actions/auth-precheck.test.ts b/src/actions/auth-precheck.test.ts index 6c6df35c..da37a925 100644 --- a/src/actions/auth-precheck.test.ts +++ b/src/actions/auth-precheck.test.ts @@ -1,19 +1,25 @@ // @ts-nocheck import { beforeEach, describe, expect, it, vi } from "vitest"; -import { checkLogin } from "@/lib/auth/password"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { siteSettings } from "@/lib/services/site-settings"; import { precheckLogin } from "./auth-precheck"; -const { queryPreparedOne } = vi.hoisted(() => { - const queryPreparedOne = vi.fn().mockResolvedValue(null); - return { queryPreparedOne }; -}); +const core = vi.hoisted(() => ({ + getLoginUser: vi.fn(), + verifyLoginPassword: vi.fn(), + isEmailUnverified: vi.fn(), + runDummyHashCheck: vi.fn(), + normalizeLoginInput: (username: unknown, password: unknown) => ({ + username: String(username ?? "") + .normalize("NFC") + .trim(), + password: String(password ?? "").normalize("NFC"), + }), +})); vi.mock("@/env", () => ({ env: { CONVERT_PASSWORDS: false } })); -vi.mock("@/lib/auth/password", () => ({ checkLogin: vi.fn() })); -vi.mock("@/lib/db", () => ({ queryPreparedOne })); +vi.mock("@/lib/auth/login-core", () => core); vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() })); vi.mock("@/lib/services/captcha", () => ({ captchaConfig: vi.fn(), @@ -23,33 +29,35 @@ vi.mock("@/lib/services/site-settings", () => ({ siteSettings: { getBool: vi.fn() }, })); +const user = (overrides = {}) => ({ + password: "hash", + twoFactorConfirmedAt: null, + mail: null, + mailVerified: "0", + ...overrides, +}); + beforeEach(() => { vi.clearAllMocks(); vi.mocked(clientIp).mockResolvedValue("1.2.3.4"); vi.mocked(rateLimit).mockResolvedValue({ ok: true }); - vi.mocked(checkLogin).mockResolvedValue({ valid: true } as never); vi.mocked(captchaConfig).mockResolvedValue({ provider: "none" } as never); - queryPreparedOne.mockResolvedValue(null); + core.getLoginUser.mockResolvedValue(null); + core.verifyLoginPassword.mockResolvedValue({ valid: true }); + core.isEmailUnverified.mockResolvedValue(false); + core.runDummyHashCheck.mockResolvedValue(undefined); }); describe("precheckLogin", () => { it("returns ok for valid login without 2FA", async () => { - queryPreparedOne.mockResolvedValue({ - password: "hash", - twoFactorConfirmedAt: null, - mail: null, - mailVerified: "0", - }); + core.getLoginUser.mockResolvedValue(user()); expect(await precheckLogin("user", "pass")).toBe("ok"); }); it("returns twofactor when 2FA is set up", async () => { - queryPreparedOne.mockResolvedValue({ - password: "hash", - twoFactorConfirmedAt: new Date(), - mail: null, - mailVerified: "0", - }); + core.getLoginUser.mockResolvedValue( + user({ twoFactorConfirmedAt: new Date() }), + ); expect(await precheckLogin("user", "pass")).toBe("twofactor"); }); @@ -62,30 +70,20 @@ describe("precheckLogin", () => { provider: "hcaptcha", } as never); vi.mocked(verifyCaptcha).mockResolvedValue(false); - queryPreparedOne.mockResolvedValue({ - password: "hash", - twoFactorConfirmedAt: null, - mail: null, - mailVerified: "0", - }); + core.getLoginUser.mockResolvedValue(user()); expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha"); }); it("returns invalid when user not found (dummy hash check)", async () => { - queryPreparedOne.mockResolvedValue(null); + core.getLoginUser.mockResolvedValue(null); const result = await precheckLogin("nonexistent", "pass"); expect(result).toBe("invalid"); - expect(checkLogin).toHaveBeenCalled(); + expect(core.runDummyHashCheck).toHaveBeenCalled(); }); it("returns unverified when email verification required", async () => { - queryPreparedOne.mockResolvedValue({ - password: "hash", - twoFactorConfirmedAt: null, - mail: "user@example.com", - mailVerified: "0", - }); - vi.mocked(siteSettings.getBool).mockResolvedValue(true); + core.getLoginUser.mockResolvedValue(user({ mail: "user@example.com" })); + core.isEmailUnverified.mockResolvedValue(true); expect(await precheckLogin("user", "pass")).toBe("unverified"); }); }); diff --git a/src/actions/auth-precheck.ts b/src/actions/auth-precheck.ts index b5eee29a..64acb0f9 100644 --- a/src/actions/auth-precheck.ts +++ b/src/actions/auth-precheck.ts @@ -1,11 +1,14 @@ "use server"; -import { env } from "@/env"; -import { checkLogin } from "@/lib/auth/password"; -import { queryPreparedOne } from "@/lib/db"; +import { + getLoginUser, + isEmailUnverified, + normalizeLoginInput, + runDummyHashCheck, + verifyLoginPassword, +} from "@/lib/auth/login-core"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; -import { siteSettings } from "@/lib/services/site-settings"; export type PrecheckResult = | "ok" @@ -24,10 +27,7 @@ export async function precheckLogin( password: string, captchaToken?: string | null, ): Promise { - const u = String(username ?? "") - .normalize("NFC") - .trim(); - const p = String(password ?? ""); + const { username: u, password: p } = normalizeLoginInput(username, password); if (!u || !p) return "invalid"; const ip = await clientIp(); @@ -38,49 +38,17 @@ export async function precheckLogin( if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha"; } - let user: { - password: string; - twoFactorConfirmedAt: Date | null; - mail: string | null; - mailVerified: string; - } | null; - try { - user = await queryPreparedOne<{ - password: string; - twoFactorConfirmedAt: Date | null; - mail: string | null; - mailVerified: string; - }>( - `SELECT password, two_factor_confirmed_at AS twoFactorConfirmedAt, - mail, mail_verified AS mailVerified - FROM users WHERE username = ? LIMIT 1`, - [u], - ); - } catch { - return "invalid"; - } + const user = await getLoginUser(u); if (!user) { // Prevent timing-based enumeration: always run a dummy hash check. - await checkLogin( - p, - "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", - { - convertPasswords: false, - }, - ); + await runDummyHashCheck(p); return "invalid"; } - const res = await checkLogin(p, user.password, { - convertPasswords: env.CONVERT_PASSWORDS, - }); + const res = await verifyLoginPassword(user, p); if (!res.valid) return "invalid"; - if ( - (await siteSettings.getBool("require_email_verification", false)) && - user.mail && - user.mailVerified !== "1" - ) { + if (await isEmailUnverified(user)) { return "unverified"; } diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index daa549d0..02dea8b8 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -8,6 +8,7 @@ import { Clock } from "@/components/clock"; import { LanguageSwitcher } from "@/components/language-switcher"; import { Reveal } from "@/components/motion-reveal"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; +import { SurfaceCard } from "@/components/surface-card"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { cached } from "@/lib/cache"; import { db, User } from "@/lib/db"; @@ -48,7 +49,7 @@ export default async function LoginPage() { ]); return ( -
+
{/* ── Top Bar ── */}
+ -
+
{/* Left panel */}
-
-
+ {recentUsers.length > 0 && ( -
-
- -

- {th("online", { count: online, hotel: "" }).trim()} -

-
-
-
- {recentUsers.map((u) => ( -
+ {recentUsers.map((u) => ( +
+ + - - - {u.username} - -
- ))} -
+ {u.username} + +
+ ))}
-
+ )} {latestUsers.length > 0 && ( -
-
- -

- Newest citizens -

-
-
-
- {latestUsers.map((u) => ( -
+ {latestUsers.map((u) => ( +
+ + - - - {u.username} - -
- ))} -
+ {u.username} + +
+ ))}
-
+ )}

-

-
+ {t("subtitle")} +

+ - -

- {t("title")} -

-
-
-

- {t("subtitle")} -

- -
-
+ nonce={nonce} + /> +
diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index 660eb7c9..7aee9cca 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -7,6 +7,7 @@ import { claimReferral } from "@/actions/referral"; 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 { db, @@ -253,8 +254,8 @@ export default async function MePage({ ) : null} -
-
+ {alertRaw ? ( -
{alertRaw}

-
+
) : null} @@ -382,14 +383,9 @@ export default async function MePage({ { value: friendCount.toLocaleString(), label: "Friends" }, { value: lastLoginDate, label: "Last login" }, ].map((s) => ( -
{s.label}
-
+ ))}
-
+

)} -

+ -
+

)}

-
+
{recentRooms.length > 0 && ( -
+

))}

-
+
)} {badges.length > 0 && ( -
+

))}

-
+
)} -
+

))}

-
+
); } catch { content = ( -
+

-

+
); } diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index a8e83c7b..3c0c0e69 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -11,6 +11,7 @@ import { Clock } from "@/components/clock"; import { LanguageSwitcher } from "@/components/language-switcher"; import { Reveal } from "@/components/motion-reveal"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; +import { SurfaceCard } from "@/components/surface-card"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { TypewriterText } from "@/components/typewriter-text"; import { auth } from "@/lib/auth"; @@ -115,7 +116,7 @@ export default async function Home() { const nonce = (await headers()).get("x-nonce") ?? undefined; return ( -
+
{/* ── Top Bar ── */}
@@ -232,7 +233,7 @@ export default async function Home() { {th("online", { count: online, hotel: hotelName })}

{th("tagline")} @@ -320,17 +321,9 @@ export default async function Home() { icon: "/assets/images/icons/catalog.png", }, ].map((s) => ( -

@@ -365,7 +358,7 @@ export default async function Home() { {s.label}
-
+ ))}
@@ -374,57 +367,23 @@ export default async function Home() {
{/* Left: Login & Register */} -
-
- -

- Login -

-
-
- -
-
+ nonce={nonce} + /> + -
+ Register -
+ {latestUsers.length > 0 && ( -
-
- -

- {tp("latestUsers", { count: users.toLocaleString() })} -

-
-
+
{latestUsers.map((u) => (
-
- -
+ {u.username} @@ -498,187 +431,118 @@ export default async function Home() {
))}
-
+ )} {/* Right: News */} -
-
-
- -

- {tp("latestNews")} -

-
- - {tp("allNews")} - -
-
- {articles.length > 0 ? ( -
- {articles.slice(0, 4).map((a) => ( - -
- {a.title} -
- - → - -
-
-

- {a.title} -

-

- {formatDate(a.createdAt, "date", "")} -

-
+ {articles.length > 0 ? ( +
+ {articles.slice(0, 4).map((a) => ( + +
+ {a.title} +
+ →
- - ))} -
- ) : ( -

- {tp("noArticles")} -

- )} -
-
+
+

+ {a.title} +

+

+ {formatDate(a.createdAt, "date", "")} +

+
+
+ + ))} +
+ ) : ( +

+ {tp("noArticles")} +

+ )} +
{/* ── Online users ── */} {recentUsers.length > 0 && ( -
-
-
- -

+ {recentUsers.slice(0, 12).map((u) => ( +
- {tp("recentUsers")} -

-
- - {tp("allUsers")} - -
-
-
- {recentUsers.slice(0, 12).map((u) => ( -
+ - - - {u.username} - -
- ))} -
+ {u.username} + +
+ ))}
-
+
)} {/* ── Bottom CTA ── */}

{tp("welcomeBody")} @@ -725,60 +589,33 @@ export default async function Home() { {/* ── Recent photos ── */} {recentPhotos.length > 0 && ( -

-
- -

- {tp("recentPhotos")} -

+
+ {recentPhotos.slice(0, 4).map((p) => ( + + + + ))}
-
-
- {recentPhotos.slice(0, 4).map((p) => ( - - - - ))} -
-
-
+ )}
diff --git a/src/app/(site)/register/page.tsx b/src/app/(site)/register/page.tsx index 8827c814..38595044 100644 --- a/src/app/(site)/register/page.tsx +++ b/src/app/(site)/register/page.tsx @@ -8,6 +8,7 @@ import { Clock } from "@/components/clock"; import { LanguageSwitcher } from "@/components/language-switcher"; import { Reveal } from "@/components/motion-reveal"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; +import { SurfaceCard } from "@/components/surface-card"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { cached } from "@/lib/cache"; import { db, User } from "@/lib/db"; @@ -50,7 +51,7 @@ export default async function RegisterPage() { ]); return ( -
+
{/* ── Top Bar ── */}
+ -
+
{/* Left panel */}
-
-
+ {recentUsers.length > 0 && ( -
-
- -

- {tpr("whoIsOnline")} -

-
-
-
- {recentUsers.map((u) => ( -
+ {recentUsers.map((u) => ( +
+ + - - - {u.username} - -
- ))} -
+ {u.username} + +
+ ))}
-
+ )} {latestUsers.length > 0 && ( -
-
- -

- Newest citizens -

-
-
-
- {latestUsers.map((u) => ( -
+ {latestUsers.map((u) => ( +
+ + - - - {u.username} - -
- ))} -
+ {u.username} + +
+ ))}
-
+ )}

-

-
+ {tpr("subtitle")} +

+ - -

- {tpr("title")} -

-
-
-

- {tpr("subtitle")} -

- -
-
+ nonce={nonce} + /> +
diff --git a/src/app/(site)/search/page.tsx b/src/app/(site)/search/page.tsx index 6944c491..72d642e7 100644 --- a/src/app/(site)/search/page.tsx +++ b/src/app/(site)/search/page.tsx @@ -2,6 +2,7 @@ import { and, eq, like } from "drizzle-orm"; import type { Metadata } from "next"; import Link from "next/link"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; +import { SurfaceCard } from "@/components/surface-card"; import { db, Rooms, User } from "@/lib/db"; export const metadata: Metadata = { title: "Search" }; @@ -18,21 +19,14 @@ export default async function SearchPage({ if (!query) { return ( -
+

Enter a search term to find users.

-
+ ); } @@ -66,14 +60,7 @@ export default async function SearchPage({ return (
-
+
-
+

Results for "{query}" — {users.length} user @@ -109,14 +96,7 @@ export default async function SearchPage({

{users.length > 0 && ( -
+

))}

-
+ )} {rooms.length > 0 && ( -
+

))}

-
+ )} {users.length === 0 && rooms.length === 0 && ( -
+

No users or rooms found for "{query}".

-
+ )}
); diff --git a/src/app/(site)/settings/page.tsx b/src/app/(site)/settings/page.tsx index 2697aa42..1fb926f3 100644 --- a/src/app/(site)/settings/page.tsx +++ b/src/app/(site)/settings/page.tsx @@ -5,6 +5,7 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { updateMotto } from "@/actions/user-settings"; import { Reveal } from "@/components/motion-reveal"; +import { SurfaceCard } from "@/components/surface-card"; import { auth } from "@/lib/auth"; import { db, User } from "@/lib/db"; import { avatarImageUrl } from "@/lib/format"; @@ -46,19 +47,20 @@ export default async function SettingsPage() {
{/* Header */} -
-
+
{/* Profile card */} -
-
- -

+
- {t("publicProfile")} -

-
-
-
-
- {`${user.username} -
-
-
-

- {user.username} -

-

- {user.motto || ( - - {t("noMotto")} - - )} -

-

- {user.mail || t("noEmail")} -

+ {`${user.username}
-
+
+

+ {user.username} +

+

+ {user.motto || ( + + {t("noMotto")} + + )} +

+

+ {user.mail || t("noEmail")} +

+
+
{/* Motto form */} -
-
- +
+ -

- {t("changeMotto")} -

-
-
-

- {t("changeMottoSubtitle")} -

- - + -
- -
-
+ > + {t("saveMotto")} + +

+ +
{/* Security card */} -
-
+ {t("securityDescription")} +

+ - -

- {t("securityTitle")} -

-
-
-

- {t("securityDescription")} -

- - {t("twoFactorLink")} - -
-
+ {t("twoFactorLink")} + +
{/* Sessions link */} - -
-
-
- -
-
- - Session Management - -

- View active sessions and sign out everywhere -

-
-
- + +
- → - -
+
+
+ +
+
+ + Session Management + +

+ View active sessions and sign out everywhere +

+
+
+ + → + +
+
diff --git a/src/app/(site)/verify/page.tsx b/src/app/(site)/verify/page.tsx index 5b3eef70..e1c05a84 100644 --- a/src/app/(site)/verify/page.tsx +++ b/src/app/(site)/verify/page.tsx @@ -3,6 +3,7 @@ import { CheckCircle2, Clock, MailX } from "lucide-react"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { isValidVerificationToken } from "@/actions/email-verify"; +import { SurfaceCard } from "@/components/surface-card"; import { db, User } from "@/lib/db"; type Status = "verified" | "already" | "invalid" | "unavailable"; @@ -20,38 +21,47 @@ function StatusCard({ children: React.ReactNode; color: string; }) { - const gradientMap: Record = { - green: - "linear-gradient(140deg, #0A2F1A 0%, #0F3D22 20%, #154C2A 40%, #1B5A32 60%, #20683A 75%, #1A5A30 90%, #144826 100%)", - yellow: - "linear-gradient(140deg, #3A2F0A 0%, #4A3D0F 20%, #5A4C15 40%, #6A5A1B 60%, #7A6820 75%, #6A5A1A 90%, #5A4814 100%)", - red: "linear-gradient(140deg, #3A0F0A 0%, #4A1A0F 20%, #5A2515 40%, #6A301B 60%, #7A3B20 75%, #6A301A 90%, #5A2514 100%)", - blue: "linear-gradient(140deg, #0A1A3A 0%, #0F254A 20%, #15305A 40%, #1B3B6A 60%, #20467A 75%, #1A3B6A 90%, #14305A 100%)", + const tintMap: Record = { + green: "#16a34a", + yellow: "#d97706", + red: "#dc2626", + blue: "#2563eb", }; + const tint = tintMap[color] ?? tintMap.blue; return (
-
+
- {icon} -

{title}

+ + {icon} + +

+ {title} +

{icon} @@ -64,7 +74,7 @@ function StatusCard({

{children}
-
+
); } diff --git a/src/app/admin/settings/cms-settings-config.ts b/src/app/admin/settings/cms-settings-config.ts index 825ee15b..dc33a47e 100644 --- a/src/app/admin/settings/cms-settings-config.ts +++ b/src/app/admin/settings/cms-settings-config.ts @@ -1,4 +1,3 @@ -import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel"; export type FieldType = @@ -45,13 +44,6 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [ icon: "building", description: "Name, branding and defaults shown across the site.", fields: [ - { - key: "hotel_name", - label: "Hotel name", - type: "text", - placeholder: "Epicnabbo", - defaultValue: FALLBACK_HOTEL_NAME, - }, { key: "cms_logo", label: "Logo URL", diff --git a/src/app/api/home/route.ts b/src/app/api/home/route.ts index fca4e88c..92593e05 100644 --- a/src/app/api/home/route.ts +++ b/src/app/api/home/route.ts @@ -1,6 +1,6 @@ import { count, desc, eq } from "drizzle-orm"; +import { env } from "@/env"; import { apiJson } from "@/lib/api"; -import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; import { db, User, WebsiteArticles } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; @@ -38,7 +38,7 @@ export async function GET(_req: Request) { return apiJson(data); } catch { return apiJson( - { articles: [], online: 0, hotelName: FALLBACK_HOTEL_NAME }, + { articles: [], online: 0, hotelName: env.HOTEL_NAME }, { status: 200 }, ); } diff --git a/src/components/mobile-nav.tsx b/src/components/mobile-nav.tsx index e0bdd9d8..8ec24538 100644 --- a/src/components/mobile-nav.tsx +++ b/src/components/mobile-nav.tsx @@ -3,7 +3,6 @@ import { AnimatePresence, motion } from "motion/react"; import Image from "next/image"; import { type ReactNode, useRef, useState } from "react"; -import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; import { mobileMenuVariants } from "@/lib/motion"; interface MobileNavProps { @@ -17,7 +16,7 @@ export function MobileNav({ children, menuLabel = "Open menu", closeLabel = "Close menu", - brandLabel = FALLBACK_HOTEL_NAME, + brandLabel = "", }: MobileNavProps) { const [open, setOpen] = useState(false); const detailsRef = useRef(null); diff --git a/src/components/public/logo-generator.tsx b/src/components/public/logo-generator.tsx index 322aff74..a654989a 100644 --- a/src/components/public/logo-generator.tsx +++ b/src/components/public/logo-generator.tsx @@ -16,10 +16,9 @@ import { renderToCanvas, } from "@/components/public/sprite-font"; import { ContentCard } from "@/components/public/ui"; -import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; export default function LogoGenerator({ - initialText = FALLBACK_HOTEL_NAME, + initialText = "", }: { initialText?: string; }) { diff --git a/src/components/surface-card.tsx b/src/components/surface-card.tsx new file mode 100644 index 00000000..6782784f --- /dev/null +++ b/src/components/surface-card.tsx @@ -0,0 +1,101 @@ +import Image from "next/image"; +import Link from "next/link"; +import type { CSSProperties, ReactNode } from "react"; + +const CARD_BORDER = + "color-mix(in srgb, var(--color-text-muted) 12%, transparent)"; +const CARD_DIVIDER = + "color-mix(in srgb, var(--color-text-muted) 8%, transparent)"; +const CARD_HEADER_BG = + "color-mix(in srgb, var(--color-primary) 10%, var(--color-surface))"; + +export interface SurfaceCardProps { + children: ReactNode; + /** Optional header title. */ + title?: string; + /** Optional header icon (image URL). */ + icon?: string; + /** Optional header action link. */ + actionHref?: string; + actionLabel?: string; + /** Class for the body wrapper (only used when a header is present). */ + bodyClassName?: string; + className?: string; + style?: CSSProperties; +} + +/** + * The single public-site card component. Mirrors the CSS `.content-card` visual + * language (--radius-lg corners, --shadow-card, hairline border). Pass `title` + * / `icon` / `actionHref` to render a section header, exactly like the old + * a card header. Without a header, children render directly so callers control + * padding via `className`. + */ +export function SurfaceCard({ + children, + title, + icon, + actionHref, + actionLabel, + bodyClassName, + className = "", + style, +}: SurfaceCardProps) { + const hasHeader = Boolean(title || icon || (actionHref && actionLabel)); + + return ( +
+ {hasHeader && ( +
+
+ {icon && ( + + )} + {title && ( +

+ {title} +

+ )} +
+ {actionHref && actionLabel && ( + + {actionLabel} + + )} +
+ )} + {hasHeader ? ( +
{children}
+ ) : ( + children + )} +
+ ); +} diff --git a/src/env.ts b/src/env.ts index 4b6e751a..06059814 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; // Minimal validated env for the foundation. When the Next.js app is added this // will move to @t3-oss/env-nextjs (the habbo-next pattern), but the data layer @@ -25,7 +24,12 @@ const schema = z .int() .positive() .default(10_000), - HOTEL_NAME: z.string().default(FALLBACK_HOTEL_NAME), + HOTEL_NAME: z + .string() + .min( + 1, + "HOTEL_NAME is not set — the site has not been configured/built yet.", + ), APP_URL: z.string().url().default("http://localhost:3000"), NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"), // Public imager URL — overrides the default /imaging relative path. diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 0521ea5d..7e5b42a7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,85 +1,24 @@ -import { eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { env } from "@/env"; import { getCachedJwtVersion } from "@/lib/auth/jwt-version-cache"; +import { + getLoginUser, + invalidateLoginCache, + isEmailUnverified, + normalizeLoginInput, + runDummyHashCheck, + verifyLoginPassword, +} from "@/lib/auth/login-core"; + +export { invalidateLoginCache }; + import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; -import { checkLogin } from "@/lib/auth/password"; import { verifyTotp } from "@/lib/auth/totp"; -import { cachedQuery, invalidateKey } from "@/lib/cached-db"; import { db, User, WebsiteLoginLogs } from "@/lib/db"; import { logger } from "@/lib/logger"; import { clientIp, rateLimit } from "@/lib/rate-limit"; -import { siteSettings } from "@/lib/services/site-settings"; - -interface LoginUser { - id: number; - username: string; - password: string | null; - rank: number; - mail: string | null; - mailVerified: string | null; - twoFactorConfirmedAt: string | null; - twoFactorSecret: string | null; -} - -/** - * Cached login user lookup — short TTL to survive brute-force attempts - * while still reflecting recent password/account changes reasonably fast. - */ -async function getLoginUser(username: string): Promise { - return cachedQuery( - `login:user:${username}`, - async () => { - const [result] = await db.execute<{ - id: number; - username: string; - password: string | null; - rank: number; - mail: string | null; - mail_verified: string | null; - two_factor_confirmed_at: string | null; - two_factor_secret: string | null; - }>(sql` - SELECT id, username, password, rank, mail, - mail_verified, - two_factor_confirmed_at, - two_factor_secret - FROM users - WHERE username = ${username} - LIMIT 1 - `); - const rows = result as unknown as Array<{ - id: number; - username: string; - password: string | null; - rank: number; - mail: string | null; - mail_verified: string | null; - two_factor_confirmed_at: string | null; - two_factor_secret: string | null; - }>; - return rows.length > 0 - ? { - id: rows[0].id, - username: rows[0].username, - password: rows[0].password, - rank: rows[0].rank, - mail: rows[0].mail, - mailVerified: rows[0].mail_verified, - twoFactorConfirmedAt: rows[0].two_factor_confirmed_at, - twoFactorSecret: rows[0].two_factor_secret, - } - : null; - }, - 15, // 15s TTL — brute-force protection without blocking legit changes - ); -} - -/** Call after password reset / rank change to invalidate the cached login row. */ -export async function invalidateLoginCache(username: string): Promise { - await invalidateKey(`login:user:${username}`); -} async function verify2faCode(userId: number, code: string): Promise { const [user] = await db @@ -149,8 +88,10 @@ export const { handlers, signOut, auth } = NextAuth({ code: { label: "2FA code", type: "text" }, }, authorize: async (credentials) => { - const username = String(credentials?.username ?? "").trim(); - const password = String(credentials?.password ?? ""); + const { username, password } = normalizeLoginInput( + credentials?.username, + credentials?.password, + ); if (!username || !password) return null; const ip = await clientIp(); @@ -161,28 +102,14 @@ export const { handlers, signOut, auth } = NextAuth({ const user = await getLoginUser(username); if (!user) { // Prevent timing-based enumeration: always run a dummy hash check. - await checkLogin( - password, - "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", - { - convertPasswords: false, - }, - ); + await runDummyHashCheck(password); return null; } - // Byte-compatible AtomCMS check (argon2id + legacy md5/bcrypt upgrade). - if (!user.password) return null; - const res = await checkLogin(password, user.password, { - convertPasswords: env.CONVERT_PASSWORDS, - }); + const res = await verifyLoginPassword(user, password); if (!res.valid) return null; - if ( - (await siteSettings.getBool("require_email_verification", false)) && - user.mail && - user.mailVerified !== "1" - ) { + if (await isEmailUnverified(user)) { return null; } diff --git a/src/lib/auth/login-core.ts b/src/lib/auth/login-core.ts new file mode 100644 index 00000000..41e4c354 --- /dev/null +++ b/src/lib/auth/login-core.ts @@ -0,0 +1,122 @@ +import { sql } from "drizzle-orm"; +import { env } from "@/env"; +import { checkLogin } from "@/lib/auth/password"; +import { cachedQuery, invalidateKey } from "@/lib/cached-db"; +import { db } from "@/lib/db"; +import { siteSettings } from "@/lib/services/site-settings"; + +export interface LoginUser { + id: number; + username: string; + password: string | null; + rank: number; + mail: string | null; + mailVerified: string | null; + twoFactorConfirmedAt: string | null; + twoFactorSecret: string | null; +} + +/** + * Fixed dummy bcrypt hash used to keep timing roughly constant when a username + * does not exist, so attackers can't enumerate accounts by response time. + */ +const DUMMY_BCRYPT_HASH = + "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd"; + +/** + * Normalize credentials exactly like the registration flow hashes them, so + * accounts with accented/non-ASCII usernames or passwords verify correctly. + */ +export function normalizeLoginInput(username: unknown, password: unknown) { + return { + username: String(username ?? "") + .normalize("NFC") + .trim(), + password: String(password ?? "").normalize("NFC"), + }; +} + +/** + * Cached login user lookup — short TTL to survive brute-force attempts + * while still reflecting recent password/account changes reasonably fast. + */ +export async function getLoginUser( + username: string, +): Promise { + return cachedQuery( + `login:user:${username}`, + async () => { + const [result] = await db.execute<{ + id: number; + username: string; + password: string | null; + rank: number; + mail: string | null; + mail_verified: string | null; + two_factor_confirmed_at: string | null; + two_factor_secret: string | null; + }>(sql` + SELECT id, username, password, rank, mail, + mail_verified, + two_factor_confirmed_at, + two_factor_secret + FROM users + WHERE username = ${username} + LIMIT 1 + `); + const rows = result as unknown as Array<{ + id: number; + username: string; + password: string | null; + rank: number; + mail: string | null; + mail_verified: string | null; + two_factor_confirmed_at: string | null; + two_factor_secret: string | null; + }>; + return rows.length > 0 + ? { + id: rows[0].id, + username: rows[0].username, + password: rows[0].password, + rank: rows[0].rank, + mail: rows[0].mail, + mailVerified: rows[0].mail_verified, + twoFactorConfirmedAt: rows[0].two_factor_confirmed_at, + twoFactorSecret: rows[0].two_factor_secret, + } + : null; + }, + 15, // 15s TTL — brute-force protection without blocking legit changes + ); +} + +/** Call after password reset / rank change to invalidate the cached login row. */ +export async function invalidateLoginCache(username: string): Promise { + await invalidateKey(`login:user:${username}`); +} + +/** Runs a dummy hash check so missing-user responses stay timing-constant. */ +export async function runDummyHashCheck(password: string): Promise { + await checkLogin(password, DUMMY_BCRYPT_HASH, { convertPasswords: false }); +} + +/** Verifies the password against the stored hash and reports a possible upgrade. */ +export async function verifyLoginPassword( + user: LoginUser, + password: string, +): Promise<{ valid: boolean; upgradedHash?: string }> { + if (!user.password) return { valid: false }; + return checkLogin(password, user.password, { + convertPasswords: env.CONVERT_PASSWORDS, + }); +} + +/** True when email verification is required but this account hasn't verified yet. */ +export async function isEmailUnverified(user: LoginUser): Promise { + return ( + (await siteSettings.getBool("require_email_verification", false)) && + !!user.mail && + user.mailVerified !== "1" + ); +} diff --git a/src/lib/brand.ts b/src/lib/brand.ts deleted file mode 100644 index 8c3551be..00000000 --- a/src/lib/brand.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Single hardcoded fallback hotel brand. - * Override order at runtime: - * 1. website_settings.hotel_name - * 2. HOTEL_NAME env - * 3. this constant - * - * Safe for client components (no env / DB imports). - */ -export const FALLBACK_HOTEL_NAME = "Atom"; diff --git a/src/lib/hotel-name.ts b/src/lib/hotel-name.ts index ddf7a547..cdf9044a 100644 --- a/src/lib/hotel-name.ts +++ b/src/lib/hotel-name.ts @@ -1,17 +1,12 @@ import "server-only"; import { env } from "@/env"; -import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; -import { siteSettings } from "@/lib/services/site-settings"; /** - * Resolve the public hotel name: CMS setting → HOTEL_NAME env → FALLBACK_HOTEL_NAME. + * The public hotel name is read directly from the required HOTEL_NAME env var. + * There is no CMS override or hardcoded preset — the site must be configured, + * otherwise HOTEL_NAME fails validation at startup. */ export async function resolveHotelName(): Promise { - const fromSettings = await siteSettings.get("hotel_name", env.HOTEL_NAME); - const trimmed = fromSettings?.trim(); - if (trimmed) return trimmed; - const fromEnv = env.HOTEL_NAME?.trim(); - if (fromEnv) return fromEnv; - return FALLBACK_HOTEL_NAME; + return env.HOTEL_NAME; } diff --git a/src/lib/services/site-settings.ts b/src/lib/services/site-settings.ts index 92cf885a..a008e288 100644 --- a/src/lib/services/site-settings.ts +++ b/src/lib/services/site-settings.ts @@ -1,12 +1,10 @@ import "server-only"; -import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; import { db, WebsiteSetting } from "@/lib/db"; import { logger } from "@/lib/logger"; import { redis } from "@/lib/redis"; const DEFAULTS: Record = { - hotel_name: FALLBACK_HOTEL_NAME, habbo_imaging_url: "/imaging", logo_url: "", nitro_client_url: "", @@ -16,7 +14,7 @@ const DEFAULTS: Record = { const CACHE_TTL_MS = 300_000; const REDIS_CACHE_KEY = "site_settings"; // Short in-process window so repeated getters in one request (header, nav, -// footer all read hotel_name / logo) don't each pay a Redis round-trip. +// footer all read logo and other settings) don't each pay a Redis round-trip. // Redis stays the source of truth across instances. const MEMORY_TTL_MS = 60_000; diff --git a/vitest.config.ts b/vitest.config.ts index 7d8e89b9..d11f9789 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ // unit tests run without a populated .env. env: { SKIP_ENV_VALIDATION: "1", + HOTEL_NAME: "TestHotel", DATABASE_URL: "mysql://root:root@localhost:3306/test", }, testTimeout: 10000,