feat(admin): extend housekeeping permissions page with groups, presets, audit, test, import/export, rank overview and dependency tracking
Local Build and Deploy / deploy (push) Successful in 1m14s

- Add group_name, depends_on and updated_by columns to website_housekeeping_permissions
- Add migration 0016 for new columns
- Rewrite housekeeping page with 7 tabs: Overview, Manage, Import/Export, Rank overview, Presets, Test, Audit log
- Add permission groups/categories with filtering
- Add bulk JSON import/export
- Add rank overview grouped by permission category
- Add permission presets for Moderator (rank 5), Admin (rank 7), Super Admin (rank 8)
- Add audit logging for all permission changes via AdminAuditLog
- Add test mode to check permissions by rank
- Add dependency tracking with warnings for missing dependencies
- Add overview dashboard with statistics
- Add Dutch and English translations for all new features
This commit is contained in:
openhands committed 2026-07-17 19:02:23 +02:00
1 parent 49d204e85c
commit 98d4b715df
7 files changed
+1636 -220

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
export function HousekeepingClient() {
const t = useTranslations("pages.admin.housekeeping");
const [exportData, setExportData] = useState<string | null>(null);
const [copying, setCopying] = useState(false);
const handleExport = useCallback(async () => {
const { exportPermissions } = await import("@/actions/admin-housekeeping");
const data = await exportPermissions();
setExportData(data);
}, []);
const handleCopy = useCallback(async () => {
if (!exportData) return;
setCopying(true);
try {
await navigator.clipboard.writeText(exportData);
} catch {
// fallback
}
setTimeout(() => setCopying(false), 1500);
}, [exportData]);
return (
<div>
<button
type="button"
onClick={handleExport}
className="btn btn-outline mb-3"
>
{t("export")}
</button>
{exportData && (
<div className="relative">
<pre className="admin-card font-mono text-xs overflow-auto max-h-[300px] p-3">
{exportData}
</pre>
<button
type="button"
onClick={handleCopy}
className="btn btn-outline btn-sm absolute top-2 right-2"
>
{copying ? t("copied") : t("copy")}
</button>
</div>
)}
</div>
);
}
+593 -185
View File
@@ -1,241 +1,649 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
applyPreset,
bulkImportPermissions,
clearAllPermissions,
deletePermission,
testRankPermission,
upsertPermission,
} from "@/actions/admin-housekeeping";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { HousekeepingClient } from "./client";
export const dynamic = "force-dynamic";
const GROUPS = [
"Core",
"Users",
"Rooms",
"Bans",
"News",
"Logs",
"Settings",
"Wordfilter",
"Catalog",
"Shop",
"Events",
"Tickets",
"Moderation",
"Polls",
"Analytics",
"DevOps",
"Prefixes",
"Banners",
"Radio",
"RCON",
"Assets",
"Export",
"Notifications",
"CMS Pages",
];
export default async function AdminHousekeeping({
searchParams,
}: {
searchParams: Promise<{ q?: string; mode?: "manage" | "import" }>;
searchParams: Promise<{
q?: string;
mode?: string;
tab?:
| "overview"
| "manage"
| "import"
| "ranks"
| "presets"
| "test"
| "audit";
group?: string;
}>;
}) {
const { session, permissions: acl } = await getAdminContext();
if (!canAccess(acl, PERMS.SETTINGS_VIEW, session.user.rank)) {
const ctx = await getAdminContext();
if (!canAccess(ctx.permissions, PERMS.SETTINGS_VIEW, ctx.session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.housekeeping");
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const mode = sp.mode === "import" ? "import" : "manage";
const tab = sp.tab ?? "overview";
const groupFilter = (sp.group ?? "").trim();
let permissions: Awaited<
type HKPerm = Awaited<
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
> = [];
try {
permissions = await prisma.websiteHousekeepingPermissions.findMany({
orderBy: [{ permission: "asc" }],
});
} catch {
permissions = [];
}
>[0];
const allPermissions: HKPerm[] = await prisma.websiteHousekeepingPermissions
.findMany({ orderBy: [{ groupName: "asc" }, { permission: "asc" }] })
.catch(() => []);
const filtered = q
? permissions.filter((p) =>
p.permission.toLowerCase().includes(q.toLowerCase()),
)
: permissions;
const filtered = allPermissions.filter((p) => {
if (q && !p.permission.toLowerCase().includes(q.toLowerCase()))
return false;
if (groupFilter && (p.groupName ?? "") !== groupFilter) return false;
return true;
});
const ranks = permissions.map((p) => p.minRank);
const ranks = allPermissions.map((p) => p.minRank);
const maxRank = ranks.length ? Math.max(...ranks) : 0;
const totalMin = ranks.reduce((sum, r) => sum + r, 0);
const selectedIds = new Set<number>();
if (mode === "import") {
const importRaw = String(sp.q ?? "");
if (importRaw) {
const importIds = importRaw
.split(",")
.map((i) => Number(i.trim()))
.filter((i) => !Number.isNaN(i));
importIds.forEach((id) => {
selectedIds.add(id);
});
const groupCounts: Record<string, number> = {};
for (const p of allPermissions) {
const g: string = p.groupName ?? "Uncategorized";
groupCounts[g] = (groupCounts[g] || 0) + 1;
}
const rankGroups: Record<string, typeof allPermissions> = {};
for (const p of allPermissions) {
if (!rankGroups[p.minRank]) rankGroups[p.minRank] = [];
rankGroups[p.minRank].push(p);
}
const missingDeps: Array<{ permission: string; dependsOn: string }> = [];
const permSet = new Set(allPermissions.map((p) => p.permission));
for (const p of allPermissions) {
if (p.dependsOn && !permSet.has(p.dependsOn)) {
missingDeps.push({ permission: p.permission, dependsOn: p.dependsOn });
}
}
return (
<main>
<div className="admin-stat-row">
<span className="admin-stat-chip">
{t("permissionsCount", { count: permissions.length })}
</span>
<span className="admin-stat-chip">
{t("highestMinRank", { rank: maxRank })}
</span>
<span className="admin-stat-chip">
{t("totalMinRank", { total: totalMin })}
</span>
</div>
<div className="admin-filter-bar">
<form className="flex gap-2 flex-1">
<input
name="q"
defaultValue={q}
placeholder={t("searchPermission")}
className="flex-1 min-w-[200px]"
/>
<button type="submit" className="btn btn-primary">
{t("search")}
</button>
</form>
<div className="admin-tabs mb-4">
<Link
href="/admin/housekeeping?mode=manage"
className="btn btn-outline"
href="/admin/housekeeping?tab=overview"
className={"admin-tab " + (tab === "overview" ? "active" : "")}
>
{t("manage")}
{t("tabOverview")}
</Link>
<Link
href="/admin/housekeeping?mode=import&form=selected_ids"
className="btn btn-outline"
href="/admin/housekeeping?tab=manage"
className={"admin-tab " + (tab === "manage" ? "active" : "")}
>
{t("importSelection")}
{t("tabManage")}
</Link>
<Link
href="/admin/housekeeping?tab=import"
className={"admin-tab " + (tab === "import" ? "active" : "")}
>
{t("tabImport")}
</Link>
<Link
href="/admin/housekeeping?tab=ranks"
className={"admin-tab " + (tab === "ranks" ? "active" : "")}
>
{t("tabRanks")}
</Link>
<Link
href="/admin/housekeeping?tab=presets"
className={"admin-tab " + (tab === "presets" ? "active" : "")}
>
{t("tabPresets")}
</Link>
<Link
href="/admin/housekeeping?tab=test"
className={"admin-tab " + (tab === "test" ? "active" : "")}
>
{t("tabTest")}
</Link>
<Link
href="/admin/housekeeping?tab=audit"
className={"admin-tab " + (tab === "audit" ? "active" : "")}
>
{t("tabAudit")}
</Link>
</div>
{mode === "manage" ? (
<section>
<h2 className="admin-section-title">{t("addPermission")}</h2>
<form action={upsertPermission} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input
name="permission"
placeholder={t("permissionKey")}
required
className="min-w-[200px]"
/>
<input
name="minRank"
type="number"
min={0}
placeholder={t("minRank")}
required
className="w-[110px]"
/>
<input
name="description"
placeholder={t("description")}
className="flex-1 min-w-[160px]"
/>
<button type="submit" className="btn btn-primary">
{t("save")}
</button>
{tab === "overview" && (
<>
<div className="admin-stat-row">
<span className="admin-stat-chip">
{t("permissionsCount", { count: allPermissions.length })}
</span>
<span className="admin-stat-chip">
{t("highestMinRank", { rank: maxRank })}
</span>
<span className="admin-stat-chip">
{t("totalMinRank", { total: totalMin })}
</span>
<span className="admin-stat-chip">
{t("groupCount", { count: Object.keys(groupCounts).length })}
</span>
<span className="admin-stat-chip">
{t("rankCount", { count: Object.keys(rankGroups).length })}
</span>
{missingDeps.length > 0 && (
<span
className="admin-stat-chip"
style={{ color: "var(--color-danger)" }}
>
{t("missingDeps", { count: missingDeps.length })}
</span>
)}
</div>
<section className="mb-6">
<h2 className="admin-section-title">{t("perGroup")}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(groupCounts)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, count]) => (
<Link
key={group}
href={
"/admin/housekeeping?tab=manage&group=" +
encodeURIComponent(group)
}
className="admin-card block p-3 hover:opacity-80 transition-opacity"
>
<div className="font-semibold text-sm">{group}</div>
<div className="text-xs opacity-60">
{count} {count === 1 ? t("permission") : t("permissions")}
</div>
</Link>
))}
</div>
</form>
</section>
) : (
<section>
<h2 className="admin-section-title">{t("importPermissions")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("importHint")}
</p>
<form action={upsertPermission} className="admin-card">
<div className="flex gap-2 flex-wrap">
</section>
<section>
<h2 className="admin-section-title">{t("perRank")}</h2>
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("rank")}</th>
<th>{t("count")}</th>
<th>{t("permissions")}</th>
</tr>
</thead>
<tbody>
{Object.entries(rankGroups)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([rank, perms]) => (
<tr key={rank}>
<td className="font-semibold">Rank {rank}</td>
<td>{perms.length}</td>
<td className="text-xs opacity-70">
{perms.map((p) => p.permission).join(", ")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{missingDeps.length > 0 && (
<section className="mt-6">
<h2
className="admin-section-title"
style={{ color: "var(--color-danger)" }}
>
{t("missingDeps")}
</h2>
<div className="admin-card">
<ul className="list-disc pl-5 text-sm">
{missingDeps.map((d) => (
<li key={d.permission}>
<strong>{d.permission}</strong> {t("dependsOn")}{" "}
<code>{d.dependsOn}</code> &mdash; {t("notFound")}
</li>
))}
</ul>
</div>
</section>
)}
</>
)}
{tab === "manage" && (
<>
<div className="admin-filter-bar">
<form className="flex gap-2 flex-1 flex-wrap">
<input
name="q"
placeholder={t("importPlaceholder")}
required
defaultValue={q}
placeholder={t("searchPermission")}
className="flex-1 min-w-[200px]"
/>
<select
name="group"
defaultValue={groupFilter}
className="w-[180px]"
>
<option value="">{t("allGroups")}</option>
{GROUPS.map((g) => (
<option key={g} value={g}>
{g} ({groupCounts[g] ?? 0})
</option>
))}
{Object.keys(groupCounts)
.filter((g) => !GROUPS.includes(g))
.map((g) => (
<option key={g} value={g}>
{g} ({groupCounts[g]})
</option>
))}
</select>
<input type="hidden" name="tab" value="manage" />
<button type="submit" className="btn btn-primary">
{t("import")}
{t("search")}
</button>
</form>
</div>
<section>
<h2 className="admin-section-title">{t("addPermission")}</h2>
<form action={upsertPermission} className="admin-card">
<div className="flex gap-2 flex-wrap">
<input
name="permission"
placeholder={t("permissionKey")}
required
className="min-w-[200px] flex-1"
/>
<input
name="minRank"
type="number"
min={0}
placeholder={t("minRank")}
required
className="w-[110px]"
/>
<input
name="description"
placeholder={t("description")}
className="flex-1 min-w-[160px]"
/>
<select name="groupName" className="w-[150px]">
<option value="">{t("noGroup")}</option>
{GROUPS.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
<input
name="dependsOn"
placeholder={t("dependsOn")}
className="min-w-[160px] flex-1"
/>
<button type="submit" className="btn btn-primary">
{t("save")}
</button>
</div>
</form>
</section>
<section>
<h2 className="admin-section-title">
{t("permissions")}{" "}
<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)]">
{filtered.length}
</span>
</h2>
{filtered.length === 0 ? (
<div className="admin-empty">{t("noPermissions")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th className="w-[40px]">#</th>
<th>{t("colPermission")}</th>
<th>{t("colDescription")}</th>
<th className="w-[120px]">{t("colGroup")}</th>
<th className="w-[100px]">{t("colMinRank")}</th>
<th className="w-[180px]">{t("colDepends")}</th>
<th className="w-[80px]" />
</tr>
</thead>
<tbody>
{filtered.map((p, i) => (
<tr key={String(p.id)}>
<td className="muted w-[40px]">{i + 1}</td>
<td>
<strong>{p.permission}</strong>
</td>
<td className="muted">{p.description ?? "—"}</td>
<td>
{p.groupName ? (
<span className="inline-block px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/12 text-xs font-medium">
{p.groupName}
</span>
) : (
<span className="text-xs opacity-50">—</span>
)}
</td>
<td>
<form
action={upsertPermission}
style={{ display: "flex", gap: "0.5rem" }}
>
<input
type="hidden"
name="permission"
value={p.permission}
/>
<input
type="hidden"
name="description"
value={p.description ?? ""}
/>
<input
type="hidden"
name="groupName"
value={p.groupName ?? ""}
/>
<input
type="hidden"
name="dependsOn"
value={p.dependsOn ?? ""}
/>
<input
name="minRank"
type="number"
min={0}
defaultValue={p.minRank}
style={{ width: 80 }}
/>
<button
type="submit"
className="btn btn-outline btn-sm"
>
{t("save")}
</button>
</form>
</td>
<td className="text-xs">
{p.dependsOn ? (
<span
className={
permSet.has(p.dependsOn)
? "opacity-70"
: "opacity-100 font-semibold"
}
style={
!permSet.has(p.dependsOn)
? { color: "var(--color-danger)" }
: {}
}
>
{p.dependsOn}
{!permSet.has(p.dependsOn)
? " " + t("missing")
: ""}
</span>
) : (
<span className="opacity-50">—</span>
)}
</td>
<td>
<form action={deletePermission}>
<input
type="hidden"
name="id"
value={String(p.id)}
/>
<button
type="submit"
className="btn btn-danger btn-sm"
>
✕
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</>
)}
{tab === "import" && (
<>
<section>
<h2 className="admin-section-title">{t("exportTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("exportHint")}
</p>
<HousekeepingClient />
</section>
<section className="mt-8">
<h2 className="admin-section-title">{t("importTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("importHint")}
</p>
<form action={bulkImportPermissions} className="admin-card">
<div className="flex flex-col gap-2">
<textarea
name="json"
placeholder={t("importPlaceholder")}
required
rows={8}
className="w-full font-mono text-sm"
/>
<button type="submit" className="btn btn-primary self-start">
{t("import")}
</button>
</div>
</form>
</section>
</>
)}
{tab === "ranks" && (
<section>
<h2 className="admin-section-title">{t("perRank")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("rankOverviewHint")}
</p>
{Object.keys(rankGroups).length === 0 ? (
<div className="admin-empty">{t("noPermissions")}</div>
) : (
<div className="space-y-4">
{Object.entries(rankGroups)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([rank, perms]) => {
const groupsInRank: Record<string, typeof perms> = {};
for (const p of perms) {
const g = p.groupName ?? "Uncategorized";
if (!groupsInRank[g]) groupsInRank[g] = [];
groupsInRank[g].push(p);
}
return (
<div key={rank} className="admin-card">
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-bold">
{t("rank")} {rank}
</h3>
<span className="text-sm opacity-60">
{perms.length} {t("permissions")}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{Object.entries(groupsInRank)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, groupPerms]) => (
<div key={group}>
<div className="text-xs font-semibold uppercase tracking-wider opacity-60 mb-1">
{group}
</div>
<ul className="list-disc pl-4 text-xs space-y-0.5">
{groupPerms.map((p) => (
<li key={p.permission}>
{p.permission}
{p.dependsOn ? (
<span className="opacity-50 ml-1">
→ {p.dependsOn}
</span>
) : null}
</li>
))}
</ul>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</section>
)}
{tab === "presets" && (
<section>
<h2 className="admin-section-title">{t("presets")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("presetsHint")}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{(["moderator", "admin", "superadmin"] as const).map((preset) => {
const labels: Record<string, string> = {
moderator: t("presetModerator"),
admin: t("presetAdmin"),
superadmin: t("presetSuperAdmin"),
};
const descs: Record<string, string> = {
moderator: t("presetModeratorDesc"),
admin: t("presetAdminDesc"),
superadmin: t("presetSuperAdminDesc"),
};
return (
<div key={preset} className="admin-card">
<h3 className="font-bold text-lg mb-1">{labels[preset]}</h3>
<p className="text-xs opacity-60 mb-3">{descs[preset]}</p>
<form action={applyPreset}>
<input type="hidden" name="preset" value={preset} />
<button type="submit" className="btn btn-primary w-full">
{t("applyPreset")}
</button>
</form>
</div>
);
})}
</div>
<div className="mt-6">
<form
action={clearAllPermissions}
onSubmit={async (e) => {
e.preventDefault();
if (confirm(t("confirmClearAll")))
(e.target as HTMLFormElement).requestSubmit();
}}
>
<button type="submit" className="btn btn-danger">
{t("clearAll")}
</button>
</form>
</div>
</section>
)}
{tab === "test" && (
<section>
<h2 className="admin-section-title">{t("testTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("testHint")}
</p>
<form
action={testRankPermission}
className="admin-card flex gap-2 flex-wrap"
>
<input
name="testRank"
type="number"
min={0}
placeholder={t("rank")}
required
className="w-[150px]"
/>
<button type="submit" className="btn btn-primary">
{t("test")}
</button>
</form>
</section>
)}
<section>
<h2 className="admin-section-title">
{t("permissions")}{" "}
<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)]">
{filtered.length}
</span>
</h2>
{filtered.length === 0 ? (
<div className="admin-empty">{t("noPermissions")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th className="w-[40px]">#</th>
<th>{t("colPermission")}</th>
<th>{t("colDescription")}</th>
<th className="w-[200px]">{t("colMinRank")}</th>
<th className="w-[80px]" />
</tr>
</thead>
<tbody>
{filtered.map((p, i) => (
<tr
key={String(p.id)}
style={
mode === "import" && selectedIds.has(i)
? {
backgroundColor:
"color-mix(in srgb, var(--admin-accent) 5%, transparent)",
}
: {}
}
>
<td className="muted" style={{ width: "40px" }}>
{i + 1}
</td>
<td>
<strong>{p.permission}</strong>
</td>
<td className="muted">{p.description ?? "—"}</td>
<td>
<form
action={upsertPermission}
style={{ display: "flex", gap: "0.5rem" }}
>
<input
type="hidden"
name="permission"
value={p.permission}
/>
<input
type="hidden"
name="description"
value={p.description ?? ""}
/>
<input
name="minRank"
type="number"
min={0}
defaultValue={p.minRank}
style={{ width: 90 }}
/>
<button type="submit" className="btn btn-outline">
{t("save")}
</button>
</form>
</td>
<td>
<form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">
✕
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{tab === "audit" && (
<section>
<h2 className="admin-section-title">{t("auditTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("auditHint")}
</p>
<div className="admin-card">
<p className="text-sm opacity-70">{t("auditLogsAt")}</p>
<Link href="/admin/logs/audit" className="btn btn-outline mt-2">
{t("viewAuditLogs")}
</Link>
</div>
)}
</section>
</section>
)}
</main>
);
}