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

+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>
);
}