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
650 lines
18 KiB
TypeScript
650 lines
18 KiB
TypeScript
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 { 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?: string;
|
|
tab?:
|
|
| "overview"
|
|
| "manage"
|
|
| "import"
|
|
| "ranks"
|
|
| "presets"
|
|
| "test"
|
|
| "audit";
|
|
group?: string;
|
|
}>;
|
|
}) {
|
|
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 tab = sp.tab ?? "overview";
|
|
const groupFilter = (sp.group ?? "").trim();
|
|
|
|
type HKPerm = Awaited<
|
|
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
|
|
>[0];
|
|
const allPermissions: HKPerm[] = await prisma.websiteHousekeepingPermissions
|
|
.findMany({ orderBy: [{ groupName: "asc" }, { permission: "asc" }] })
|
|
.catch(() => []);
|
|
|
|
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 = allPermissions.map((p) => p.minRank);
|
|
const maxRank = ranks.length ? Math.max(...ranks) : 0;
|
|
const totalMin = ranks.reduce((sum, r) => sum + r, 0);
|
|
|
|
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-tabs mb-4">
|
|
<Link
|
|
href="/admin/housekeeping?tab=overview"
|
|
className={"admin-tab " + (tab === "overview" ? "active" : "")}
|
|
>
|
|
{t("tabOverview")}
|
|
</Link>
|
|
<Link
|
|
href="/admin/housekeeping?tab=manage"
|
|
className={"admin-tab " + (tab === "manage" ? "active" : "")}
|
|
>
|
|
{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>
|
|
|
|
{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>
|
|
</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> — {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"
|
|
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("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>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|