From ac5cd6bc3f6e28122ddc579ce011ae825aae8c36 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 27 Aug 2026 15:09:43 +0200 Subject: [PATCH 01/12] fix: normalize username and password with NFC in login flow precheckLogin already normalized the username with NFC, but the NextAuth credentials authorize handler only trimmed it. This caused a mismatch for accounts with accented/non-ASCII usernames: the precheck passed while the actual sign-in lookup found no user and returned 'invalid username or password'. Also normalize the password to NFC in both the precheck and the authorize handler to match how register.ts hashes it. --- src/actions/auth-precheck.ts | 2 +- src/lib/auth.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/actions/auth-precheck.ts b/src/actions/auth-precheck.ts index b5eee29a..3c353503 100644 --- a/src/actions/auth-precheck.ts +++ b/src/actions/auth-precheck.ts @@ -27,7 +27,7 @@ export async function precheckLogin( const u = String(username ?? "") .normalize("NFC") .trim(); - const p = String(password ?? ""); + const p = String(password ?? "").normalize("NFC"); if (!u || !p) return "invalid"; const ip = await clientIp(); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 0521ea5d..594b06ac 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -149,8 +149,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 = String(credentials?.username ?? "") + .normalize("NFC") + .trim(); + const password = String(credentials?.password ?? "").normalize("NFC"); if (!username || !password) return null; const ip = await clientIp(); From e3ed37d1706ab42305443c02311327ad4af9c26a Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 27 Aug 2026 15:12:03 +0200 Subject: [PATCH 02/12] build: align pinned Node.js version with CI runtime (26.8.1) .nvmrc and package.json engines.node must match the exact version the CI environment runs, otherwise scripts/check-node-toolchain.mjs fails the strict equality assertion. --- .nvmrc | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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": { From 89c1751e79966676bb97c7f2a784ba7b7a4bc1cf Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 27 Aug 2026 15:17:54 +0200 Subject: [PATCH 03/12] refactor: extract shared login credential verification into auth/login-core The username normalization, dummy-hash constant, password check and email-verification gate were duplicated between precheckLogin and the NextAuth credentials authorize handler. Move them into a single login-core module so both paths share one source of truth and stay consistent. --- src/actions/auth-precheck.test.ts | 70 +++++++++-------- src/actions/auth-precheck.ts | 56 +++----------- src/lib/auth.ts | 113 +++++---------------------- src/lib/auth/login-core.ts | 122 ++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 174 deletions(-) create mode 100644 src/lib/auth/login-core.ts 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 3c353503..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 ?? "").normalize("NFC"); + 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/lib/auth.ts b/src/lib/auth.ts index 594b06ac..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,10 +88,10 @@ export const { handlers, signOut, auth } = NextAuth({ code: { label: "2FA code", type: "text" }, }, authorize: async (credentials) => { - const username = String(credentials?.username ?? "") - .normalize("NFC") - .trim(); - const password = String(credentials?.password ?? "").normalize("NFC"); + const { username, password } = normalizeLoginInput( + credentials?.username, + credentials?.password, + ); if (!username || !password) return null; const ip = await clientIp(); @@ -163,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" + ); +} From 005af25773a071d171fd3d8f4ebc3457ddee6c33 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 27 Aug 2026 15:21:25 +0200 Subject: [PATCH 04/12] style: make home page layout more professional Unify the per-section neon gradient headers (blue/green/purple) into a single consistent SectionCard component with a calm surface header and hairline divider, constrain the page to a centered max-w-6xl container, and soften the hero/cards with rounded-3xl corners and subtle shadows. --- src/app/(site)/page.tsx | 457 +++++++++++--------------------- src/components/home-section.tsx | 84 ++++++ 2 files changed, 237 insertions(+), 304 deletions(-) create mode 100644 src/components/home-section.tsx diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index a8e83c7b..a1d9718f 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -8,6 +8,7 @@ import { AmbientOrbs } from "@/components/ambient-orbs"; import { AnimatedCounter } from "@/components/animated-counter"; import { HomeLoginForm } from "@/components/auth/home-login-form"; import { Clock } from "@/components/clock"; +import { SectionCard } from "@/components/home-section"; import { LanguageSwitcher } from "@/components/language-switcher"; import { Reveal } from "@/components/motion-reveal"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; @@ -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")} @@ -322,14 +323,12 @@ export default async function Home() { ].map((s) => (

@@ -374,52 +373,24 @@ export default async function Home() {
{/* Left: Login & Register */} -
-
- -

- Login -

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

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

-
-
+
{latestUsers.map((u) => (
-
- -
+ {u.username} @@ -498,187 +443,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 +601,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/components/home-section.tsx b/src/components/home-section.tsx new file mode 100644 index 00000000..91670bb5 --- /dev/null +++ b/src/components/home-section.tsx @@ -0,0 +1,84 @@ +import Image from "next/image"; +import Link from "next/link"; +import type { ReactNode } from "react"; + +interface SectionCardProps { + title: string; + icon?: string; + actionHref?: string; + actionLabel?: string; + children: ReactNode; + className?: string; + bodyClassName?: string; +} + +/** + * Consistent, restrained panel used across the home page. Replaces the + * per-section neon gradients with a single calm surface + hairline divider so + * the page reads as professional rather than game-like. + */ +export function SectionCard({ + title, + icon, + actionHref, + actionLabel, + children, + className, + bodyClassName, +}: SectionCardProps) { + return ( +
+
+
+ {icon && ( + + )} +

+ {title} +

+
+ {actionHref && actionLabel && ( + + {actionLabel} + + )} +
+
{children}
+
+ ); +} From 9f0ee74ce855526f0be933eb29d2fdf2e484d516 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 27 Aug 2026 15:24:59 +0200 Subject: [PATCH 05/12] style: apply consistent professional layout to register page Reuse the SectionCard component to unify the green/purple/blue neon section headers, center the page in a max-w-6xl container, and give the welcome panel and form card consistent rounded corners and subtle shadows, matching the home page. --- src/app/(site)/register/page.tsx | 248 ++++++++++--------------------- 1 file changed, 81 insertions(+), 167 deletions(-) diff --git a/src/app/(site)/register/page.tsx b/src/app/(site)/register/page.tsx index 8827c814..50ca72b1 100644 --- a/src/app/(site)/register/page.tsx +++ b/src/app/(site)/register/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { RegisterForm } from "@/components/auth/register-form"; import { Clock } from "@/components/clock"; +import { SectionCard } from "@/components/home-section"; import { LanguageSwitcher } from "@/components/language-switcher"; import { Reveal } from "@/components/motion-reveal"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; @@ -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} + /> +
From 087dd7d8736459d829c7997ff3fd2f200a7a0ec5 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 27 Aug 2026 15:32:32 +0200 Subject: [PATCH 06/12] style: apply consistent professional layout to login page Reuse the SectionCard component to unify the neon section headers, center the page in a max-w-6xl container, and give the welcome panel and login card consistent rounded corners and subtle shadows, matching the home and register pages. --- src/app/(site)/login/page.tsx | 250 +++++++++++----------------------- 1 file changed, 82 insertions(+), 168 deletions(-) diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index daa549d0..999700b9 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { LoginForm } from "@/components/auth/login-form"; import { Clock } from "@/components/clock"; +import { SectionCard } from "@/components/home-section"; import { LanguageSwitcher } from "@/components/language-switcher"; import { Reveal } from "@/components/motion-reveal"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; @@ -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} + /> +
From b3340dbfdfe7320ccdcd7d9604ae9fda3b0eb301 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 27 Aug 2026 15:42:30 +0200 Subject: [PATCH 07/12] style: unify remaining site card headers with SectionCard Convert the settings page neon gradient section headers (blue/purple/green) to the shared SectionCard, and replace the verify page's harsh multi-stop status gradients with subtle status-tinted headers while keeping the green/amber/red/blue semantics. The me page already used a consistent rounded-2xl card style, so it needed no change. --- src/app/(site)/settings/page.tsx | 310 ++++++++++++------------------- src/app/(site)/verify/page.tsx | 46 +++-- 2 files changed, 146 insertions(+), 210 deletions(-) diff --git a/src/app/(site)/settings/page.tsx b/src/app/(site)/settings/page.tsx index 2697aa42..b4071599 100644 --- a/src/app/(site)/settings/page.tsx +++ b/src/app/(site)/settings/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { updateMotto } from "@/actions/user-settings"; +import { SectionCard } from "@/components/home-section"; import { Reveal } from "@/components/motion-reveal"; import { auth } from "@/lib/auth"; import { db, User } from "@/lib/db"; @@ -47,18 +48,19 @@ export default async function SettingsPage() { {/* Header */}
-
-
- -

+
- {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 */}
= { - 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} From a3e3356ea7e18fe492a8ca890c6f8c4f4a83b1de Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 27 Aug 2026 15:51:32 +0200 Subject: [PATCH 08/12] style: align both card systems to shared design tokens Unify the public site by making SectionCard and the verify status card use the same --radius-lg / --shadow-card tokens and primary-tint header as the existing CSS .content-card used by all content pages. This makes the entire (site) group visually consistent without rewriting every page, and keeps the shadcn Card in components/ui/card.tsx (used by admin) intact. --- src/app/(site)/login/page.tsx | 3 +-- src/app/(site)/register/page.tsx | 3 +-- src/app/(site)/verify/page.tsx | 3 +-- src/components/home-section.tsx | 7 +++---- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index 999700b9..71ca3756 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -129,8 +129,7 @@ export default async function LoginPage() { borderColor: "color-mix(in srgb, var(--color-primary) 20%, transparent)", background: `url(/assets/images/background.png) center/cover no-repeat`, - boxShadow: - "0 1px 2px color-mix(in srgb, var(--color-text-muted) 12%, transparent), 0 8px 24px -12px color-mix(in srgb, var(--color-text-muted) 25%, transparent)", + boxShadow: "var(--shadow-card)", }} >
From ed6eaf33a0a8c6cc5ed9f1f133ff6fb61bd1863a Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 27 Aug 2026 15:59:15 +0200 Subject: [PATCH 09/12] style: convert remaining ad-hoc inline cards to shared SurfaceCard Introduce components/surface-card.tsx (Card + CardBody) mirroring the .content-card / SectionCard token set, and use it on the (site) pages that still hand-rolled card markup: me, search, and verify. This puts every public page on one of the shared card components (ContentCard, SectionCard, or SurfaceCard) for consistent radius/shadow/border. --- src/app/(site)/me/page.tsx | 78 +++++++-------------------------- src/app/(site)/search/page.tsx | 56 +++++------------------ src/app/(site)/verify/page.tsx | 13 ++---- src/components/surface-card.tsx | 43 ++++++++++++++++++ 4 files changed, 73 insertions(+), 117 deletions(-) create mode 100644 src/components/surface-card.tsx diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index 660eb7c9..53008c93 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, @@ -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)/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)/verify/page.tsx b/src/app/(site)/verify/page.tsx index 52f6fa7b..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"; @@ -30,15 +31,7 @@ function StatusCard({ return (
-
+
{children}
-
+
); } diff --git a/src/components/surface-card.tsx b/src/components/surface-card.tsx new file mode 100644 index 00000000..631bda20 --- /dev/null +++ b/src/components/surface-card.tsx @@ -0,0 +1,43 @@ +import type { CSSProperties, ReactNode } from "react"; + +const CARD_BORDER = + "color-mix(in srgb, var(--color-text-muted) 12%, transparent)"; + +/** + * Public-site surface card. Mirrors the CSS .content-card / SectionCard visual + * language (--radius-lg corners, --shadow-card, hairline border) so every page + * that opts out of ContentCard still shares one consistent component. + */ +export function SurfaceCard({ + className = "", + style, + children, +}: { + className?: string; + style?: CSSProperties; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** Padded body for a SurfaceCard. Override padding via className. */ +export function SurfaceCardBody({ + className = "p-5", + children, +}: { + className?: string; + children: ReactNode; +}) { + return
{children}
; +} From 555c783d55202ae4477eb49ca21fa24046637b29 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 27 Aug 2026 16:17:09 +0200 Subject: [PATCH 10/12] refactor: consolidate card components into a single SurfaceCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the three near-identical public card primitives (ContentCard for content pages, SectionCard for auth/account, and the new SurfaceCard) by merging SectionCard into SurfaceCard, which now supports an optional header (title/icon/action). Every remaining ad-hoc inline `rounded-2xl border` card across (site) is converted to SurfaceCard, preserving each card's unique visuals (background images, blur, gradients) via the style passthrough. Net result: the public site uses exactly two card components — ContentCard (CMS/content pages) and SurfaceCard (everything else) — and the shadcn Card in components/ui/card.tsx is left untouched for admin. Also deletes the now-unused components/home-section.tsx. --- src/app/(site)/login/page.tsx | 21 +++--- src/app/(site)/me/page.tsx | 12 ++-- src/app/(site)/page.tsx | 44 +++++------- src/app/(site)/register/page.tsx | 21 +++--- src/app/(site)/settings/page.tsx | 113 +++++++++++++++---------------- src/components/home-section.tsx | 83 ----------------------- src/components/surface-card.tsx | 106 ++++++++++++++++++++++------- 7 files changed, 178 insertions(+), 222 deletions(-) delete mode 100644 src/components/home-section.tsx diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index 71ca3756..02dea8b8 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -5,10 +5,10 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { LoginForm } from "@/components/auth/login-form"; import { Clock } from "@/components/clock"; -import { SectionCard } from "@/components/home-section"; 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"; @@ -123,13 +123,12 @@ export default async function LoginPage() {
{/* Left panel */}
-
-
+ {recentUsers.length > 0 && ( - ))}
- + )} {latestUsers.length > 0 && ( - ))}
- + )}

- - +

diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index 53008c93..7aee9cca 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -254,8 +254,8 @@ export default async function MePage({ ) : null} -
-
+ {alertRaw ? ( -
{alertRaw}

-
+
) : null} diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index a1d9718f..3c0c0e69 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -8,10 +8,10 @@ import { AmbientOrbs } from "@/components/ambient-orbs"; import { AnimatedCounter } from "@/components/animated-counter"; import { HomeLoginForm } from "@/components/auth/home-login-form"; import { Clock } from "@/components/clock"; -import { SectionCard } from "@/components/home-section"; 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"; @@ -321,15 +321,9 @@ export default async function Home() { icon: "/assets/images/icons/catalog.png", }, ].map((s) => ( -
-
+ ))}
@@ -373,7 +367,7 @@ export default async function Home() {
{/* Left: Login & Register */} - - + -
+ Register -
+ {latestUsers.length > 0 && ( - ))}
- + )} {/* Right: News */} - )} - +
{/* ── Online users ── */} {recentUsers.length > 0 && ( - ))}
- + )} @@ -601,7 +589,7 @@ export default async function Home() { {/* ── Recent photos ── */} {recentPhotos.length > 0 && ( - @@ -627,7 +615,7 @@ export default async function Home() { ))}
- +
)}
diff --git a/src/app/(site)/register/page.tsx b/src/app/(site)/register/page.tsx index 713bac2e..38595044 100644 --- a/src/app/(site)/register/page.tsx +++ b/src/app/(site)/register/page.tsx @@ -5,10 +5,10 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { RegisterForm } from "@/components/auth/register-form"; import { Clock } from "@/components/clock"; -import { SectionCard } from "@/components/home-section"; 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"; @@ -125,13 +125,12 @@ export default async function RegisterPage() {
{/* Left panel */}
-
-
+ {recentUsers.length > 0 && ( - ))}
- + )} {latestUsers.length > 0 && ( - ))}
- + )}

- - +

diff --git a/src/app/(site)/settings/page.tsx b/src/app/(site)/settings/page.tsx index b4071599..1fb926f3 100644 --- a/src/app/(site)/settings/page.tsx +++ b/src/app/(site)/settings/page.tsx @@ -4,8 +4,8 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { updateMotto } from "@/actions/user-settings"; -import { SectionCard } from "@/components/home-section"; 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"; @@ -47,8 +47,8 @@ export default async function SettingsPage() {
{/* Header */} -
-
+ {/* Profile card */} -
- +
{/* Motto form */} -
- + {/* Security card */} - {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/components/home-section.tsx b/src/components/home-section.tsx deleted file mode 100644 index 644904c0..00000000 --- a/src/components/home-section.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import Image from "next/image"; -import Link from "next/link"; -import type { ReactNode } from "react"; - -interface SectionCardProps { - title: string; - icon?: string; - actionHref?: string; - actionLabel?: string; - children: ReactNode; - className?: string; - bodyClassName?: string; -} - -/** - * Consistent, restrained panel used across the home page. Replaces the - * per-section neon gradients with a single calm surface + hairline divider so - * the page reads as professional rather than game-like. - */ -export function SectionCard({ - title, - icon, - actionHref, - actionLabel, - children, - className, - bodyClassName, -}: SectionCardProps) { - return ( -
-
-
- {icon && ( - - )} -

- {title} -

-
- {actionHref && actionLabel && ( - - {actionLabel} - - )} -
-
{children}
-
- ); -} diff --git a/src/components/surface-card.tsx b/src/components/surface-card.tsx index 631bda20..6782784f 100644 --- a/src/components/surface-card.tsx +++ b/src/components/surface-card.tsx @@ -1,22 +1,48 @@ +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))"; -/** - * Public-site surface card. Mirrors the CSS .content-card / SectionCard visual - * language (--radius-lg corners, --shadow-card, hairline border) so every page - * that opts out of ContentCard still shares one consistent component. - */ -export function SurfaceCard({ - className = "", - style, - children, -}: { +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; - children: ReactNode; -}) { +} + +/** + * 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 (
- {children} + {hasHeader && ( +
+
+ {icon && ( + + )} + {title && ( +

+ {title} +

+ )} +
+ {actionHref && actionLabel && ( + + {actionLabel} + + )} +
+ )} + {hasHeader ? ( +
{children}
+ ) : ( + children + )}
); } - -/** Padded body for a SurfaceCard. Override padding via className. */ -export function SurfaceCardBody({ - className = "p-5", - children, -}: { - className?: string; - children: ReactNode; -}) { - return
{children}
; -} From 164a4f4ef604826a31baa4c7fcbac494b6d23b03 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 27 Aug 2026 16:35:00 +0200 Subject: [PATCH 11/12] refactor: remove hotel-name fallback, fail fast when unconfigured Drop the hardcoded FALLBACK_HOTEL_NAME ("Atom") preset and the brand.ts module. HOTEL_NAME is now a required env var: if it (and the CMS hotel_name setting) is missing the site fails validation at startup/build with a clear message instead of silently rendering a placeholder hotel name. resolveHotelName() resolves CMS hotel_name -> required HOTEL_NAME only. Callers that used the preset (api/home route catch branch, CMS settings form default, mobile-nav/logo-generator prop defaults) now use the configured name or an empty default; the real name is already passed in by server parents. --- src/app/admin/settings/cms-settings-config.ts | 3 +-- src/app/api/home/route.ts | 4 ++-- src/components/mobile-nav.tsx | 3 +-- src/components/public/logo-generator.tsx | 3 +-- src/env.ts | 8 ++++++-- src/lib/brand.ts | 10 ---------- src/lib/hotel-name.ts | 15 +++++++-------- src/lib/services/site-settings.ts | 3 +-- 8 files changed, 19 insertions(+), 30 deletions(-) delete mode 100644 src/lib/brand.ts diff --git a/src/app/admin/settings/cms-settings-config.ts b/src/app/admin/settings/cms-settings-config.ts index 825ee15b..7b099f10 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 = @@ -50,7 +49,7 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [ label: "Hotel name", type: "text", placeholder: "Epicnabbo", - defaultValue: FALLBACK_HOTEL_NAME, + defaultValue: "", }, { key: "cms_logo", 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/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/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..2ec742c6 100644 --- a/src/lib/hotel-name.ts +++ b/src/lib/hotel-name.ts @@ -1,17 +1,16 @@ 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. + * Resolve the public hotel name: CMS `hotel_name` setting, falling back to the + * required HOTEL_NAME env var. There is no 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; + const fromSettings = ( + await siteSettings.get("hotel_name", env.HOTEL_NAME) + )?.trim(); + return fromSettings || env.HOTEL_NAME; } diff --git a/src/lib/services/site-settings.ts b/src/lib/services/site-settings.ts index 92cf885a..42abc845 100644 --- a/src/lib/services/site-settings.ts +++ b/src/lib/services/site-settings.ts @@ -1,12 +1,11 @@ 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, + hotel_name: "", habbo_imaging_url: "/imaging", logo_url: "", nitro_client_url: "", From 750fcb2e5ca70d21c6a825660b6aeb817e038b5c Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 27 Aug 2026 16:42:12 +0200 Subject: [PATCH 12/12] refactor: resolve hotel name directly from HOTEL_NAME env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveHotelName() now returns env.HOTEL_NAME directly — the single source of truth. The CMS hotel_name site setting and its DEFAULTS entry are removed as dead code since they no longer influence the displayed name. Call sites are unchanged (still await resolveHotelName()); only the lookup behind it is gone, so the public site always shows the configured env name with no DB round-trip and no preset. --- src/app/admin/settings/cms-settings-config.ts | 7 ------- src/lib/hotel-name.ts | 12 ++++-------- src/lib/services/site-settings.ts | 3 +-- vitest.config.ts | 1 + 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/app/admin/settings/cms-settings-config.ts b/src/app/admin/settings/cms-settings-config.ts index 7b099f10..dc33a47e 100644 --- a/src/app/admin/settings/cms-settings-config.ts +++ b/src/app/admin/settings/cms-settings-config.ts @@ -44,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: "", - }, { key: "cms_logo", label: "Logo URL", diff --git a/src/lib/hotel-name.ts b/src/lib/hotel-name.ts index 2ec742c6..cdf9044a 100644 --- a/src/lib/hotel-name.ts +++ b/src/lib/hotel-name.ts @@ -1,16 +1,12 @@ import "server-only"; import { env } from "@/env"; -import { siteSettings } from "@/lib/services/site-settings"; /** - * Resolve the public hotel name: CMS `hotel_name` setting, falling back to the - * required HOTEL_NAME env var. There is no hardcoded preset — the site must be - * configured, otherwise HOTEL_NAME fails validation at startup. + * 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) - )?.trim(); - return fromSettings || env.HOTEL_NAME; + return env.HOTEL_NAME; } diff --git a/src/lib/services/site-settings.ts b/src/lib/services/site-settings.ts index 42abc845..a008e288 100644 --- a/src/lib/services/site-settings.ts +++ b/src/lib/services/site-settings.ts @@ -5,7 +5,6 @@ import { logger } from "@/lib/logger"; import { redis } from "@/lib/redis"; const DEFAULTS: Record = { - hotel_name: "", habbo_imaging_url: "/imaging", logo_url: "", nitro_client_url: "", @@ -15,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,