feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m55s

Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-21 21:08:33 +02:00
1 parent 2ff08e5127
commit ed7db6e048
76 files changed
+4834 -1376

No files matched your search

-2
View File
@@ -4,8 +4,6 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
type Achievement = {
name: string;
category: string;
-2
View File
@@ -5,8 +5,6 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
const CRITICAL = new Set(["critical", "error", "danger"]);
const WARNING = new Set(["warning", "warn"]);
@@ -0,0 +1,796 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
alertUser,
disconnectUser,
executeCommand,
forwardUser,
giveBadge,
giveCredits,
giveDiamonds,
giveDuckets,
hotelAlert,
sendGift,
setMotto,
setRank,
updateCatalog,
updateNavigator,
updateWordFilter,
} from "@/actions/commandocentrum";
import { useServerAction } from "@/hooks/use-server-action";
const RCON_ERROR = "RCON command failed. Is the emulator running?";
export function CommandocentrumControls() {
const t = useTranslations("pages.admin.commandocentrum");
const { run, isPending } = useServerAction();
const [hotelMessage, setHotelMessage] = useState("");
const [disconnect, setDisconnect] = useState({ userId: "", username: "" });
const [alert, setAlert] = useState({ userId: "", message: "" });
const [forward, setForward] = useState({ userId: "", roomId: "" });
const [credits, setCredits] = useState({ userId: "", credits: "" });
const [duckets, setDuckets] = useState({ userId: "", amount: "" });
const [diamonds, setDiamonds] = useState({ userId: "", amount: "" });
const [badge, setBadge] = useState({ userId: "", badge: "" });
const [motto, setMottoForm] = useState({ userId: "", motto: "" });
const [rank, setRankForm] = useState({ userId: "", rank: "" });
const [command, setCommand] = useState({ userId: "", command: "" });
const [gift, setGift] = useState({
userId: "",
itemId: "",
message: t("defaultGiftMessage"),
});
return (
<>
<section className="mt-6">
<h2 className="admin-section-title">{t("emulatorControls")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("updateCatalog")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("updateCatalogDesc")}
</p>
<button
type="button"
className="btn btn-primary"
disabled={isPending}
onClick={() =>
run(() => updateCatalog(), {
successMessage: "Catalog updated.",
errorMessage: RCON_ERROR,
})
}
>
{t("updateCatalog")}
</button>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("updateWordFilter")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("updateWordFilterDesc")}
</p>
<button
type="button"
className="btn btn-primary"
disabled={isPending}
onClick={() =>
run(() => updateWordFilter(), {
successMessage: "Word filter updated.",
errorMessage: RCON_ERROR,
})
}
>
{t("updateWordFilter")}
</button>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("updateNavigator")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("updateNavigatorDesc")}
</p>
<button
type="button"
className="btn btn-primary"
disabled={isPending}
onClick={() =>
run(() => updateNavigator(), {
successMessage: "Navigator updated.",
errorMessage: RCON_ERROR,
})
}
>
{t("updateNavigator")}
</button>
</div>
</div>
<div className="admin-card mt-6">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("hotelAlert")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("hotelAlertDesc")}
</p>
<div className="flex gap-2">
<input
value={hotelMessage}
onChange={(e) => setHotelMessage(e.target.value)}
required
maxLength={512}
placeholder={t("messageToBroadcast")}
className="flex-1 min-w-[220px]"
disabled={isPending}
/>
<button
type="button"
className="btn btn-danger"
disabled={isPending || !hotelMessage.trim()}
onClick={() =>
run(() => hotelAlert({ message: hotelMessage }), {
successMessage: "Hotel alert sent.",
errorMessage: RCON_ERROR,
onSuccess: () => setHotelMessage(""),
})
}
>
{t("sendAlert")}
</button>
</div>
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">{t("userActions")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("userActionsDesc")}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("actions.disconnect")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("disconnectDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={disconnect.userId}
onChange={(e) =>
setDisconnect((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("usernameRequired")}
value={disconnect.username}
onChange={(e) =>
setDisconnect((s) => ({ ...s, username: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<button
type="button"
className="btn btn-danger w-full"
disabled={
isPending ||
!disconnect.userId ||
!disconnect.username.trim()
}
onClick={() =>
run(
() =>
disconnectUser({
userId: Number(disconnect.userId),
username: disconnect.username,
}),
{
successMessage: "User disconnected.",
errorMessage: RCON_ERROR,
onSuccess: () =>
setDisconnect({ userId: "", username: "" }),
},
)
}
>
{t("actions.disconnect")}
</button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("actions.alert")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("alertDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={alert.userId}
onChange={(e) =>
setAlert((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("alertMessage")}
value={alert.message}
onChange={(e) =>
setAlert((s) => ({ ...s, message: e.target.value }))
}
maxLength={512}
className="w-full"
disabled={isPending}
/>
<button
type="button"
className="btn btn-primary w-full"
disabled={
isPending || !alert.userId || !alert.message.trim()
}
onClick={() =>
run(
() =>
alertUser({
userId: Number(alert.userId),
message: alert.message,
}),
{
successMessage: "Alert sent.",
errorMessage: RCON_ERROR,
onSuccess: () => setAlert({ userId: "", message: "" }),
},
)
}
>
{t("actions.alert")}
</button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("forwardToRoom")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("forwardDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={forward.userId}
onChange={(e) =>
setForward((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("roomId")}
value={forward.roomId}
onChange={(e) =>
setForward((s) => ({ ...s, roomId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<button
type="button"
className="btn btn-primary w-full"
disabled={isPending || !forward.userId || !forward.roomId}
onClick={() =>
run(
() =>
forwardUser({
userId: Number(forward.userId),
roomId: Number(forward.roomId),
}),
{
successMessage: "User forwarded.",
errorMessage: RCON_ERROR,
onSuccess: () =>
setForward({ userId: "", roomId: "" }),
},
)
}
>
{t("forwardUser")}
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveCredits")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveCreditsDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={credits.userId}
onChange={(e) =>
setCredits((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("amount")}
value={credits.credits}
onChange={(e) =>
setCredits((s) => ({ ...s, credits: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<button
type="button"
className="btn btn-primary w-full"
disabled={isPending || !credits.userId || !credits.credits}
onClick={() =>
run(
() =>
giveCredits({
userId: Number(credits.userId),
credits: Number(credits.credits),
}),
{
successMessage: "Credits sent.",
errorMessage: RCON_ERROR,
onSuccess: () =>
setCredits({ userId: "", credits: "" }),
},
)
}
>
{t("giveCredits")}
</button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveDuckets")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveDucketsDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={duckets.userId}
onChange={(e) =>
setDuckets((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("amount")}
value={duckets.amount}
onChange={(e) =>
setDuckets((s) => ({ ...s, amount: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<button
type="button"
className="btn btn-primary w-full"
disabled={isPending || !duckets.userId || !duckets.amount}
onClick={() =>
run(
() =>
giveDuckets({
userId: Number(duckets.userId),
amount: Number(duckets.amount),
}),
{
successMessage: "Duckets sent.",
errorMessage: RCON_ERROR,
onSuccess: () =>
setDuckets({ userId: "", amount: "" }),
},
)
}
>
{t("giveDuckets")}
</button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveDiamonds")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveDiamondsDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={diamonds.userId}
onChange={(e) =>
setDiamonds((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("amount")}
value={diamonds.amount}
onChange={(e) =>
setDiamonds((s) => ({ ...s, amount: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<button
type="button"
className="btn btn-primary w-full"
disabled={isPending || !diamonds.userId || !diamonds.amount}
onClick={() =>
run(
() =>
giveDiamonds({
userId: Number(diamonds.userId),
amount: Number(diamonds.amount),
}),
{
successMessage: "Diamonds sent.",
errorMessage: RCON_ERROR,
onSuccess: () =>
setDiamonds({ userId: "", amount: "" }),
},
)
}
>
{t("giveDiamonds")}
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveBadge")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveBadgeDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={badge.userId}
onChange={(e) =>
setBadge((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("badgeCode")}
value={badge.badge}
onChange={(e) =>
setBadge((s) => ({ ...s, badge: e.target.value }))
}
maxLength={32}
className="w-full"
disabled={isPending}
/>
<button
type="button"
className="btn btn-primary w-full"
disabled={isPending || !badge.userId || !badge.badge.trim()}
onClick={() =>
run(
() =>
giveBadge({
userId: Number(badge.userId),
badge: badge.badge,
}),
{
successMessage: "Badge granted.",
errorMessage: RCON_ERROR,
onSuccess: () => setBadge({ userId: "", badge: "" }),
},
)
}
>
{t("giveBadge")}
</button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("setMotto")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("setMottoDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={motto.userId}
onChange={(e) =>
setMottoForm((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("newMotto")}
value={motto.motto}
onChange={(e) =>
setMottoForm((s) => ({ ...s, motto: e.target.value }))
}
maxLength={127}
className="w-full"
disabled={isPending}
/>
<button
type="button"
className="btn btn-primary w-full"
disabled={isPending || !motto.userId || !motto.motto.trim()}
onClick={() =>
run(
() =>
setMotto({
userId: Number(motto.userId),
motto: motto.motto,
}),
{
successMessage: "Motto updated.",
errorMessage: RCON_ERROR,
onSuccess: () =>
setMottoForm({ userId: "", motto: "" }),
},
)
}
>
{t("setMotto")}
</button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("setRank")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("setRankDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={rank.userId}
onChange={(e) =>
setRankForm((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={0}
max={10}
placeholder={t("rankPlaceholder")}
value={rank.rank}
onChange={(e) =>
setRankForm((s) => ({ ...s, rank: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<button
type="button"
className="btn btn-primary w-full"
disabled={isPending || !rank.userId || rank.rank === ""}
onClick={() =>
run(
() =>
setRank({
userId: Number(rank.userId),
rank: Number(rank.rank),
}),
{
successMessage: "Rank updated.",
errorMessage: RCON_ERROR,
onSuccess: () => setRankForm({ userId: "", rank: "" }),
},
)
}
>
{t("setRank")}
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("executeCommand")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("executeCommandDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={command.userId}
onChange={(e) =>
setCommand((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("commandPlaceholder")}
value={command.command}
onChange={(e) =>
setCommand((s) => ({ ...s, command: e.target.value }))
}
maxLength={100}
className="w-full"
disabled={isPending}
/>
<button
type="button"
className="btn btn-primary w-full"
disabled={
isPending || !command.userId || !command.command.trim()
}
onClick={() =>
run(
() =>
executeCommand({
userId: Number(command.userId),
command: command.command,
}),
{
successMessage: "Command executed.",
errorMessage: RCON_ERROR,
onSuccess: () =>
setCommand({ userId: "", command: "" }),
},
)
}
>
{t("execute")}
</button>
</div>
</div>
<div className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("sendGift")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("sendGiftDesc")}
</p>
<div className="space-y-2">
<input
type="number"
min={1}
placeholder={t("userId")}
value={gift.userId}
onChange={(e) =>
setGift((s) => ({ ...s, userId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="number"
min={1}
placeholder={t("furniId")}
value={gift.itemId}
onChange={(e) =>
setGift((s) => ({ ...s, itemId: e.target.value }))
}
className="w-full"
disabled={isPending}
/>
<input
type="text"
placeholder={t("giftMessage")}
value={gift.message}
onChange={(e) =>
setGift((s) => ({ ...s, message: e.target.value }))
}
maxLength={255}
className="w-full"
disabled={isPending}
/>
<button
type="button"
className="btn btn-primary w-full"
disabled={isPending || !gift.userId || !gift.itemId}
onClick={() =>
run(
() =>
sendGift({
userId: Number(gift.userId),
itemId: Number(gift.itemId),
message: gift.message,
}),
{
successMessage: "Gift sent.",
errorMessage: RCON_ERROR,
onSuccess: () =>
setGift({
userId: "",
itemId: "",
message: t("defaultGiftMessage"),
}),
},
)
}
>
{t("sendGift")}
</button>
</div>
</div>
</div>
</section>
</>
);
}
+2 -432
View File
@@ -1,23 +1,6 @@
import os from "node:os";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
alertUser,
disconnectUser,
executeCommand,
forwardUser,
giveBadge,
giveCredits,
giveDiamonds,
giveDuckets,
hotelAlert,
sendGift,
setMotto,
setRank,
updateCatalog,
updateNavigator,
updateWordFilter,
} from "@/actions/commandocentrum";
import {
DiagnosticRow,
InfoItem,
@@ -28,6 +11,7 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
import { CommandocentrumControls } from "./commandocentrum-controls";
export const dynamic = "force-dynamic";
@@ -223,421 +207,7 @@ export default async function CommandoCentrum() {
</section>
</div>
<section className="mt-6">
<h2 className="admin-section-title">{t("emulatorControls")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<form action={updateCatalog} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("updateCatalog")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("updateCatalogDesc")}
</p>
<button type="submit" className="btn btn-primary">
{t("updateCatalog")}
</button>
</form>
<form action={updateWordFilter} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("updateWordFilter")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("updateWordFilterDesc")}
</p>
<button type="submit" className="btn btn-primary">
{t("updateWordFilter")}
</button>
</form>
<form action={updateNavigator} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("updateNavigator")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("updateNavigatorDesc")}
</p>
<button type="submit" className="btn btn-primary">
{t("updateNavigator")}
</button>
</form>
</div>
<form action={hotelAlert} className="admin-card mt-6">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("hotelAlert")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("hotelAlertDesc")}
</p>
<div className="flex gap-2">
<input
name="message"
required
maxLength={512}
placeholder={t("messageToBroadcast")}
className="flex-1 min-w-[220px]"
/>
<button type="submit" className="btn btn-danger">
{t("sendAlert")}
</button>
</div>
</form>
</section>
{/* ── User RCON actions ──────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">{t("userActions")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
{t("userActionsDesc")}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<form action={disconnectUser} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("actions.disconnect")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("disconnectDesc")}
</p>
<div className="space-y-2">
<input
name="userId"
type="number"
min={1}
placeholder={t("userId")}
required
className="w-full"
/>
<input
name="username"
type="text"
placeholder={t("usernameRequired")}
required
className="w-full"
/>
<button type="submit" className="btn btn-danger w-full">
{t("actions.disconnect")}
</button>
</div>
</form>
<form action={alertUser} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("actions.alert")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("alertDesc")}
</p>
<div className="space-y-2">
<input
name="userId"
type="number"
min={1}
placeholder={t("userId")}
required
className="w-full"
/>
<input
name="message"
type="text"
placeholder={t("alertMessage")}
required
maxLength={512}
className="w-full"
/>
<button type="submit" className="btn btn-primary w-full">
{t("actions.alert")}
</button>
</div>
</form>
<form action={forwardUser} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("forwardToRoom")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("forwardDesc")}
</p>
<div className="space-y-2">
<input
name="userId"
type="number"
min={1}
placeholder={t("userId")}
required
className="w-full"
/>
<input
name="roomId"
type="number"
min={1}
placeholder={t("roomId")}
required
className="w-full"
/>
<button type="submit" className="btn btn-primary w-full">
{t("forwardUser")}
</button>
</div>
</form>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<form action={giveCredits} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveCredits")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveCreditsDesc")}
</p>
<div className="space-y-2">
<input
name="userId"
type="number"
min={1}
placeholder={t("userId")}
required
className="w-full"
/>
<input
name="credits"
type="number"
min={1}
placeholder={t("amount")}
required
className="w-full"
/>
<button type="submit" className="btn btn-primary w-full">
{t("giveCredits")}
</button>
</div>
</form>
<form action={giveDuckets} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveDuckets")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveDucketsDesc")}
</p>
<div className="space-y-2">
<input
name="userId"
type="number"
min={1}
placeholder={t("userId")}
required
className="w-full"
/>
<input
name="amount"
type="number"
min={1}
placeholder={t("amount")}
required
className="w-full"
/>
<button type="submit" className="btn btn-primary w-full">
{t("giveDuckets")}
</button>
</div>
</form>
<form action={giveDiamonds} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveDiamonds")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveDiamondsDesc")}
</p>
<div className="space-y-2">
<input
name="userId"
type="number"
min={1}
placeholder={t("userId")}
required
className="w-full"
/>
<input
name="amount"
type="number"
min={1}
placeholder={t("amount")}
required
className="w-full"
/>
<button type="submit" className="btn btn-primary w-full">
{t("giveDiamonds")}
</button>
</div>
</form>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<form action={giveBadge} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("giveBadge")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("giveBadgeDesc")}
</p>
<div className="space-y-2">
<input
name="userId"
type="number"
min={1}
placeholder={t("userId")}
required
className="w-full"
/>
<input
name="badge"
type="text"
placeholder={t("badgeCode")}
required
maxLength={32}
className="w-full"
/>
<button type="submit" className="btn btn-primary w-full">
{t("giveBadge")}
</button>
</div>
</form>
<form action={setMotto} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("setMotto")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("setMottoDesc")}
</p>
<div className="space-y-2">
<input
name="userId"
type="number"
min={1}
placeholder={t("userId")}
required
className="w-full"
/>
<input
name="motto"
type="text"
placeholder={t("newMotto")}
required
maxLength={127}
className="w-full"
/>
<button type="submit" className="btn btn-primary w-full">
{t("setMotto")}
</button>
</div>
</form>
<form action={setRank} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("setRank")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("setRankDesc")}
</p>
<div className="space-y-2">
<input
name="userId"
type="number"
min={1}
placeholder={t("userId")}
required
className="w-full"
/>
<input
name="rank"
type="number"
min={0}
max={10}
placeholder={t("rankPlaceholder")}
required
className="w-full"
/>
<button type="submit" className="btn btn-primary w-full">
{t("setRank")}
</button>
</div>
</form>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<form action={executeCommand} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("executeCommand")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("executeCommandDesc")}
</p>
<div className="space-y-2">
<input
name="userId"
type="number"
min={1}
placeholder={t("userId")}
required
className="w-full"
/>
<input
name="command"
type="text"
placeholder={t("commandPlaceholder")}
required
maxLength={100}
className="w-full"
/>
<button type="submit" className="btn btn-primary w-full">
{t("execute")}
</button>
</div>
</form>
<form action={sendGift} className="admin-card">
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
{t("sendGift")}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
{t("sendGiftDesc")}
</p>
<div className="space-y-2">
<input
name="userId"
type="number"
min={1}
placeholder={t("userId")}
required
className="w-full"
/>
<input
name="itemId"
type="number"
min={1}
placeholder={t("furniId")}
required
className="w-full"
/>
<input
name="message"
type="text"
placeholder={t("giftMessage")}
maxLength={255}
defaultValue={t("defaultGiftMessage")}
className="w-full"
/>
<button type="submit" className="btn btn-primary w-full">
{t("sendGift")}
</button>
</div>
</form>
</div>
</section>
<CommandocentrumControls />
{/* ── Staff activity ─────────────────────────────────────── */}
<section className="mt-6">
-2
View File
@@ -8,8 +8,6 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
export default async function AdminEmailTemplates() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
+44 -11
View File
@@ -1,11 +1,14 @@
"use client";
import { Pencil } from "lucide-react";
import { Pencil, Trash2 } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { deleteEvent } from "@/actions/events";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface EventRow {
@@ -35,6 +38,8 @@ interface EventsTableProps {
export function EventsTable({ data }: EventsTableProps) {
const t = useTranslations("pages.admin.events");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const statusLabel: Record<string, string> = {
published: t("statusPublished"),
@@ -43,6 +48,20 @@ export function EventsTable({ data }: EventsTableProps) {
completed: t("statusCompleted"),
};
async function handleDelete(row: EventRow) {
const ok = await confirm({
title: "Delete event",
description: `Delete "${row.title}"? This cannot be undone.`,
confirmLabel: "Delete",
cancelLabel: "Cancel",
});
if (!ok) return;
run(() => deleteEvent({ id: row.id }), {
successMessage: "Event deleted!",
errorMessage: "Failed to delete event.",
});
}
const columns: DataTableColumn<EventRow>[] = [
{ key: "id", label: t("colId"), sortable: true },
{
@@ -88,20 +107,34 @@ export function EventsTable({ data }: EventsTableProps) {
key: "actions",
label: t("colActions"),
render: (_, row) => (
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/events/${row.id}`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/events/${row.id}`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
disabled={isPending}
onClick={() => handleDelete(row)}
aria-label="Delete event"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
),
},
];
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
/>
<>
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
/>
{confirmDialog}
</>
);
}
-2
View File
@@ -3,8 +3,6 @@ import { FaviconForm } from "./favicon-form";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
export default async function AdminFaviconPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
@@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { adminFetch } from "@/lib/admin-fetch";
import { runSseImport } from "@/lib/sse-client";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -55,52 +56,6 @@ interface PageMeta {
total: number;
}
// ── Shared SSE reader (mirrors import-clothing-client.tsx) ────────────────────
async function runSseImport(
url: string,
body: unknown,
onDone: (classname: string) => void,
onComplete: (succeeded: number, failed: number) => void,
): Promise<void> {
const res = await adminFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.body) {
toast.error("No response stream");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6));
if (
evt.type === "item_progress" &&
(evt.status === "done" || evt.status === "failed")
) {
onDone(evt.classname);
}
if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
}
}
onComplete(succeeded, failed);
}
// ── Source form ───────────────────────────────────────────────────────────────
interface SourceFormProps {
@@ -19,6 +19,7 @@ import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { getAvatarUrl } from "@/lib/imager";
import { adminFetch } from "@/lib/admin-fetch";
import { runSseImport } from "@/lib/sse-client";
// ── Shared types ─────────────────────────────────────────────────────────────
@@ -43,52 +44,6 @@ type Mode = "libs" | "sets";
const PER_PAGE = 100;
// ── Shared SSE reader ─────────────────────────────────────────────────────────
async function runSseImport(
url: string,
body: unknown,
onDone: (label: string) => void,
onComplete: (succeeded: number, failed: number) => void,
): Promise<void> {
const res = await adminFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.body) {
toast.error("No response stream");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6));
if (
evt.type === "item_progress" &&
(evt.status === "done" || evt.status === "failed")
) {
onDone(evt.classname);
}
if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
}
}
onComplete(succeeded, failed);
}
// ── Avatar preview (renders a mini avatar wearing the item) ───────────────────
// A default dressed avatar; the previewed part overrides/adds its slot so the
@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { adminFetch } from "@/lib/admin-fetch";
import { readSseStream } from "@/lib/sse-client";
interface PetItem {
lib: string;
@@ -109,34 +110,22 @@ export function ImportPetsClient() {
setBatchProgress(null);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let done = 0;
let succeeded = 0;
let failed = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
const evt = JSON.parse(part.slice(6));
if (evt.type === "item_progress" && evt.status === "done") {
done++;
markDone(evt.classname);
setBatchProgress({ done, total: items.length });
} else if (evt.type === "item_progress" && evt.status === "failed") {
done++;
setBatchProgress({ done, total: items.length });
} else if (evt.type === "batch_complete") {
succeeded = evt.succeeded;
failed = evt.failed;
}
await readSseStream(res.body, (evt) => {
if (evt.type === "item_progress" && evt.status === "done") {
done++;
markDone(String(evt.classname ?? ""));
setBatchProgress({ done, total: items.length });
} else if (evt.type === "item_progress" && evt.status === "failed") {
done++;
setBatchProgress({ done, total: items.length });
} else if (evt.type === "batch_complete") {
succeeded = Number(evt.succeeded ?? 0);
failed = Number(evt.failed ?? 0);
}
}
});
setBatchProgress(null);
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
+2
View File
@@ -14,6 +14,8 @@ import { setCsrfCookie } from "@/lib/foundation/security";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
// Request-time auth: requireStaff, CSRF cookie, and optional 2FA gate cannot be
// statically rendered. Child admin pages inherit this — leaf force-dynamic is redundant.
export const dynamic = "force-dynamic";
export default async function AdminLayout({
-2
View File
@@ -4,8 +4,6 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
// navigator_flatcats — emulator-owned flat (private-room) categories shown in the
// navigator. The Prisma delegate exists because the table has a primary key.
type Flatcat = {
+44 -11
View File
@@ -1,11 +1,14 @@
"use client";
import { Pencil } from "lucide-react";
import { Pencil, Trash2 } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { deletePoll } from "@/actions/polls";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface PollRow {
@@ -31,6 +34,8 @@ interface PollsTableProps {
export function PollsTable({ data }: PollsTableProps) {
const t = useTranslations("pages.admin.polls");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const statusLabel: Record<string, string> = {
active: t("statusActive"),
@@ -38,6 +43,20 @@ export function PollsTable({ data }: PollsTableProps) {
closed: t("statusClosed"),
};
async function handleDelete(row: PollRow) {
const ok = await confirm({
title: "Delete poll",
description: `Delete "${row.title}"? This cannot be undone.`,
confirmLabel: "Delete",
cancelLabel: "Cancel",
});
if (!ok) return;
run(() => deletePoll({ id: row.id }), {
successMessage: "Poll deleted!",
errorMessage: "Failed to delete poll.",
});
}
const columns: DataTableColumn<PollRow>[] = [
{ key: "id", label: t("colId"), sortable: true },
{
@@ -82,20 +101,34 @@ export function PollsTable({ data }: PollsTableProps) {
key: "actions",
label: t("colActions"),
render: (_, row) => (
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/polls/${row.id}`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/polls/${row.id}`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
disabled={isPending}
onClick={() => handleDelete(row)}
aria-label="Delete poll"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
),
},
];
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
/>
<>
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
/>
{confirmDialog}
</>
);
}
@@ -241,6 +241,14 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
type: "boolean",
defaultValue: "0",
},
{
key: "require_email_verification",
label: "Require email verification",
description:
"Block login until the account email is verified (accounts without email are unaffected).",
type: "boolean",
defaultValue: "0",
},
{
key: "max_accounts_per_ip",
label: "Max accounts per IP",
-2
View File
@@ -4,8 +4,6 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
type SubRow = {
id: number;
userId: number | null;
-2
View File
@@ -6,8 +6,6 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
type Tag = {
id: bigint;
name: string;
-2
View File
@@ -3,8 +3,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { VouchersClient, type VoucherRow } from "./vouchers-client";
export const dynamic = "force-dynamic";
export default async function AdminVouchers() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
-2
View File
@@ -3,8 +3,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { WordFilterClient } from "./wordfilter-client";
export const dynamic = "force-dynamic";
export default async function AdminWordFilter() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.WORDFILTER_VIEW, session.user.rank)) {