From e101ea474efb3240ea7f8e8a78ed18f67c39a856 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 15 Jul 2026 21:02:57 +0200 Subject: [PATCH] Fix rooms list crash and rebuild CMS settings UI. Rooms queried the wrong Prisma model and a non-existent owner relation. Settings now use grouped managed fields plus a searchable advanced key/value panel. Co-authored-by: Cursor --- src/actions/admin-settings.ts | 35 ++ src/app/admin/rooms/page.tsx | 40 ++- .../settings/advanced-settings-panel.tsx | 147 +++++++++ src/app/admin/settings/cms-settings-config.ts | 302 ++++++++++++++++++ src/app/admin/settings/cms-settings-form.tsx | 221 +++++++++++++ src/app/admin/settings/page.tsx | 155 ++++----- 6 files changed, 796 insertions(+), 104 deletions(-) create mode 100644 src/app/admin/settings/advanced-settings-panel.tsx create mode 100644 src/app/admin/settings/cms-settings-config.ts create mode 100644 src/app/admin/settings/cms-settings-form.tsx diff --git a/src/actions/admin-settings.ts b/src/actions/admin-settings.ts index 6793efd892..8b98b962fa 100644 --- a/src/actions/admin-settings.ts +++ b/src/actions/admin-settings.ts @@ -1,11 +1,46 @@ "use server"; import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config"; import { requirePermissionRateLimited } from "@/lib/admin/guard"; +import { actionOk, adminAction } from "@/lib/foundation/action"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; +const managedKeySet = new Set(MANAGED_SETTING_KEYS); + +const saveManagedSchema = z.object({ + settings: z.record(z.string(), z.string()), +}); + +export const saveManagedSettings = adminAction( + { + permission: PERMS.SETTINGS_EDIT, + schema: saveManagedSchema, + rateLimitKey: "admin-settings-save", + rateLimitMax: 30, + }, + async (ctx) => { + const entries = Object.entries(ctx.data.settings).filter(([key]) => + managedKeySet.has(key), + ); + await Promise.all( + entries.map(([key, value]) => + prisma.websiteSetting.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }), + ), + ); + siteSettings.reload(); + revalidatePath("/admin/settings"); + return actionOk({ saved: entries.length }); + }, +); + export async function updateSetting(formData: FormData): Promise { await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") diff --git a/src/app/admin/rooms/page.tsx b/src/app/admin/rooms/page.tsx index 91a6ae0074..759cd6e116 100644 --- a/src/app/admin/rooms/page.tsx +++ b/src/app/admin/rooms/page.tsx @@ -6,27 +6,41 @@ export default async function RoomsPage({ params: paramsPromise, searchParams, }: { - params: Promise<{ locale: string }>; + params: Promise<{ locale?: string }>; searchParams: Promise>; }) { const result = await fetchAdminList( { permission: PERMS.ROOMS_VIEW, - model: "room", - searchFields: ["name", "owner.username"], - include: { owner: { select: { username: true } } }, + // Prisma model is `Rooms` → client accessor `rooms`. + // Owner is denormalized on the row (ownerId / ownerName), not a relation. + model: "rooms", + searchFields: ["name", "ownerName"], + select: { + id: true, + name: true, + ownerId: true, + ownerName: true, + state: true, + users: true, + usersMax: true, + description: true, + }, + filterMap: { + filter_state: (v) => ({ state: v }), + }, mapRow: (r) => ({ - id: r.id, - name: r.name, - ownerName: r.owner.username, - ownerId: r.owner?.id ?? 0, - state: r.state, - users: r.users, - usersMax: r.usersMax, - description: r.description, + id: r.id as number, + name: r.name as string, + ownerName: (r.ownerName as string) || "—", + ownerId: (r.ownerId as number) ?? 0, + state: r.state as string, + users: r.users as number, + usersMax: r.usersMax as number, + description: (r.description as string) || "", }), }, - paramsPromise, + paramsPromise as Promise<{ locale: string }>, searchParams, ); diff --git a/src/app/admin/settings/advanced-settings-panel.tsx b/src/app/admin/settings/advanced-settings-panel.tsx new file mode 100644 index 0000000000..8bc1822988 --- /dev/null +++ b/src/app/admin/settings/advanced-settings-panel.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Search, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + createSetting, + deleteSetting, + updateSetting, +} from "@/actions/admin-settings"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { MANAGED_SETTING_KEYS } from "./cms-settings-config"; + +interface SettingRow { + key: string; + value: string; + comment: string | null; +} + +export function AdvancedSettingsPanel({ + settings, + canEdit, +}: { + settings: SettingRow[]; + canEdit: boolean; +}) { + const [query, setQuery] = useState(""); + const [showManaged, setShowManaged] = useState(false); + const managed = useMemo(() => new Set(MANAGED_SETTING_KEYS), []); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return settings.filter((s) => { + if (!showManaged && managed.has(s.key)) return false; + if (!q) return true; + return ( + s.key.toLowerCase().includes(q) || + s.value.toLowerCase().includes(q) || + (s.comment ?? "").toLowerCase().includes(q) + ); + }); + }, [settings, query, showManaged, managed]); + + return ( +
+
+
+

+ Advanced key / value +

+

+ Raw website_settings rows. Prefer the grouped form above for common + keys. +

+
+ +
+ +
+ + setQuery(e.target.value)} + placeholder="Search keys, values, comments…" + className="pl-9" + /> +
+ + {canEdit ? ( +
+ + + + +
+ ) : null} + + {filtered.length === 0 ? ( +
No matching settings
+ ) : ( +
+ {filtered.map((s) => ( +
+
+ + {s.key} + + {canEdit ? ( + <> +
+ + + +
+
+ + +
+ + ) : ( + + {s.value} + + )} +
+ {s.comment ? ( +

+ {s.comment} +

+ ) : null} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/admin/settings/cms-settings-config.ts b/src/app/admin/settings/cms-settings-config.ts new file mode 100644 index 0000000000..1fbe5a91f0 --- /dev/null +++ b/src/app/admin/settings/cms-settings-config.ts @@ -0,0 +1,302 @@ +export type FieldType = + | "text" + | "password" + | "url" + | "number" + | "boolean" + | "textarea"; + +export type SettingsGroupIcon = + | "building" + | "image" + | "gamepad" + | "music" + | "shield" + | "link" + | "user-plus"; + +export interface ManagedField { + key: string; + label: string; + description?: string; + type?: FieldType; + placeholder?: string; + defaultValue?: string; +} + +export interface SettingsGroup { + id: string; + title: string; + icon: SettingsGroupIcon; + description?: string; + fields: ManagedField[]; +} + +/** Keys managed by the structured CMS Settings form (not theme / VPN / maintenance pages). */ +export const SETTINGS_GROUPS: SettingsGroup[] = [ + { + id: "identity", + title: "Hotel identity", + icon: "building", + description: "Name, branding and defaults shown across the site.", + fields: [ + { + key: "hotel_name", + label: "Hotel name", + type: "text", + placeholder: "Epicnabbo", + defaultValue: "Atom", + }, + { + key: "cms_logo", + label: "Logo URL", + description: "Header logo path or absolute URL.", + type: "url", + placeholder: "/api/media/logo/…", + }, + { + key: "cms_favicon", + label: "Favicon URL", + type: "url", + placeholder: "/favicon.svg", + }, + { + key: "cms_header", + label: "Header background", + type: "url", + placeholder: "/assets/images/background.png", + defaultValue: "/assets/images/background.png", + }, + { + key: "default_dark", + label: "Dark mode by default", + description: "New visitors start in dark mode unless they override it.", + type: "boolean", + defaultValue: "0", + }, + { + key: "min_staff_rank", + label: "Minimum staff rank", + description: "Rank shown on the public staff page.", + type: "number", + defaultValue: "7", + }, + ], + }, + { + id: "imaging", + title: "Avatars & badges", + icon: "image", + description: "Imaging endpoints used for avatars and badge icons.", + fields: [ + { + key: "habbo_imaging_url", + label: "Habbo imaging upstream", + type: "url", + defaultValue: "https://www.habbo.com/habbo-imaging/avatarimage", + }, + { + key: "imaging_use_habbo_fallback", + label: "Use Habbo.com as avatar fallback", + type: "boolean", + defaultValue: "1", + }, + { + key: "badge_base_url", + label: "Badge icons base URL", + type: "url", + placeholder: "/swf/c_images/album1584", + }, + { + key: "badges_path", + label: "Badges path (rares page)", + type: "url", + }, + { + key: "furniture_icons_path", + label: "Furniture icons path", + type: "url", + }, + ], + }, + { + id: "client", + title: "Nitro client", + icon: "gamepad", + description: "Game client loaded at /client.", + fields: [ + { + key: "nitro_client_url", + label: "Nitro client URL", + description: "Absolute or same-origin URL for the Nitro iframe.", + type: "url", + placeholder: "https://…/client/", + }, + { + key: "nitro_files_root", + label: "Nitro-Files root (server path)", + description: + "Filesystem root used when importing furni / writing live assets.", + type: "text", + placeholder: "E:\\path\\to\\Nitro-Files", + }, + ], + }, + { + id: "assets", + title: "Game assets", + icon: "music", + description: "Public URLs and overrides for SWF / Nitro asset packages.", + fields: [ + { + key: "soundtrack_base_url", + label: "Song disks MP3 base URL", + type: "url", + defaultValue: "/swf/dcr/hof_furni/mp3/", + }, + { + key: "furni_data_mirror_path", + label: "FurnitureData mirror path", + type: "text", + }, + { + key: "figure_swf_base_url", + label: "Figure SWF base URL (override)", + type: "url", + }, + { + key: "effect_swf_base_url", + label: "Effect SWF base URL (override)", + type: "url", + }, + { + key: "pet_swf_base_url", + label: "Pet SWF base URL (override)", + type: "url", + }, + ], + }, + { + id: "radio", + title: "Radio", + icon: "music", + description: "Public radio player and DJ monitoring feeds.", + fields: [ + { + key: "radio_enabled", + label: "Enable radio player", + type: "boolean", + defaultValue: "0", + }, + { + key: "radio_name", + label: "Radio display name", + type: "text", + }, + { + key: "radio_stream_url", + label: "Stream URL", + type: "url", + }, + { + key: "radio_now_playing_api_url", + label: "Now-playing API URL", + type: "url", + }, + { + key: "radio_listeners_api_url", + label: "Listeners API URL", + type: "url", + }, + ], + }, + { + id: "security", + title: "Security & registration", + icon: "shield", + description: "Account limits, captcha and staff 2FA.", + fields: [ + { + key: "force_staff_2fa", + label: "Require 2FA for staff", + description: "Staff without 2FA are redirected to set it up.", + type: "boolean", + defaultValue: "0", + }, + { + key: "max_accounts_per_ip", + label: "Max accounts per IP", + description: "0 = unlimited.", + type: "number", + defaultValue: "0", + }, + { + key: "captcha_provider", + label: "Captcha provider", + description: "none | turnstile | recaptcha", + type: "text", + defaultValue: "none", + }, + { + key: "turnstile_site_key", + label: "Turnstile site key", + type: "text", + }, + { + key: "recaptcha_site_key", + label: "reCAPTCHA site key", + type: "text", + }, + { + key: "abuse_guard_enabled", + label: "Enable abuse guard", + type: "boolean", + defaultValue: "0", + }, + { + key: "abuse_guard_threshold", + label: "Abuse guard threshold", + type: "number", + defaultValue: "200", + }, + { + key: "abuse_guard_window_seconds", + label: "Abuse guard window (seconds)", + type: "number", + defaultValue: "10", + }, + ], + }, + { + id: "misc", + title: "Other", + icon: "link", + description: "Extra hotel features.", + fields: [ + { + key: "drawbadge.price", + label: "Draw-badge price", + type: "number", + defaultValue: "0", + }, + ], + }, +]; + +export const MANAGED_SETTING_KEYS: string[] = SETTINGS_GROUPS.flatMap((g) => + g.fields.map((f) => f.key), +); + +export const BOOLEAN_KEYS = new Set( + SETTINGS_GROUPS.flatMap((g) => + g.fields.filter((f) => f.type === "boolean").map((f) => f.key), + ), +); + +export const FIELD_DEFAULTS: Record = Object.fromEntries( + SETTINGS_GROUPS.flatMap((g) => + g.fields + .filter((f) => f.defaultValue != null) + .map((f) => [f.key, f.defaultValue as string]), + ), +); diff --git a/src/app/admin/settings/cms-settings-form.tsx b/src/app/admin/settings/cms-settings-form.tsx new file mode 100644 index 0000000000..d256732c78 --- /dev/null +++ b/src/app/admin/settings/cms-settings-form.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { + Building2, + Gamepad2, + ImageIcon, + Link as LinkIcon, + Loader2, + Music, + Save, + Shield, + UserPlus, +} from "lucide-react"; +import { useState } from "react"; +import { saveManagedSettings } from "@/actions/admin-settings"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { useServerAction } from "@/hooks/use-server-action"; +import { + BOOLEAN_KEYS, + type ManagedField, + SETTINGS_GROUPS, + type SettingsGroup, +} from "./cms-settings-config"; + +const ICONS = { + building: Building2, + image: ImageIcon, + gamepad: Gamepad2, + music: Music, + shield: Shield, + link: LinkIcon, + "user-plus": UserPlus, +} as const; + +export function CmsSettingsForm({ + values: initialValues, + canEdit, +}: { + values: Record; + canEdit: boolean; +}) { + const [values, setValues] = useState(initialValues); + const { isPending, run } = useServerAction(); + + function handleChange(key: string, value: string) { + setValues((prev) => ({ ...prev, [key]: value })); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!canEdit) return; + const normalized = { ...values }; + for (const key of BOOLEAN_KEYS) { + normalized[key] = values[key] === "1" ? "1" : "0"; + } + run(() => saveManagedSettings({ settings: normalized }), { + successMessage: "Settings saved.", + errorMessage: "Could not save settings.", + }); + } + + return ( +
+ {SETTINGS_GROUPS.map((group) => ( + + ))} + + {canEdit ? ( +
+ +
+ ) : ( +

+ You can view settings but need edit permission to change them. +

+ )} + + ); +} + +function GroupSection({ + group, + values, + canEdit, + onChange, +}: { + group: SettingsGroup; + values: Record; + canEdit: boolean; + onChange: (key: string, value: string) => void; +}) { + const Icon = ICONS[group.icon]; + return ( +
+
+
+ +
+
+

+ {group.title} +

+ {group.description ? ( +

+ {group.description} +

+ ) : null} +
+
+ +
+ {group.fields.map((field) => ( + + ))} +
+
+ ); +} + +function FieldControl({ + field, + value, + canEdit, + onChange, +}: { + field: ManagedField; + value: string; + canEdit: boolean; + onChange: (key: string, value: string) => void; +}) { + const type = field.type ?? "text"; + const wide = type === "textarea" || type === "boolean"; + + if (type === "boolean") { + const checked = value === "1" || value.toLowerCase() === "true"; + return ( +
+
+ + {field.description ? ( +

+ {field.description} +

+ ) : null} +
+ onChange(field.key, next ? "1" : "0")} + /> +
+ ); + } + + return ( +
+ + {field.description ? ( +

{field.description}

+ ) : null} + {type === "textarea" ? ( +