Files
Epicnabbo-Catalogus-Updated…/src/app/admin/housekeeping/page.tsx
T
openhands f1e4e32a1e refactor(housekeeping): modal confirm, bulk delete, search debounce, audit inline, form reset
- Replace browser confirm() with custom ConfirmModal component
- Add search debounce (300ms) before syncing URL params
- Reset add-permission form after successful submit
- Add bulk select/delete with checkbox column and bulkDeletePermissions action
- AuditSection fetches logs server-side via getAuditLogs and shows inline table
- Add 10 new i18n keys (cancel, confirm, confirmDeletePermission,
  confirmBulkDelete, deleteSelected, confirmClearAllDesc,
  auditTime, auditUser, auditAction, auditTarget) synced to all 22 locales
2026-07-17 19:53:53 +02:00

322 lines
9.1 KiB
TypeScript

import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { getAuditLogs } from "@/lib/services/audit";
import {
AuditSection,
ExportSection,
ImportSection,
ManageSection,
PresetsSection,
TestSection,
} 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<{
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 tab = sp.tab ?? "overview";
type HKPerm = Awaited<
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
>[0];
const allPermissions: HKPerm[] = await prisma.websiteHousekeepingPermissions
.findMany({ orderBy: [{ groupName: "asc" }, { permission: "asc" }] })
.catch(() => []);
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 });
}
}
const allGroups = [...new Set([...GROUPS, ...Object.keys(groupCounts)])];
const auditLogs = await getAuditLogs({
search: "housekeeping",
perPage: 25,
}).catch(() => null);
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> &mdash; {t("notFound")}
</li>
))}
</ul>
</div>
</section>
)}
</>
)}
{tab === "manage" && (
<ManageSection
permissions={allPermissions}
groups={allGroups}
groupCounts={groupCounts}
permSet={permSet}
/>
)}
{tab === "import" && (
<>
<ImportSection />
<div className="mt-8">
<ExportSection />
</div>
</>
)}
{tab === "presets" && <PresetsSection />}
{tab === "test" && <TestSection permissions={allPermissions} />}
{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">
&rarr; {p.dependsOn}
</span>
) : null}
</li>
))}
</ul>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</section>
)}
{tab === "audit" && <AuditSection initialLogs={auditLogs} />}
</main>
);
}