From 98d4b715dfbb06a32e5d17d190e4fadadbd60e4d Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 17 Jul 2026 19:02:23 +0200 Subject: [PATCH] feat(admin): extend housekeeping permissions page with groups, presets, audit, test, import/export, rank overview and dependency tracking - 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 --- .../0016_housekeeping_group_field.sql | 6 + prisma/schema.prisma | 3 + src/actions/admin-housekeeping.ts | 884 +++++++++++++++++- src/app/admin/housekeeping/client.tsx | 53 ++ src/app/admin/housekeeping/page.tsx | 778 +++++++++++---- src/messages/en.json | 66 +- src/messages/nl.json | 66 +- 7 files changed, 1636 insertions(+), 220 deletions(-) create mode 100644 prisma/migrations/0016_housekeeping_group_field.sql create mode 100644 src/app/admin/housekeeping/client.tsx diff --git a/prisma/migrations/0016_housekeeping_group_field.sql b/prisma/migrations/0016_housekeeping_group_field.sql new file mode 100644 index 00000000..ac4c810d --- /dev/null +++ b/prisma/migrations/0016_housekeeping_group_field.sql @@ -0,0 +1,6 @@ +ALTER TABLE `website_housekeeping_permissions` + ADD COLUMN `group_name` VARCHAR(100) DEFAULT NULL AFTER `description`, + ADD COLUMN `depends_on` VARCHAR(255) DEFAULT NULL AFTER `group_name`, + ADD COLUMN `updated_by` INT DEFAULT NULL AFTER `updated_at`; + +-- Migration tracking (no model change needed for audit log usage) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1c559aa..3b63ab29 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1623,6 +1623,9 @@ model WebsiteHousekeepingPermissions { permission String @unique @db.VarChar(255) minRank Int @map("min_rank") description String? @db.VarChar(255) + groupName String? @map("group_name") @db.VarChar(100) + dependsOn String? @map("depends_on") @db.VarChar(255) + updatedBy Int? @map("updated_by") createdAt DateTime? @map("created_at") @db.Timestamp(0) updatedAt DateTime? @map("updated_at") @db.Timestamp(0) diff --git a/src/actions/admin-housekeeping.ts b/src/actions/admin-housekeeping.ts index 21c76465..7953a395 100644 --- a/src/actions/admin-housekeeping.ts +++ b/src/actions/admin-housekeeping.ts @@ -2,37 +2,90 @@ import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; +import { auth } from "@/lib/auth"; +import { sessionUserId } from "@/lib/auth/session-user"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; +import { logAudit } from "@/lib/services/audit"; + +function sanitize(str: string, max = 255): string { + return String(str ?? "") + .normalize("NFC") + .trim() + .slice(0, max); +} + +async function currentUserId(): Promise { + const session = await auth(); + return session?.user ? sessionUserId(session.user.id) : null; +} -/** - * Create or update a housekeeping permission (keyed by the unique `permission` - * string). Mirrors AtomCMS' housekeeping permission management. - */ export async function upsertPermission(formData: FormData): Promise { await requirePermission(PERMS.SETTINGS_EDIT); - const permission = String(formData.get("permission") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); + const permission = sanitize(String(formData.get("permission") ?? "")); const minRank = Number(formData.get("minRank")); - const descriptionRaw = String(formData.get("description") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); + const descriptionRaw = sanitize(String(formData.get("description") ?? "")); const description = descriptionRaw.length > 0 ? descriptionRaw : null; + const groupName = + sanitize(String(formData.get("groupName") ?? ""), 100) || null; + const dependsOn = sanitize(String(formData.get("dependsOn") ?? "")) || null; if (!permission || !Number.isFinite(minRank) || minRank < 0) return; + const userId = await currentUserId(); + try { - await prisma.websiteHousekeepingPermissions.upsert({ + const existing = await prisma.websiteHousekeepingPermissions.findUnique({ where: { permission }, - update: { minRank, description }, - create: { permission, minRank, description }, }); + + if (existing) { + const before = { + minRank: existing.minRank, + description: existing.description, + groupName: existing.groupName, + dependsOn: existing.dependsOn, + }; + await prisma.websiteHousekeepingPermissions.update({ + where: { permission }, + data: { + minRank, + description, + groupName, + dependsOn, + updatedBy: userId ?? undefined, + }, + }); + await logAudit({ + userId: userId ?? 0, + action: "housekeeping.permission.update", + target: "WebsiteHousekeepingPermissions", + targetId: Number(existing.id), + before, + after: { minRank, description, groupName, dependsOn }, + }); + } else { + await prisma.websiteHousekeepingPermissions.create({ + data: { + permission, + minRank, + description, + groupName, + dependsOn, + updatedBy: userId ?? undefined, + }, + }); + await logAudit({ + userId: userId ?? 0, + action: "housekeeping.permission.create", + target: "WebsiteHousekeepingPermissions", + targetId: undefined, + after: { permission, minRank, description, groupName, dependsOn }, + }); + } } catch { - // Swallow: duplicate/constraint issues shouldn't crash the action. + // Swallow } revalidatePath("/admin/housekeeping"); @@ -44,10 +97,29 @@ export async function deletePermission(formData: FormData): Promise { const raw = String(formData.get("id") ?? "").normalize("NFC"); if (!raw) return; + const userId = await currentUserId(); + try { - await prisma.websiteHousekeepingPermissions.delete({ + const existing = await prisma.websiteHousekeepingPermissions.findUnique({ where: { id: BigInt(raw) }, }); + if (existing) { + await prisma.websiteHousekeepingPermissions.delete({ + where: { id: BigInt(raw) }, + }); + await logAudit({ + userId: userId ?? 0, + action: "housekeeping.permission.delete", + target: "WebsiteHousekeepingPermissions", + targetId: Number(existing.id), + before: { + permission: existing.permission, + minRank: existing.minRank, + description: existing.description, + groupName: existing.groupName, + }, + }); + } } catch { // Already gone / invalid id. } @@ -55,25 +127,771 @@ export async function deletePermission(formData: FormData): Promise { revalidatePath("/admin/housekeeping"); } -/** - * Export housekeeping permissions as plain JSON-serializable rows - * (used by the admin export UI). - */ -export async function exportPermissions(): Promise< - { permission: string; minRank: number; description: string | null }[] -> { +export async function bulkImportPermissions(formData: FormData): Promise { + await requirePermission(PERMS.SETTINGS_EDIT); + + const raw = sanitize(String(formData.get("json") ?? "")); + const errors: string[] = []; + let count = 0; + + if (!raw) return; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + + if (!Array.isArray(parsed)) { + return; + } + + const userId = await currentUserId(); + + for (const item of parsed) { + const permission = sanitize(String(item.permission ?? "")); + const minRank = Number(item.minRank); + const description = sanitize(String(item.description ?? "")) || null; + const groupName = sanitize(String(item.groupName ?? ""), 100) || null; + const dependsOn = sanitize(String(item.dependsOn ?? "")) || null; + + if (!permission || !Number.isFinite(minRank) || minRank < 0) { + errors.push("Invalid entry: " + JSON.stringify(item)); + continue; + } + + try { + await prisma.websiteHousekeepingPermissions.upsert({ + where: { permission }, + update: { + minRank, + description, + groupName, + dependsOn, + updatedBy: userId ?? undefined, + }, + create: { + permission, + minRank, + description, + groupName, + dependsOn, + updatedBy: userId ?? undefined, + }, + }); + count++; + } catch { + errors.push("Failed to upsert: " + permission); + } + } + + revalidatePath("/admin/housekeeping"); +} + +export async function exportPermissions(): Promise { await requirePermission(PERMS.SETTINGS_VIEW); - try { - return await prisma.websiteHousekeepingPermissions.findMany({ - orderBy: { permission: "asc" }, - select: { - permission: true, - minRank: true, - description: true, + const perms = await prisma.websiteHousekeepingPermissions.findMany({ + orderBy: [{ groupName: "asc" }, { permission: "asc" }], + select: { + permission: true, + minRank: true, + description: true, + groupName: true, + dependsOn: true, + }, + }); + + return JSON.stringify(perms, null, 2); +} + +export async function applyPreset(formData: FormData): Promise { + await requirePermission(PERMS.SETTINGS_EDIT); + + const preset = String(formData.get("preset") ?? "").trim(); + const userId = await currentUserId(); + + const presets: Record< + string, + Array<{ + permission: string; + minRank: number; + description?: string; + groupName?: string; + }> + > = { + moderator: [ + { + permission: "admin.dashboard", + minRank: 5, + description: "Access admin dashboard", + groupName: "Core", + }, + { + permission: "admin.users.view", + minRank: 5, + description: "View user profiles", + groupName: "Users", + }, + { + permission: "admin.users.ban", + minRank: 5, + description: "Ban users", + groupName: "Users", + }, + { + permission: "admin.room.view", + minRank: 5, + description: "View rooms", + groupName: "Rooms", + }, + { + permission: "admin.bans.view", + minRank: 5, + description: "View bans", + groupName: "Bans", + }, + { + permission: "admin.logs.view", + minRank: 5, + description: "View logs", + groupName: "Logs", + }, + { + permission: "admin.wordfilter.view", + minRank: 5, + description: "View wordfilter", + groupName: "Wordfilter", + }, + { + permission: "mod.dashboard", + minRank: 5, + description: "Mod dashboard", + groupName: "Moderation", + }, + { + permission: "mod.cfh.view", + minRank: 5, + description: "View CFH", + groupName: "Moderation", + }, + { + permission: "mod.cfh.edit", + minRank: 5, + description: "Handle CFH", + groupName: "Moderation", + }, + { + permission: "mod.actions", + minRank: 5, + description: "Mod actions", + groupName: "Moderation", + }, + { + permission: "mod.users.view", + minRank: 5, + description: "View users (mod)", + groupName: "Moderation", + }, + { + permission: "mod.bans.view", + minRank: 5, + description: "View bans (mod)", + groupName: "Moderation", + }, + ], + admin: [ + { + permission: "admin.dashboard", + minRank: 7, + description: "Access admin dashboard", + groupName: "Core", + }, + { + permission: "admin.users.view", + minRank: 7, + description: "View user profiles", + groupName: "Users", + }, + { + permission: "admin.users.edit", + minRank: 7, + description: "Edit users", + groupName: "Users", + }, + { + permission: "admin.users.ban", + minRank: 7, + description: "Ban users", + groupName: "Users", + }, + { + permission: "admin.users.reset_password", + minRank: 7, + description: "Reset passwords", + groupName: "Users", + }, + { + permission: "admin.room.view", + minRank: 7, + description: "View rooms", + groupName: "Rooms", + }, + { + permission: "admin.room.edit", + minRank: 7, + description: "Edit rooms", + groupName: "Rooms", + }, + { + permission: "admin.room.delete", + minRank: 7, + description: "Delete rooms", + groupName: "Rooms", + }, + { + permission: "admin.bans.view", + minRank: 7, + description: "View bans", + groupName: "Bans", + }, + { + permission: "admin.news.view", + minRank: 7, + description: "View news", + groupName: "News", + }, + { + permission: "admin.news.edit", + minRank: 7, + description: "Edit news", + groupName: "News", + }, + { + permission: "admin.logs.view", + minRank: 7, + description: "View logs", + groupName: "Logs", + }, + { + permission: "admin.settings.view", + minRank: 7, + description: "View settings", + groupName: "Settings", + }, + { + permission: "admin.wordfilter.view", + minRank: 7, + description: "View wordfilter", + groupName: "Wordfilter", + }, + { + permission: "admin.wordfilter.edit", + minRank: 7, + description: "Edit wordfilter", + groupName: "Wordfilter", + }, + { + permission: "admin.catalog.view", + minRank: 7, + description: "View catalog", + groupName: "Catalog", + }, + { + permission: "admin.shop.view", + minRank: 7, + description: "View shop", + groupName: "Shop", + }, + { + permission: "admin.events.view", + minRank: 7, + description: "View events", + groupName: "Events", + }, + { + permission: "admin.tickets.view", + minRank: 7, + description: "View tickets", + groupName: "Tickets", + }, + { + permission: "admin.moderation.view", + minRank: 7, + description: "View moderation", + groupName: "Moderation", + }, + { + permission: "admin.polls.view", + minRank: 7, + description: "View polls", + groupName: "Polls", + }, + { + permission: "admin.analytics.view", + minRank: 7, + description: "View analytics", + groupName: "Analytics", + }, + { + permission: "admin.prefixes.view", + minRank: 7, + description: "View prefixes", + groupName: "Prefixes", + }, + { + permission: "admin.banners.view", + minRank: 7, + description: "View banners", + groupName: "Banners", + }, + { + permission: "admin.radio.view", + minRank: 7, + description: "View radio", + groupName: "Radio", + }, + { + permission: "admin.rcon.execute", + minRank: 7, + description: "Execute RCON", + groupName: "RCON", + }, + { + permission: "mod.dashboard", + minRank: 7, + description: "Mod dashboard", + groupName: "Moderation", + }, + { + permission: "mod.cfh.view", + minRank: 7, + description: "View CFH", + groupName: "Moderation", + }, + { + permission: "mod.cfh.edit", + minRank: 7, + description: "Handle CFH", + groupName: "Moderation", + }, + { + permission: "mod.actions", + minRank: 7, + description: "Mod actions", + groupName: "Moderation", + }, + ], + superadmin: [ + { + permission: "admin.dashboard", + minRank: 8, + description: "Access admin dashboard", + groupName: "Core", + }, + { + permission: "admin.users.view", + minRank: 8, + description: "View user profiles", + groupName: "Users", + }, + { + permission: "admin.users.edit", + minRank: 8, + description: "Edit users", + groupName: "Users", + }, + { + permission: "admin.users.ban", + minRank: 8, + description: "Ban users", + groupName: "Users", + }, + { + permission: "admin.users.reset_password", + minRank: 8, + description: "Reset passwords", + groupName: "Users", + }, + { + permission: "admin.room.view", + minRank: 8, + description: "View rooms", + groupName: "Rooms", + }, + { + permission: "admin.room.edit", + minRank: 8, + description: "Edit rooms", + groupName: "Rooms", + }, + { + permission: "admin.room.delete", + minRank: 8, + description: "Delete rooms", + groupName: "Rooms", + }, + { + permission: "admin.bans.view", + minRank: 8, + description: "View bans", + groupName: "Bans", + }, + { + permission: "admin.news.view", + minRank: 8, + description: "View news", + groupName: "News", + }, + { + permission: "admin.news.edit", + minRank: 8, + description: "Edit news", + groupName: "News", + }, + { + permission: "admin.logs.view", + minRank: 8, + description: "View logs", + groupName: "Logs", + }, + { + permission: "admin.settings.view", + minRank: 8, + description: "View settings", + groupName: "Settings", + }, + { + permission: "admin.settings.edit", + minRank: 8, + description: "Edit settings", + groupName: "Settings", + }, + { + permission: "admin.permissions.manage", + minRank: 8, + description: "Manage permissions", + groupName: "Settings", + }, + { + permission: "admin.wordfilter.view", + minRank: 8, + description: "View wordfilter", + groupName: "Wordfilter", + }, + { + permission: "admin.wordfilter.edit", + minRank: 8, + description: "Edit wordfilter", + groupName: "Wordfilter", + }, + { + permission: "admin.catalog.view", + minRank: 8, + description: "View catalog", + groupName: "Catalog", + }, + { + permission: "admin.catalog.edit", + minRank: 8, + description: "Edit catalog", + groupName: "Catalog", + }, + { + permission: "admin.shop.view", + minRank: 8, + description: "View shop", + groupName: "Shop", + }, + { + permission: "admin.shop.edit", + minRank: 8, + description: "Edit shop", + groupName: "Shop", + }, + { + permission: "admin.events.view", + minRank: 8, + description: "View events", + groupName: "Events", + }, + { + permission: "admin.events.edit", + minRank: 8, + description: "Edit events", + groupName: "Events", + }, + { + permission: "admin.tickets.view", + minRank: 8, + description: "View tickets", + groupName: "Tickets", + }, + { + permission: "admin.tickets.edit", + minRank: 8, + description: "Edit tickets", + groupName: "Tickets", + }, + { + permission: "admin.moderation.view", + minRank: 8, + description: "View moderation", + groupName: "Moderation", + }, + { + permission: "admin.moderation.edit", + minRank: 8, + description: "Edit moderation", + groupName: "Moderation", + }, + { + permission: "admin.polls.view", + minRank: 8, + description: "View polls", + groupName: "Polls", + }, + { + permission: "admin.polls.edit", + minRank: 8, + description: "Edit polls", + groupName: "Polls", + }, + { + permission: "admin.analytics.view", + minRank: 8, + description: "View analytics", + groupName: "Analytics", + }, + { + permission: "admin.analytics.export", + minRank: 8, + description: "Export analytics", + groupName: "Analytics", + }, + { + permission: "admin.devops.view", + minRank: 8, + description: "View devops", + groupName: "DevOps", + }, + { + permission: "admin.devops.edit", + minRank: 8, + description: "Edit devops", + groupName: "DevOps", + }, + { + permission: "admin.prefixes.view", + minRank: 8, + description: "View prefixes", + groupName: "Prefixes", + }, + { + permission: "admin.prefixes.edit", + minRank: 8, + description: "Edit prefixes", + groupName: "Prefixes", + }, + { + permission: "admin.banners.view", + minRank: 8, + description: "View banners", + groupName: "Banners", + }, + { + permission: "admin.banners.edit", + minRank: 8, + description: "Edit banners", + groupName: "Banners", + }, + { + permission: "admin.radio.view", + minRank: 8, + description: "View radio", + groupName: "Radio", + }, + { + permission: "admin.radio.edit", + minRank: 8, + description: "Edit radio", + groupName: "Radio", + }, + { + permission: "admin.rcon.execute", + minRank: 8, + description: "Execute RCON", + groupName: "RCON", + }, + { + permission: "admin.assets.import", + minRank: 8, + description: "Import assets", + groupName: "Assets", + }, + { + permission: "admin.export", + minRank: 8, + description: "Export data", + groupName: "Export", + }, + { + permission: "admin.notifications.view", + minRank: 8, + description: "View notifications", + groupName: "Notifications", + }, + { + permission: "admin.notifications.edit", + minRank: 8, + description: "Edit notifications", + groupName: "Notifications", + }, + { + permission: "admin.pages.view", + minRank: 8, + description: "View CMS pages", + groupName: "CMS Pages", + }, + { + permission: "admin.pages.edit", + minRank: 8, + description: "Edit CMS pages", + groupName: "CMS Pages", + }, + { + permission: "mod.dashboard", + minRank: 8, + description: "Mod dashboard", + groupName: "Moderation", + }, + { + permission: "mod.cfh.view", + minRank: 8, + description: "View CFH", + groupName: "Moderation", + }, + { + permission: "mod.cfh.edit", + minRank: 8, + description: "Handle CFH", + groupName: "Moderation", + }, + { + permission: "mod.actions", + minRank: 8, + description: "Mod actions", + groupName: "Moderation", + }, + { + permission: "mod.team.view", + minRank: 8, + description: "View mod team", + groupName: "Moderation", + }, + { + permission: "mod.tickets.view", + minRank: 8, + description: "View mod tickets", + groupName: "Moderation", + }, + { + permission: "mod.tickets.edit", + minRank: 8, + description: "Edit mod tickets", + groupName: "Moderation", + }, + { + permission: "mod.users.view", + minRank: 8, + description: "View users (mod)", + groupName: "Moderation", + }, + { + permission: "mod.bans.view", + minRank: 8, + description: "View bans (mod)", + groupName: "Moderation", + }, + ], + }; + + const selected = presets[preset]; + if (!selected) return; + + for (const item of selected) { + const { permission, minRank, description, groupName } = item; + await prisma.websiteHousekeepingPermissions.upsert({ + where: { permission }, + update: { + minRank, + description, + groupName, + updatedBy: userId ?? undefined, + }, + create: { + permission, + minRank, + description, + groupName, + updatedBy: userId ?? undefined, }, }); - } catch { - return []; } + + await logAudit({ + userId: userId ?? 0, + action: "housekeeping.preset.apply", + target: "WebsiteHousekeepingPermissions", + after: { preset, count: selected.length }, + }); + + revalidatePath("/admin/housekeeping"); +} + +export async function clearAllPermissions(): Promise { + await requirePermission(PERMS.SETTINGS_EDIT); + + const userId = await currentUserId(); + const count = await prisma.websiteHousekeepingPermissions.count(); + + await prisma.websiteHousekeepingPermissions.deleteMany(); + + await logAudit({ + userId: userId ?? 0, + action: "housekeeping.permission.clear_all", + target: "WebsiteHousekeepingPermissions", + after: { deletedCount: count }, + }); + + revalidatePath("/admin/housekeeping"); +} + +export async function testRankPermission(formData: FormData): Promise { + await requirePermission(PERMS.SETTINGS_VIEW); + + const rank = Number(formData.get("testRank")); + if (!Number.isFinite(rank) || rank < 0) return; + + const perms = await prisma.websiteHousekeepingPermissions.findMany({ + where: { minRank: { lte: rank } }, + orderBy: [{ groupName: "asc" }, { permission: "asc" }], + select: { permission: true }, + }); + + const granted = perms.map((p) => p.permission); + // Result is displayed via the revalidated page + revalidatePath( + "/admin/housekeeping?tab=test&result=" + + encodeURIComponent(JSON.stringify({ rank, permissions: granted })), + ); +} + +export async function getAuditHistory(): Promise { + await requirePermission(PERMS.SETTINGS_VIEW); + + const { getAuditLogs } = await import("@/lib/services/audit"); + const result = await getAuditLogs({ search: "housekeeping", perPage: 50 }); + return result.rows; } diff --git a/src/app/admin/housekeeping/client.tsx b/src/app/admin/housekeeping/client.tsx new file mode 100644 index 00000000..efd0fb1f --- /dev/null +++ b/src/app/admin/housekeeping/client.tsx @@ -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(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 ( +
+ + {exportData && ( +
+
+						{exportData}
+					
+ +
+ )} +
+ ); +} diff --git a/src/app/admin/housekeeping/page.tsx b/src/app/admin/housekeeping/page.tsx index 2df89a9a..3d598132 100644 --- a/src/app/admin/housekeeping/page.tsx +++ b/src/app/admin/housekeeping/page.tsx @@ -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 - > = []; - 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(); - 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 = {}; + for (const p of allPermissions) { + const g: string = p.groupName ?? "Uncategorized"; + groupCounts[g] = (groupCounts[g] || 0) + 1; + } + + const rankGroups: Record = {}; + 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 (
-
- - {t("permissionsCount", { count: permissions.length })} - - - {t("highestMinRank", { rank: maxRank })} - - - {t("totalMinRank", { total: totalMin })} - -
- -
-
- - -
+
- {t("manage")} + {t("tabOverview")} - {t("importSelection")} + {t("tabManage")} + + + {t("tabImport")} + + + {t("tabRanks")} + + + {t("tabPresets")} + + + {t("tabTest")} + + + {t("tabAudit")}
- {mode === "manage" ? ( -
-

{t("addPermission")}

-
-
- - - - + {tab === "overview" && ( + <> +
+ + {t("permissionsCount", { count: allPermissions.length })} + + + {t("highestMinRank", { rank: maxRank })} + + + {t("totalMinRank", { total: totalMin })} + + + {t("groupCount", { count: Object.keys(groupCounts).length })} + + + {t("rankCount", { count: Object.keys(rankGroups).length })} + + {missingDeps.length > 0 && ( + + {t("missingDeps", { count: missingDeps.length })} + + )} +
+ +
+

{t("perGroup")}

+
+ {Object.entries(groupCounts) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([group, count]) => ( + +
{group}
+
+ {count} {count === 1 ? t("permission") : t("permissions")} +
+ + ))}
- -
- ) : ( -
-

{t("importPermissions")}

-

- {t("importHint")} -

-
-
+
+ +
+

{t("perRank")}

+
+ + + + + + + + + + {Object.entries(rankGroups) + .sort(([a], [b]) => Number(b) - Number(a)) + .map(([rank, perms]) => ( + + + + + + ))} + +
{t("rank")}{t("count")}{t("permissions")}
Rank {rank}{perms.length} + {perms.map((p) => p.permission).join(", ")} +
+
+
+ + {missingDeps.length > 0 && ( +
+

+ {t("missingDeps")} +

+
+
    + {missingDeps.map((d) => ( +
  • + {d.permission} {t("dependsOn")}{" "} + {d.dependsOn} — {t("notFound")} +
  • + ))} +
+
+
+ )} + + )} + + {tab === "manage" && ( + <> +
+ + + + +
+ +
+

{t("addPermission")}

+
+
+ + + + + + +
+
+
+ +
+

+ {t("permissions")}{" "} + + {filtered.length} + +

+ {filtered.length === 0 ? ( +
{t("noPermissions")}
+ ) : ( +
+ + + + + + + + + + + + + {filtered.map((p, i) => ( + + + + + + + + + + ))} + +
#{t("colPermission")}{t("colDescription")}{t("colGroup")}{t("colMinRank")}{t("colDepends")} +
{i + 1} + {p.permission} + {p.description ?? "—"} + {p.groupName ? ( + + {p.groupName} + + ) : ( + — + )} + +
+ + + + + + +
+
+ {p.dependsOn ? ( + + {p.dependsOn} + {!permSet.has(p.dependsOn) + ? " " + t("missing") + : ""} + + ) : ( + — + )} + +
+ + +
+
+
+ )} +
+ + )} + + {tab === "import" && ( + <> +
+

{t("exportTitle")}

+

+ {t("exportHint")} +

+ +
+
+

{t("importTitle")}

+

+ {t("importHint")} +

+
+
+