- 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
322 lines
9.1 KiB
TypeScript
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> — {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">
|
|
→ {p.dependsOn}
|
|
</span>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{tab === "audit" && <AuditSection initialLogs={auditLogs} />}
|
|
</main>
|
|
);
|
|
}
|