feat(mod): lite /mod panel + clarify ACL vs housekeeping legacy
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m31s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-22 21:29:32 +02:00
1 parent a384c9a8bb
commit 084cca6ea4
18 files changed
+931 -77

No files matched your search

+12 -9
View File
@@ -12,8 +12,11 @@ import { rcon } from "@/lib/services/rcon";
const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
const CFH_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT] as const;
const MOD_ACTION_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] as const;
export const assignCfhTicket = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({
where: { id: ctx.data.ticketId },
@@ -42,7 +45,7 @@ const cfhStateSchema = z.object({
});
export const updateCfhState = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
{ permission: CFH_PERM, schema: cfhStateSchema },
async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({
where: { id: ctx.data.ticketId },
@@ -68,7 +71,7 @@ export const updateCfhState = adminAction(
);
export const closeCfhTicket = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
await prisma.supportTickets.update({
where: { id: ctx.data.ticketId },
@@ -91,7 +94,7 @@ export const closeCfhTicket = adminAction(
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
export const quickKick = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
async (ctx) => {
await rcon.disconnectUser(ctx.data.userId);
@@ -112,7 +115,7 @@ const muteSchema = z.object({
});
export const quickMute = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: muteSchema },
{ permission: MOD_ACTION_PERM, schema: muteSchema },
async (ctx) => {
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
@@ -129,7 +132,7 @@ export const quickMute = adminAction(
);
export const quickUnmute = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
async (ctx) => {
await rcon.unmuteUser(ctx.data.userId);
@@ -150,7 +153,7 @@ const alertSchema = z.object({
});
export const quickAlert = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: alertSchema },
{ permission: MOD_ACTION_PERM, schema: alertSchema },
async (ctx) => {
await rcon.alertUser(ctx.data.userId, ctx.data.message);
@@ -169,7 +172,7 @@ export const quickAlert = adminAction(
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
export const quickRoomKick = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
async (ctx) => {
await rcon.kickAll(ctx.data.roomId);
@@ -190,7 +193,7 @@ const broadcastSchema = z.object({
});
export const broadcastAlert = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
async (ctx) => {
if (ctx.data.type === "hotel") {
await rcon.hotelAlert(ctx.data.message);
+16
View File
@@ -106,6 +106,22 @@ export default async function AdminHousekeeping({
return (
<main>
<div
className="mb-4 rounded-lg border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-4 py-3 text-sm text-[var(--admin-text)]"
role="status"
>
<p className="m-0 font-semibold">{t("legacyBannerTitle")}</p>
<p className="m-0 mt-1 text-[var(--admin-text-muted)]">
{t("legacyBannerBody")}{" "}
<Link
href="/admin/permissions"
className="font-medium text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
{t("legacyBannerCta")}
</Link>
</p>
</div>
<div className="admin-tabs mb-4">
<Link
href="/admin/housekeeping?tab=overview"
@@ -74,6 +74,8 @@ export function CfhDetail({
canEdit,
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the public component API
currentUserId,
listHref = "/admin/moderation/cfh",
userLinkMode = "admin",
}: {
ticket: {
id: number;
@@ -91,6 +93,8 @@ export function CfhDetail({
banHistory: { count: number; recent: BanRecord[] };
canEdit: boolean;
currentUserId: number;
listHref?: string;
userLinkMode?: "admin" | "public";
}) {
const { run, isPending } = useServerAction();
const [alertMsg, setAlertMsg] = useState("");
@@ -98,11 +102,18 @@ export function CfhDetail({
const state = CFH_STATES[ticket.state] ?? CFH_STATES[0];
function userHref(user: UserInfo) {
if (userLinkMode === "public") {
return `/u/${encodeURIComponent(user.username)}`;
}
return `/admin/users/show/${user.id}`;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start gap-4">
<Link href="/admin/moderation/cfh">
<Link href={listHref}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -155,7 +166,7 @@ export function CfhDetail({
<CardContent>
{sender ? (
<Link
href={`/admin/users/show/${sender.id}`}
href={userHref(sender)}
className="flex items-center gap-3 hover:text-primary no-underline"
>
<User className="h-4 w-4" />
@@ -184,7 +195,7 @@ export function CfhDetail({
{reported ? (
<div>
<Link
href={`/admin/users/show/${reported.id}`}
href={userHref(reported)}
className="flex items-center gap-3 hover:text-primary no-underline"
>
<AlertTriangle className="h-4 w-4 text-[var(--admin-error)]" />
@@ -400,7 +411,7 @@ export function CfhDetail({
<CardContent>
{moderator ? (
<Link
href={`/admin/users/show/${moderator.id}`}
href={userHref(moderator)}
className="flex items-center gap-2 text-sm hover:text-primary no-underline"
>
<Shield className="h-3.5 w-3.5 text-primary" />
+21 -4
View File
@@ -20,9 +20,26 @@ export interface CfhRow {
stateLabel: string;
}
export function CfhTable({ data }: { data: PaginatedResult<CfhRow> }) {
export function CfhTable({
data,
basePath = "/admin/moderation/cfh",
userLinkMode = "admin",
}: {
data: PaginatedResult<CfhRow>;
/** Detail URL prefix (no trailing slash). */
basePath?: string;
/** Admin user show vs public profile. */
userLinkMode?: "admin" | "public";
}) {
const t = useTranslations("pages.admin.cfh");
function userHref(id: number, username: string) {
if (userLinkMode === "public" && username && username !== "—") {
return `/u/${encodeURIComponent(username)}`;
}
return `/admin/users/${id}`;
}
const columns: DataTableColumn<CfhRow>[] = [
{ key: "id", label: t("colId"), sortable: true },
{
@@ -32,7 +49,7 @@ export function CfhTable({ data }: { data: PaginatedResult<CfhRow> }) {
render: (_value, row) => (
<div className="min-w-0">
<Link
href={`/admin/moderation/cfh/${row.id}`}
href={`${basePath}/${row.id}`}
className="text-sm font-medium truncate block hover:underline"
>
{row.issue || t("noDescription")}
@@ -50,7 +67,7 @@ export function CfhTable({ data }: { data: PaginatedResult<CfhRow> }) {
render: (_value, row) =>
row.senderId ? (
<Link
href={`/admin/users/${row.senderId}`}
href={userHref(row.senderId, row.sender)}
className="hover:underline"
>
{row.sender}
@@ -66,7 +83,7 @@ export function CfhTable({ data }: { data: PaginatedResult<CfhRow> }) {
render: (_value, row) =>
row.reportedId ? (
<Link
href={`/admin/users/${row.reportedId}`}
href={userHref(row.reportedId, row.reported)}
className="font-medium hover:underline"
>
{row.reported}
@@ -98,6 +98,17 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
return (
<div className="space-y-6">
<div
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm text-[var(--admin-text)]"
role="status"
>
<p className="m-0 font-semibold">Live ACL</p>
<p className="m-0 mt-1 text-muted-foreground">
Rank ACL here controls real admin access. The Housekeeping table is
legacy reference only and does not gate routes.
</p>
</div>
{/* Header */}
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
+13
View File
@@ -0,0 +1,13 @@
import { ModActionsClient } from "@/app/admin/moderation/actions/mod-actions-client";
import { requireModPermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
export default async function ModQuickActionsPage() {
await requireModPermission([PERMS.MOD_ACTIONS, PERMS.MODERATION_EDIT]);
return (
<div className="space-y-6">
<ModActionsClient />
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { requireModPermission } from "@/lib/admin/guard";
import { activeBanWhere, unixNow } from "@/lib/bans";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
function fromUnix(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
}
export default async function ModBansPage() {
await requireModPermission([PERMS.MOD_BANS_VIEW, PERMS.BANS_VIEW]);
const t = await getTranslations("pages.admin.bans");
const now = unixNow();
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
try {
bans = await prisma.ban.findMany({
where: activeBanWhere(now),
orderBy: { timestamp: "desc" },
take: 100,
});
} catch {
bans = [];
}
const permanent = bans.filter((b) => b.banExpire === 0).length;
const accountBans = bans.filter((b) => b.type === "account").length;
return (
<main>
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
Read-only ban list for moderators. Create/lift bans requires the full
admin panel.
</p>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("activeBans")}
value={bans.length}
state={bans.length > 0 ? "warn" : "ok"}
icon="🔨"
/>
<StatusCard
label={t("permanent")}
value={permanent}
state={permanent > 0 ? "danger" : "neutral"}
icon="⛔"
/>
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
</div>
<section className="mt-6">
<h2 className="admin-section-title">
{t("activeBans")} ({bans.length})
</h2>
{bans.length === 0 ? (
<div className="admin-empty">{t("noBans")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colUserId")}</th>
<th>{t("colType")}</th>
<th>{t("colReason")}</th>
<th>{t("colExpires")}</th>
</tr>
</thead>
<tbody>
{bans.map((b) => (
<tr key={b.id}>
<td>{b.userId}</td>
<td>
<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)]">
{b.type}
</span>
</td>
<td>{b.banReason || "—"}</td>
<td>
{b.banExpire === 0 ? (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
{t("permanent")}
</span>
) : (
fromUnix(b.banExpire)
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { notFound } from "next/navigation";
import { CfhDetail } from "@/app/admin/moderation/cfh/[id]/cfh-detail";
import { requireModPermission } from "@/lib/admin/guard";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export default async function ModCfhDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireModPermission([PERMS.MOD_CFH_VIEW, PERMS.MODERATION_VIEW]);
const { session, permissions } = await getAdminContext();
const { id } = await params;
const ticketId = Number(id);
if (Number.isNaN(ticketId)) notFound();
const ticket = await prisma.supportTickets.findUnique({
where: { id: ticketId },
});
if (!ticket) notFound();
const canEdit =
canAccess(permissions, PERMS.MOD_CFH_EDIT, session.user.rank) ||
canAccess(permissions, PERMS.MODERATION_EDIT, session.user.rank);
const userIds = [ticket.senderId, ticket.reportedId, ticket.modId].filter(
Boolean,
);
const users =
userIds.length > 0
? await prisma.user.findMany({
where: { id: { in: userIds } },
select: {
id: true,
username: true,
look: true,
rank: true,
online: true,
ipRegister: true,
mail: true,
},
})
: [];
const userMap = Object.fromEntries(users.map((u) => [u.id, u]));
const reportedId = ticket.reportedId;
const [banCount, recentBans] = reportedId
? await Promise.all([
prisma.ban.count({ where: { userId: reportedId } }),
prisma.ban.findMany({
where: { userId: reportedId },
orderBy: { id: "desc" },
take: 5,
}),
])
: [0, []];
return (
<CfhDetail
ticket={{
id: ticket.id,
state: ticket.state,
type: ticket.type,
timestamp: ticket.timestamp,
issue: ticket.issue,
roomId: ticket.roomId,
category: ticket.category,
score: ticket.score,
}}
sender={userMap[ticket.senderId] ?? null}
reported={userMap[ticket.reportedId] ?? null}
moderator={userMap[ticket.modId] ?? null}
banHistory={{
count: banCount,
recent: recentBans.map((b) => ({
id: b.id,
reason: b.banReason,
type: b.type,
staff: `Staff #${b.userStaffId}`,
timestamp: b.timestamp,
})),
}}
canEdit={canEdit}
currentUserId={session.user.id}
listHref="/mod/cfh"
userLinkMode="public"
/>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { getTranslations } from "next-intl/server";
import { type CfhRow, CfhTable } from "@/app/admin/moderation/cfh/cfh-table";
import type { Prisma } from "@/generated/prisma/client";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function ModCfhListPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
await requireModPermission([PERMS.MOD_CFH_VIEW, PERMS.MODERATION_VIEW]);
const t = await getTranslations("pages.admin.cfh");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const stateRaw = raw.filter_state ?? raw.state;
const stateFilter =
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
const conditions: Prisma.SupportTicketsWhereInput[] = [];
if (stateFilter >= 0) {
conditions.push({ state: stateFilter });
}
if (parsed.search.trim()) {
const q = parsed.search.trim();
const asId = Number(q);
let userIds: number[] = [];
try {
const users = await prisma.user.findMany({
where: { username: { contains: q } },
select: { id: true },
take: 50,
});
userIds = users.map((u) => u.id);
} catch {
userIds = [];
}
conditions.push({
OR: [
{ issue: { contains: q } },
...(Number.isFinite(asId) && asId > 0 ? [{ id: asId }] : []),
...(userIds.length
? [
{ senderId: { in: userIds } },
{ reportedId: { in: userIds } },
{ modId: { in: userIds } },
]
: []),
],
});
}
const where: Prisma.SupportTicketsWhereInput =
conditions.length === 0
? {}
: conditions.length === 1
? conditions[0]
: { AND: conditions };
const SORT_MAP: Record<
string,
Prisma.SupportTicketsOrderByWithRelationInput
> = {
id: { id: "desc" },
issue: { issue: "asc" },
sender: { senderId: "asc" },
reported: { reportedId: "asc" },
mod: { modId: "asc" },
state: { state: "asc" },
};
const baseOrder = SORT_MAP[parsed.sort ?? "id"] ?? { id: "desc" };
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
const orderBy = {
[orderField]: parsed.order,
} as Prisma.SupportTicketsOrderByWithRelationInput;
const total = await prisma.supportTickets.count({ where }).catch(() => 0);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const tickets = await prisma.supportTickets
.findMany({
where,
orderBy,
skip: pagination.offset,
take: pagination.perPage,
})
.catch(() => []);
const userIds = [
...new Set([
...tickets.map((x) => x.senderId),
...tickets.map((x) => x.reportedId),
...tickets.map((x) => x.modId),
]),
].filter(Boolean);
let userMap = new Map<number, string>();
if (userIds.length > 0) {
try {
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
});
userMap = new Map(users.map((u) => [u.id, u.username]));
} catch {
userMap = new Map();
}
}
const stateLabels: Record<number, string> = {
0: t("stateOpen"),
1: t("statePicked"),
2: t("stateClosed"),
};
const rows: CfhRow[] = tickets.map((ticket) => ({
id: ticket.id,
issue: ticket.issue,
roomId: ticket.roomId,
when: ticket.timestamp
? new Date(ticket.timestamp * 1000)
.toISOString()
.slice(0, 16)
.replace("T", " ")
: "—",
sender: userMap.get(ticket.senderId) ?? "—",
senderId: ticket.senderId,
reported: userMap.get(ticket.reportedId) ?? "—",
reportedId: ticket.reportedId,
mod: userMap.get(ticket.modId) ?? "—",
state: ticket.state,
stateLabel: stateLabels[ticket.state] ?? String(ticket.state),
}));
return (
<div className="space-y-6">
{total === 0 && !parsed.search && stateFilter < 0 ? (
<div className="admin-empty">{t("noTickets")}</div>
) : (
<CfhTable
basePath="/mod/cfh"
userLinkMode="public"
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
/>
)}
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { Ban, Gavel, LogOut, Radio, Shield } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireMod } from "@/lib/admin/guard";
import { setCsrfCookie } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export default async function ModLayout({ children }: { children: ReactNode }) {
const staff = await requireMod();
let csrfToken = "";
try {
csrfToken = await setCsrfCookie();
} catch {
csrfToken = "";
}
if (await siteSettings.getBool("force_staff_2fa", false)) {
const u = await prisma.user
.findUnique({
where: { id: staff.id },
select: { twoFactorConfirmedAt: true },
})
.catch(() => null);
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
}
const t = await getTranslations("pages.mod");
const { permissions } = await getAdminContext();
const showCfh =
canAccess(permissions, PERMS.MOD_CFH_VIEW, staff.rank) ||
canAccess(permissions, PERMS.MODERATION_VIEW, staff.rank);
const showActions =
canAccess(permissions, PERMS.MOD_ACTIONS, staff.rank) ||
canAccess(permissions, PERMS.MODERATION_EDIT, staff.rank);
const showBans =
canAccess(permissions, PERMS.MOD_BANS_VIEW, staff.rank) ||
canAccess(permissions, PERMS.BANS_VIEW, staff.rank);
const showAdmin = canAccess(permissions, PERMS.ADMIN_DASHBOARD, staff.rank);
const nav = [
{ href: "/mod", label: t("nav.overview"), icon: Shield, show: true },
{ href: "/mod/cfh", label: t("nav.cfh"), icon: Radio, show: showCfh },
{
href: "/mod/actions",
label: t("nav.actions"),
icon: Gavel,
show: showActions,
},
{ href: "/mod/bans", label: t("nav.bans"), icon: Ban, show: showBans },
].filter((item) => item.show);
return (
<>
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
<div
data-admin
className="min-h-screen"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<header className="border-b border-[var(--admin-border)] bg-[var(--admin-sidebar-background)]">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<p className="m-0 text-xs font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
{t("badge")}
</p>
<h1 className="m-0 truncate text-lg font-semibold text-[var(--admin-sidebar-text-readable)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-sidebar-muted-readable)]">
{staff.username} · rank {staff.rank}
</p>
</div>
<div className="flex items-center gap-1.5">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
{showAdmin ? (
<Link
href="/admin"
className="rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--admin-sidebar-muted-readable)] no-underline hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]"
>
{t("nav.fullAdmin")}
</Link>
) : null}
<Link
href="/"
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--admin-sidebar-muted-readable)] no-underline hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]"
>
<LogOut size={14} />
{t("nav.backToSite")}
</Link>
</div>
</div>
<nav className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4 pb-3">
{nav.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--admin-sidebar-muted-readable)] no-underline hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]"
>
<Icon size={14} />
{item.label}
</Link>
);
})}
</nav>
</header>
<main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main>
</div>
</>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { Ban, Gavel, Radio, Shield } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatsCard } from "@/components/admin/stats-card";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export default async function ModDashboardPage() {
const t = await getTranslations("pages.mod");
const { session, permissions } = await getAdminContext();
const rank = session.user.rank;
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const minStaffRank = await getMinStaffRank();
const showCfh =
canAccess(permissions, PERMS.MOD_CFH_VIEW, rank) ||
canAccess(permissions, PERMS.MODERATION_VIEW, rank);
const showActions =
canAccess(permissions, PERMS.MOD_ACTIONS, rank) ||
canAccess(permissions, PERMS.MODERATION_EDIT, rank);
const showBans =
canAccess(permissions, PERMS.MOD_BANS_VIEW, rank) ||
canAccess(permissions, PERMS.BANS_VIEW, rank);
const [openCfh, todayBans, modsOnline] = await Promise.all([
showCfh
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
: Promise.resolve(0),
showBans
? prisma.ban
.count({ where: { timestamp: { gte: todayStart } } })
.catch(() => 0)
: Promise.resolve(0),
prisma.user
.count({ where: { online: "1", rank: { gte: minStaffRank } } })
.catch(() => 0),
]);
return (
<div className="space-y-6">
<div>
<h2 className="admin-section-title m-0">{t("overviewTitle")}</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
{t("overviewSubtitle")}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{showCfh ? (
<StatsCard
title={t("stats.openCfh")}
value={openCfh}
icon={<Radio className="h-4 w-4" />}
href="/mod/cfh"
/>
) : null}
{showBans ? (
<StatsCard
title={t("stats.bansToday")}
value={todayBans}
icon={<Ban className="h-4 w-4" />}
href="/mod/bans"
/>
) : null}
<StatsCard
title={t("stats.modsOnline")}
value={modsOnline}
icon={<Shield className="h-4 w-4" />}
/>
{showActions ? (
<StatsCard
title={t("stats.quickActions")}
value="→"
description={t("stats.quickActionsHint")}
icon={<Gavel className="h-4 w-4" />}
href="/mod/actions"
/>
) : null}
</div>
<section className="admin-card space-y-2 p-4">
<h3 className="m-0 text-sm font-semibold">{t("shortcutsTitle")}</h3>
<ul className="m-0 list-none space-y-1 p-0 text-sm">
{showCfh ? (
<li>
<Link href="/mod/cfh" className="text-primary hover:underline">
{t("nav.cfh")}
</Link>
</li>
) : null}
{showActions ? (
<li>
<Link
href="/mod/actions"
className="text-primary hover:underline"
>
{t("nav.actions")}
</Link>
</li>
) : null}
{showBans ? (
<li>
<Link href="/mod/bans" className="text-primary hover:underline">
{t("nav.bans")}
</Link>
</li>
) : null}
</ul>
</section>
</div>
);
}
+56 -5
View File
@@ -1,18 +1,43 @@
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import type { Session } from "next-auth";
import { getTranslations } from "next-intl/server";
import { LanguageSwitcher } from "@/components/language-switcher";
import { resolveHotelName } from "@/lib/hotel-name";
import { MobileNav } from "@/components/mobile-nav";
import { NavDropdown } from "@/components/nav-dropdown";
import { NavbarColorPicker } from "@/components/navbar-color-picker";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { resolveHotelName } from "@/lib/hotel-name";
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
export async function Navigation({ session }: { session: Session | null }) {
const t = await getTranslations("nav");
const brandLabel = await resolveHotelName();
const isStaff = (session?.user?.rank ?? 0) >= 7;
let showAdmin = false;
let showMod = false;
if (session?.user) {
const ctx = await getApiAdminContext();
if (ctx) {
showAdmin = canAccess(
ctx.permissions,
PERMS.ADMIN_DASHBOARD,
ctx.session.user.rank,
);
showMod =
canAccess(
ctx.permissions,
PERMS.MOD_DASHBOARD,
ctx.session.user.rank,
) ||
canAccess(
ctx.permissions,
PERMS.MODERATION_VIEW,
ctx.session.user.rank,
) ||
canAccess(ctx.permissions, PERMS.MOD_CFH_VIEW, ctx.session.user.rank) ||
canAccess(ctx.permissions, PERMS.MOD_ACTIONS, ctx.session.user.rank);
}
}
return (
<nav
@@ -154,7 +179,20 @@ export async function Navigation({ session }: { session: Session | null }) {
</>
) : null}
{isStaff ? (
{showMod ? (
<Link href="/mod" className="nav-item">
<Image
src="/assets/images/icons/navigation/home.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("mod")}
</Link>
) : null}
{showAdmin ? (
<Link href="/admin" className="nav-item">
<Image
src="/assets/images/icons/navigation/home.png"
@@ -298,7 +336,20 @@ export async function Navigation({ session }: { session: Session | null }) {
</>
) : null}
{isStaff ? (
{showMod ? (
<Link href="/mod" className="nav-item">
<Image
src="/assets/images/icons/navigation/home.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("mod")}
</Link>
) : null}
{showAdmin ? (
<Link href="/admin" className="nav-item">
<Image
src="/assets/images/icons/navigation/home.png"
+53 -21
View File
@@ -3,6 +3,7 @@ import Link from "next/link";
import type { Session } from "next-auth";
import { signOut } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
@@ -70,7 +71,25 @@ export async function TopHeader({ session }: { session: Session | null }) {
"https://www.habbo.com/habbo-imaging/avatarimage",
)) ?? "";
const avatar = avatarImageUrl(imager, look, { headOnly: true, direction: 2 });
const isStaff = (session.user.rank ?? 0) >= 7;
let showAdmin = false;
let showMod = false;
const ctx = await getApiAdminContext();
if (ctx) {
showAdmin = canAccess(
ctx.permissions,
PERMS.ADMIN_DASHBOARD,
ctx.session.user.rank,
);
showMod =
canAccess(ctx.permissions, PERMS.MOD_DASHBOARD, ctx.session.user.rank) ||
canAccess(
ctx.permissions,
PERMS.MODERATION_VIEW,
ctx.session.user.rank,
) ||
canAccess(ctx.permissions, PERMS.MOD_CFH_VIEW, ctx.session.user.rank) ||
canAccess(ctx.permissions, PERMS.MOD_ACTIONS, ctx.session.user.rank);
}
const textColor =
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
@@ -88,36 +107,49 @@ export async function TopHeader({ session }: { session: Session | null }) {
</div>
<div className="flex items-center gap-x-1 sm:gap-x-3">
{isStaff ? (
{showAdmin || showMod ? (
<details className="relative">
<summary
className="list-none cursor-pointer font-semibold text-xs sm:text-sm [&::-webkit-details-marker]:hidden"
style={{ color: textColor }}
>
<span className="hidden sm:inline">Administration</span>
<span className="sm:hidden">Admin</span>
<span className="hidden sm:inline">Staff</span>
<span className="sm:hidden">Staff</span>
</summary>
<div
role="menu"
className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[180px] rounded-xl z-50 py-2"
>
<Link href="/admin" className="dropdown-item" role="menuitem">
Admin panel
</Link>
<Link
href="/admin/commandocentrum"
className="dropdown-item"
role="menuitem"
>
Commandocentrum
</Link>
<Link
href="/admin/logs"
className="dropdown-item"
role="menuitem"
>
Logs
</Link>
{showMod ? (
<Link href="/mod" className="dropdown-item" role="menuitem">
Mod panel
</Link>
) : null}
{showAdmin ? (
<>
<Link
href="/admin"
className="dropdown-item"
role="menuitem"
>
Admin panel
</Link>
<Link
href="/admin/commandocentrum"
className="dropdown-item"
role="menuitem"
>
Commandocentrum
</Link>
<Link
href="/admin/logs"
className="dropdown-item"
role="menuitem"
>
Logs
</Link>
</>
) : null}
<Link href="/logo" className="dropdown-item" role="menuitem">
Logo generator
</Link>
+35 -1
View File
@@ -26,7 +26,9 @@ export async function requireStaff(): Promise<StaffUser> {
* Staff gate plus a specific ACL slug. Use for FormData server actions that
* must not stop at admin.dashboard alone (RCON, user edits, settings writes).
*/
export async function requirePermission(permission: string): Promise<StaffUser> {
export async function requirePermission(
permission: string,
): Promise<StaffUser> {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
redirectSafe("/", "/");
@@ -56,3 +58,35 @@ export async function requireStaffRateLimited(): Promise<StaffUser> {
redirectSafe("/admin?error=ratelimit", "/admin");
return staff;
}
/**
* Mod-lite gate: needs any mod.* / moderation ACL, not admin.dashboard.
* Use for `/mod` layout so mid-ranks can access without full housekeeping.
*/
export async function requireMod(): Promise<StaffUser> {
const { session, permissions } = await getAdminContext();
const ok =
canAccess(permissions, PERMS.MOD_DASHBOARD, session.user.rank) ||
canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank) ||
canAccess(permissions, PERMS.MOD_CFH_VIEW, session.user.rank) ||
canAccess(permissions, PERMS.MOD_ACTIONS, session.user.rank) ||
canAccess(permissions, PERMS.MOD_BANS_VIEW, session.user.rank);
if (!ok) redirectSafe("/", "/");
return {
id: session.user.id,
rank: session.user.rank,
username: session.user.username,
};
}
/** Mod-lite page gate: any-of permission list (no admin.dashboard required). */
export async function requireModPermission(
permission: string | readonly string[],
): Promise<StaffUser> {
const staff = await requireMod();
const { permissions } = await getAdminContext();
const needed = Array.isArray(permission) ? permission : [permission];
const ok = needed.some((slug) => canAccess(permissions, slug, staff.rank));
if (!ok) redirectSafe("/mod", "/mod");
return staff;
}
+10 -9
View File
@@ -43,7 +43,8 @@ function fail(
export { fail as actionError, ok as actionOk };
interface AdminOpts<TSchema extends z.ZodType | undefined> {
permission?: string;
/** Single slug, or any-of list (e.g. admin.moderation.edit OR mod.actions). */
permission?: string | readonly string[];
schema?: TSchema;
rateLimitKey?: string;
rateLimitMax?: number;
@@ -75,19 +76,19 @@ export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
setContextUserId(Number(apiCtx.session.user.id) as never);
if (opts.permission) {
if (
!canAccess(
apiCtx.permissions,
opts.permission,
apiCtx.session.user.rank,
)
) {
const needed = Array.isArray(opts.permission)
? opts.permission
: [opts.permission];
const allowed = needed.some((slug) =>
canAccess(apiCtx.permissions, slug, apiCtx.session.user.rank),
);
if (!allowed) {
await logAuthorizationEvent({
kind: "permission.denied",
userId: Number(apiCtx.session.user.id),
username: apiCtx.session.user.name ?? undefined,
rank: apiCtx.session.user.rank,
permission: opts.permission,
permission: needed.join("|"),
source: "adminAction",
reason: "Permission check denied",
});
+34 -8
View File
@@ -23,7 +23,8 @@
"admin": "Admin",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"drawBadge": "Draw badge"
"drawBadge": "Draw badge",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} online",
@@ -1085,7 +1086,7 @@
"backToSite": "Back to site",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"housekeeping": "Housekeeping",
"housekeeping": "Housekeeping (legacy)",
"hotelManagementPanel": "Hotel management panel",
"rankLabel": "Rank {rank}",
"community": "Community",
@@ -1656,7 +1657,7 @@
},
"permissions": {
"title": "Permissions",
"subtitle": "Manage rank-based permissions for the admin panel",
"subtitle": "Manage live ACL ranks and role permissions for admin and mod panels",
"addPermission": "Add permission",
"editPermission": "Edit permission",
"deletePermission": "Delete permission",
@@ -2537,7 +2538,7 @@
"favicon": "Favicon",
"emulator": "Emulator",
"email": "Email",
"housekeeping": "Housekeeping",
"housekeeping": "Housekeeping (legacy)",
"commando": "Commando",
"rooms": "Rooms",
"import": "Import",
@@ -2560,7 +2561,7 @@
"tickets": "Support desk",
"ticketsSubtitle": "CMS tickets, help-center tickets, and reply templates",
"staffAccess": "Ranks & permissions",
"staffAccessSubtitle": "Staff ranks, ACL permissions and housekeeping access",
"staffAccessSubtitle": "Staff ranks and live ACL. Housekeeping is legacy reference only.",
"analytics": "Analytics",
"analyticsSubtitle": "Hotel activity, retention and economy",
"devops": "DevOps",
@@ -2611,8 +2612,8 @@
"confirmDisable": "Disable maintenance mode and restore access for all users?"
},
"housekeeping": {
"title": "Housekeeping Permissions",
"subtitle": "Configure which ranks can access which admin sections",
"title": "Housekeeping (legacy)",
"subtitle": "Legacy min-rank table — not used by live ACL checks. Manage real access under Permissions.",
"addPermission": "Add permission",
"editPermission": "Edit permission",
"deletePermission": "Delete permission",
@@ -2722,7 +2723,10 @@
"auditAction": "Action",
"auditTarget": "Target",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "This table is not live ACL",
"legacyBannerBody": "Runtime access uses the ACL permissions system. Use Housekeeping only as a legacy reference.",
"legacyBannerCta": "Open live Permissions"
},
"rooms": {
"title": "Rooms",
@@ -3011,6 +3015,28 @@
"more": "More"
}
}
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert"
}
}
}
}
+34 -8
View File
@@ -23,7 +23,8 @@
"admin": "Admin",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"drawBadge": "Crea distintivo"
"drawBadge": "Crea distintivo",
"mod": "Mod"
},
"header": {
"online": "{count} online su {hotel}",
@@ -1018,7 +1019,7 @@
"devops": "DevOps",
"wordFilter": "Filtro parole",
"maintenance": "Manutenzione",
"housekeeping": "Housekeeping",
"housekeeping": "Housekeeping (legacy)",
"rooms": "Stanze",
"emailTemplates": "Template email",
"theme": "Tema",
@@ -1612,7 +1613,7 @@
},
"permissions": {
"title": "Permessi",
"subtitle": "Gestisci permessi basati sul grado per il pannello admin",
"subtitle": "Gestisci gradi e permessi ACL live per pannelli admin e mod",
"addPermission": "Aggiungi permesso",
"editPermission": "Modifica permesso",
"deletePermission": "Elimina permesso",
@@ -2458,8 +2459,8 @@
"confirmDisable": "Disattivare la modalità manutenzione e ripristinare l'accesso per tutti gli utenti?"
},
"housekeeping": {
"title": "Permessi Housekeeping",
"subtitle": "Configura quali gradi possono accedere a quali sezioni admin",
"title": "Housekeeping (legacy)",
"subtitle": "Tabella min-rank legacy — non usata dai controlli ACL attivi. Gestisci l'accesso reale in Permessi.",
"addPermission": "Aggiungi permesso",
"editPermission": "Modifica permesso",
"deletePermission": "Elimina permesso",
@@ -2569,7 +2570,10 @@
"auditTarget": "Destinatario",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Questa tabella non e ACL live",
"legacyBannerBody": "L'accesso runtime usa il sistema ACL. Usa Housekeeping solo come riferimento legacy.",
"legacyBannerCta": "Apri Permessi live"
},
"rooms": {
"title": "Stanze",
@@ -2772,7 +2776,7 @@
"favicon": "Favicon",
"emulator": "Emulator",
"email": "Email",
"housekeeping": "Housekeeping",
"housekeeping": "Housekeeping (legacy)",
"commando": "Commando",
"rooms": "Stanze",
"import": "Import",
@@ -2795,7 +2799,7 @@
"tickets": "Assistenza",
"ticketsSubtitle": "Ticket CMS, ticket centro assistenza e template risposte",
"staffAccess": "Gradi e permessi",
"staffAccessSubtitle": "Gradi staff, permessi ACL e accesso housekeeping",
"staffAccessSubtitle": "Gradi staff e ACL live. Housekeeping e solo riferimento legacy.",
"analytics": "Analitiche",
"analyticsSubtitle": "Attività hotel, retention ed economia",
"devops": "DevOps",
@@ -3014,6 +3018,28 @@
"ratelimit": "Stai inviando troppo in fretta. Attendi un momento e riprova.",
"error": "Qualcosa è andato storto. Riprova."
}
},
"mod": {
"title": "Pannello moderazione",
"badge": "Mod",
"overviewTitle": "Panoramica moderatore",
"overviewSubtitle": "CFH, azioni rapide e ban senza accesso admin completo.",
"shortcutsTitle": "Collegamenti",
"nav": {
"overview": "Panoramica",
"cfh": "Call for Help",
"actions": "Azioni rapide",
"bans": "Ban",
"fullAdmin": "Admin completo",
"backToSite": "Torna al sito"
},
"stats": {
"openCfh": "CFH aperti",
"bansToday": "Ban oggi",
"modsOnline": "Mod online",
"quickActions": "Azioni rapide",
"quickActionsHint": "Kick, mute, alert"
}
}
}
}
+34 -8
View File
@@ -23,7 +23,8 @@
"admin": "Admin",
"openMenu": "Menu openen",
"closeMenu": "Menu sluiten",
"drawBadge": "Badge tekenen"
"drawBadge": "Badge tekenen",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} online",
@@ -1076,7 +1077,7 @@
"backToSite": "Terug naar site",
"expandAll": "Alles openen",
"collapseAll": "Alles sluiten",
"housekeeping": "Housekeeping",
"housekeeping": "Housekeeping (legacy)",
"hotelManagementPanel": "Hotelbeheerpaneel",
"rankLabel": "Rang {rank}",
"hubNewsMedia": "News & media",
@@ -1588,7 +1589,7 @@
},
"permissions": {
"title": "Permissies",
"subtitle": "Beheer ranggebaseerde permissies voor het beheerderspaneel",
"subtitle": "Beheer live ACL-rangen en rolrechten voor admin- en mod-panelen",
"addPermission": "Permissie toevoegen",
"editPermission": "Permissie bewerken",
"deletePermission": "Permissie verwijderen",
@@ -2433,8 +2434,8 @@
"confirmDisable": "Onderhoudsmodus uitschakelen en toegang herstellen voor alle gebruikers?"
},
"housekeeping": {
"title": "Housekeeping Permissies",
"subtitle": "Configureer welke rangen toegang hebben tot welke beheerderssecties",
"title": "Housekeeping (legacy)",
"subtitle": "Legacy min-rank tabel — niet gebruikt door live ACL. Beheer echte toegang via Permissions.",
"addPermission": "Permissie toevoegen",
"editPermission": "Permissie bewerken",
"deletePermission": "Permissie verwijderen",
@@ -2544,7 +2545,10 @@
"auditAction": "Actie",
"auditTarget": "Doel",
"save": "Opslaan",
"importTitle": "Permissies importeren"
"importTitle": "Permissies importeren",
"legacyBannerTitle": "Deze tabel is geen live ACL",
"legacyBannerBody": "Runtime-toegang gebruikt het ACL-systeem. Gebruik Housekeeping alleen als legacy-referentie.",
"legacyBannerCta": "Open live Permissions"
},
"rooms": {
"title": "Kamers",
@@ -2838,7 +2842,7 @@
"favicon": "Favicon",
"emulator": "Emulator",
"email": "E-mail",
"housekeeping": "Housekeeping",
"housekeeping": "Housekeeping (legacy)",
"commando": "Commando",
"rooms": "Kamers",
"import": "Import",
@@ -2861,7 +2865,7 @@
"tickets": "Supportbalie",
"ticketsSubtitle": "CMS-tickets, helpcentrum-tickets en antwoordtemplates",
"staffAccess": "Rangen & rechten",
"staffAccessSubtitle": "Staffrangen, ACL-rechten en housekeeping-toegang",
"staffAccessSubtitle": "Staffrangen en live ACL. Housekeeping is alleen legacy-referentie.",
"analytics": "Analytics",
"analyticsSubtitle": "Hotelactiviteit, retentie en economie",
"devops": "DevOps",
@@ -3012,6 +3016,28 @@
"more": "Meer"
}
}
},
"mod": {
"title": "Moderatiepaneel",
"badge": "Mod",
"overviewTitle": "Moderator overzicht",
"overviewSubtitle": "CFH, snelle acties en bans zonder volledige admin-toegang.",
"shortcutsTitle": "Snelkoppelingen",
"nav": {
"overview": "Overzicht",
"cfh": "Call for Help",
"actions": "Snelle acties",
"bans": "Bans",
"fullAdmin": "Volledige admin",
"backToSite": "Terug naar site"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans vandaag",
"modsOnline": "Mods online",
"quickActions": "Snelle acties",
"quickActionsHint": "Kick, mute, alert"
}
}
}
}