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 <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-15 21:02:57 +02:00
1 parent c186f51ae0
commit e101ea474e
6 files changed
+796 -104

No files matched your search

+35
View File
@@ -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<void> {
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
+27 -13
View File
@@ -6,27 +6,41 @@ export default async function RoomsPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
params: Promise<{ locale?: string }>;
searchParams: Promise<Record<string, string>>;
}) {
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,
);
@@ -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 (
<section className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="m-0 text-base font-bold text-[var(--admin-text)]">
Advanced key / value
</h2>
<p className="m-0 mt-0.5 text-xs text-[var(--admin-text-muted)]">
Raw website_settings rows. Prefer the grouped form above for common
keys.
</p>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={showManaged}
onChange={(e) => setShowManaged(e.target.checked)}
className="rounded border"
/>
Show keys already in the form
</label>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search keys, values, comments…"
className="pl-9"
/>
</div>
{canEdit ? (
<form
action={createSetting}
className="rounded-2xl border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4 grid gap-2 sm:grid-cols-[1fr_1.4fr_1fr_auto]"
>
<Input name="key" placeholder="key" required />
<Input name="value" placeholder="value" />
<Input name="comment" placeholder="comment (optional)" />
<Button type="submit">Add / overwrite</Button>
</form>
) : null}
{filtered.length === 0 ? (
<div className="admin-empty">No matching settings</div>
) : (
<div className="space-y-2">
{filtered.map((s) => (
<div
key={s.key}
className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"
>
<div className="flex flex-wrap items-center gap-2">
<strong className="text-sm font-mono min-w-[12rem]">
{s.key}
</strong>
{canEdit ? (
<>
<form
action={updateSetting}
className="flex flex-1 min-w-[220px] gap-2"
>
<input type="hidden" name="key" value={s.key} />
<Input
name="value"
defaultValue={s.value}
className="flex-1 font-mono text-xs"
/>
<Button type="submit" variant="outline" size="sm">
Save
</Button>
</form>
<form action={deleteSetting}>
<input type="hidden" name="key" value={s.key} />
<Button
type="submit"
variant="destructive"
size="icon"
aria-label={`Delete ${s.key}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</form>
</>
) : (
<code className="flex-1 text-xs break-all text-muted-foreground">
{s.value}
</code>
)}
</div>
{s.comment ? (
<p className="m-0 mt-1.5 text-xs text-muted-foreground">
{s.comment}
</p>
) : null}
</div>
))}
</div>
)}
</section>
);
}
@@ -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<string, string> = Object.fromEntries(
SETTINGS_GROUPS.flatMap((g) =>
g.fields
.filter((f) => f.defaultValue != null)
.map((f) => [f.key, f.defaultValue as string]),
),
);
@@ -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<string, string>;
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 (
<form onSubmit={handleSubmit} className="space-y-5">
{SETTINGS_GROUPS.map((group) => (
<GroupSection
key={group.id}
group={group}
values={values}
canEdit={canEdit}
onChange={handleChange}
/>
))}
{canEdit ? (
<div className="sticky bottom-3 z-10 flex justify-end">
<Button type="submit" disabled={isPending} className="shadow-lg">
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save settings
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">
You can view settings but need edit permission to change them.
</p>
)}
</form>
);
}
function GroupSection({
group,
values,
canEdit,
onChange,
}: {
group: SettingsGroup;
values: Record<string, string>;
canEdit: boolean;
onChange: (key: string, value: string) => void;
}) {
const Icon = ICONS[group.icon];
return (
<section className="rounded-2xl border border-[var(--admin-border)] bg-[var(--admin-surface)] p-5 space-y-4 shadow-card">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center shrink-0">
<Icon size={18} className="text-[var(--admin-accent)]" />
</div>
<div className="min-w-0">
<h2 className="m-0 text-base font-bold text-[var(--admin-text)]">
{group.title}
</h2>
{group.description ? (
<p className="m-0 mt-0.5 text-xs text-[var(--admin-text-muted)]">
{group.description}
</p>
) : null}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{group.fields.map((field) => (
<FieldControl
key={field.key}
field={field}
value={values[field.key] ?? ""}
canEdit={canEdit}
onChange={onChange}
/>
))}
</div>
</section>
);
}
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 (
<div
className={`${wide ? "sm:col-span-2" : ""} flex items-center justify-between gap-4 rounded-xl border border-[var(--admin-border)] px-4 py-3`}
>
<div className="min-w-0">
<Label htmlFor={field.key} className="text-sm font-semibold">
{field.label}
</Label>
{field.description ? (
<p className="m-0 mt-0.5 text-xs text-muted-foreground">
{field.description}
</p>
) : null}
</div>
<Switch
id={field.key}
checked={checked}
disabled={!canEdit}
onCheckedChange={(next) => onChange(field.key, next ? "1" : "0")}
/>
</div>
);
}
return (
<div className={`space-y-1.5 ${wide ? "sm:col-span-2" : ""}`}>
<Label htmlFor={field.key} className="text-sm font-semibold">
{field.label}
</Label>
{field.description ? (
<p className="m-0 text-xs text-muted-foreground">{field.description}</p>
) : null}
{type === "textarea" ? (
<Textarea
id={field.key}
value={value}
disabled={!canEdit}
placeholder={field.placeholder}
onChange={(e) => onChange(field.key, e.target.value)}
rows={3}
/>
) : (
<Input
id={field.key}
type={
type === "number"
? "number"
: type === "password"
? "password"
: "text"
}
value={value}
disabled={!canEdit}
placeholder={field.placeholder}
onChange={(e) => onChange(field.key, e.target.value)}
/>
)}
<code className="block text-[0.65rem] text-muted-foreground truncate">
{field.key}
</code>
</div>
);
}
+64 -91
View File
@@ -1,22 +1,32 @@
import { Cog, ExternalLink } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
createSetting,
deleteSetting,
updateSetting,
} from "@/actions/admin-settings";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { AdvancedSettingsPanel } from "./advanced-settings-panel";
import { FIELD_DEFAULTS, MANAGED_SETTING_KEYS } from "./cms-settings-config";
import { CmsSettingsForm } from "./cms-settings-form";
export const dynamic = "force-dynamic";
export const metadata = { title: "CMS Settings" };
const RELATED = [
{ href: "/admin/theme", label: "Theme & colors" },
{ href: "/admin/maintenance", label: "Maintenance" },
{ href: "/admin/vpn", label: "VPN / proxy block" },
{ href: "/admin/emulator", label: "Emulator config" },
] as const;
export default async function AdminSettings() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.settings");
const canEdit = canAccess(
permissions,
PERMS.SETTINGS_EDIT,
session.user.rank,
);
let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
try {
@@ -27,103 +37,66 @@ export default async function AdminSettings() {
settings = [];
}
const dbValues = new Map(settings.map((s) => [s.key, s.value]));
const values: Record<string, string> = Object.fromEntries(
MANAGED_SETTING_KEYS.map((key) => [
key,
dbValues.get(key) ?? FIELD_DEFAULTS[key] ?? "",
]),
);
const documented = settings.filter((s) => !!s.comment).length;
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<span className="text-lg">⚙️</span>
<div className="space-y-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Cog size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-2xl font-extrabold tracking-tight text-[var(--admin-text)]">
CMS Settings
</h1>
<p className="m-0 mt-0.5 text-sm text-[var(--admin-text-muted)]">
Configure hotel identity, imaging, client, radio and security.
</p>
</div>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t.rich("subtitle", { code: (chunks) => <code>{chunks}</code> })}
</p>
<div className="flex flex-wrap gap-2">
{RELATED.map((item) => (
<Link
key={item.href}
href={item.href}
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-1.5 text-xs font-semibold text-[var(--admin-text)] no-underline hover:border-[var(--admin-accent)] transition-colors"
>
{item.label}
<ExternalLink size={12} className="opacity-60" />
</Link>
))}
</div>
</div>
<div className="admin-stat-row">
<span className="admin-stat-chip">
{t("settingsCount", { count: settings.length })}
{settings.length} keys in database
</span>
<span className="admin-stat-chip">
{t("documentedCount", { count: documented })}
{MANAGED_SETTING_KEYS.length} managed in form
</span>
<span className="admin-stat-chip">{documented} documented</span>
</div>
<section>
<h2 className="admin-section-title">{t("addOverwrite")}</h2>
<form action={createSetting} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input name="key" placeholder={t("form.keyPlaceholder")} required />
<input
name="value"
placeholder={t("form.valuePlaceholder")}
className="flex-1 min-w-[160px]"
/>
<input name="comment" placeholder={t("form.commentPlaceholder")} />
<button type="submit" className="btn btn-primary">
{t("save")}
</button>
</div>
</form>
</section>
<CmsSettingsForm values={values} canEdit={canEdit} />
<section>
<h2 className="admin-section-title">
{t("settings")}{" "}
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{settings.length}
</span>
</h2>
{settings.length === 0 ? (
<div className="admin-empty">{t("noSettings")}</div>
) : (
<div className="grid">
{settings.map((s) => (
<div key={s.key} className="admin-card">
<div
style={{
display: "flex",
gap: "0.75rem",
alignItems: "baseline",
}}
>
<strong style={{ minWidth: 220 }}>{s.key}</strong>
<form
action={updateSetting}
style={{ display: "flex", gap: "0.5rem", flex: 1 }}
>
<input type="hidden" name="key" value={s.key} />
<input
name="value"
defaultValue={s.value}
style={{ flex: 1 }}
/>
<button type="submit" className="btn btn-outline">
{t("save")}
</button>
</form>
<form action={deleteSetting}>
<input type="hidden" name="key" value={s.key} />
<button type="submit" className="btn btn-danger">
✕
</button>
</form>
</div>
{s.comment ? (
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
{s.comment}
</p>
) : null}
</div>
))}
</div>
)}
</section>
</main>
<AdvancedSettingsPanel
settings={settings.map((s) => ({
key: s.key,
value: s.value,
comment: s.comment,
}))}
canEdit={canEdit}
/>
</div>
);
}