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
This commit is contained in:
1 parent
49d204e85c
commit
98d4b715df
7 files changed
+1636
-220
No files matched your search
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<number | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<unknown[]> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
|
||||
const { getAuditLogs } = await import("@/lib/services/audit");
|
||||
const result = await getAuditLogs({ search: "housekeeping", perPage: 50 });
|
||||
return result.rows;
|
||||
}
|
||||
@@ -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
@@ -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> — {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>
|
||||
);
|
||||
}
|
||||
+65
-1
@@ -1879,7 +1879,71 @@
|
||||
"save": "Save permission",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"confirmClearAll": "Delete ALL permissions? This action cannot be undone!",
|
||||
"colGroup": "Group",
|
||||
"colDepends": "Depends on",
|
||||
"colPermission": "Permission",
|
||||
"colDescription": "Description",
|
||||
"colMinRank": "Min. Rank",
|
||||
"permissionKey": "Permission key",
|
||||
"minRank": "Min. rank",
|
||||
"description": "Description",
|
||||
"permissions": "Permissions",
|
||||
"permissionsCount": "{count} permissions",
|
||||
"permission": "permission",
|
||||
"highestMinRank": "Highest min. rank: {rank}",
|
||||
"totalMinRank": "Total min. rank sum: {total}",
|
||||
"groupCount": "{count} groups",
|
||||
"rankCount": "{count} ranks",
|
||||
"missingDeps": "{count} missing dependencies",
|
||||
"dependsOn": "depends on",
|
||||
"notFound": "not found",
|
||||
"missing": "(missing)",
|
||||
"rank": "Rank",
|
||||
"count": "Count",
|
||||
"searchPermission": "Search permissions…",
|
||||
"search": "Search",
|
||||
"manage": "Manage",
|
||||
"importSelection": "Import selection",
|
||||
"noGroup": "No group",
|
||||
"allGroups": "All groups",
|
||||
"perGroup": "Permissions per group",
|
||||
"perRank": "Permissions per rank",
|
||||
"rankOverviewHint": "All permissions grouped by the rank they are assigned to.",
|
||||
"tabOverview": "Overview",
|
||||
"tabManage": "Manage",
|
||||
"tabImport": "Import / Export",
|
||||
"tabRanks": "Rank overview",
|
||||
"tabPresets": "Presets",
|
||||
"tabTest": "Test",
|
||||
"tabAudit": "Audit log",
|
||||
"importPermissions": "Import permissions",
|
||||
"importHint": "Paste a JSON array of permission objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
|
||||
"importPlaceholder": "Paste JSON here…",
|
||||
"import": "Import",
|
||||
"exportTitle": "Export permissions",
|
||||
"exportHint": "Export all permissions as JSON for backup or batch editing.",
|
||||
"export": "Export JSON",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"presets": "Permission presets",
|
||||
"presetsHint": "Apply a predefined set of permissions for common staff roles.",
|
||||
"presetModerator": "Moderator",
|
||||
"presetModeratorDesc": "Basic moderation tools: user lookup, bans, CFH handling, chat logs.",
|
||||
"presetAdmin": "Administrator",
|
||||
"presetAdminDesc": "Full admin access: users, rooms, news, settings, shop, events, radio & more.",
|
||||
"presetSuperAdmin": "Super Admin",
|
||||
"presetSuperAdminDesc": "Complete access to every feature including permission management and asset import.",
|
||||
"applyPreset": "Apply preset",
|
||||
"clearAll": "Clear all permissions",
|
||||
"testTitle": "Test rank access",
|
||||
"testHint": "Enter a rank number to see which permissions would be granted.",
|
||||
"test": "Test",
|
||||
"auditTitle": "Permission audit log",
|
||||
"auditHint": "All changes to housekeeping permissions are logged.",
|
||||
"auditLogsAt": "Audit logs are available on the Audit log page.",
|
||||
"viewAuditLogs": "View audit logs"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Rooms",
|
||||
|
||||
+65
-1
@@ -1879,7 +1879,71 @@
|
||||
"save": "Permissie opslaan",
|
||||
"saving": "Opslaan…",
|
||||
"cancel": "Annuleren"
|
||||
}
|
||||
},
|
||||
"confirmClearAll": "ALLE permissies verwijderen? Dit kan niet ongedaan worden gemaakt!",
|
||||
"colGroup": "Groep",
|
||||
"colDepends": "Afhankelijk van",
|
||||
"colPermission": "Permissie",
|
||||
"colDescription": "Omschrijving",
|
||||
"colMinRank": "Min. rang",
|
||||
"permissionKey": "Permissiesleutel",
|
||||
"minRank": "Min. rang",
|
||||
"description": "Omschrijving",
|
||||
"permissions": "Permissies",
|
||||
"permissionsCount": "{count} permissies",
|
||||
"permission": "permissie",
|
||||
"highestMinRank": "Hoogste min. rang: {rank}",
|
||||
"totalMinRank": "Totale min. rang som: {total}",
|
||||
"groupCount": "{count} groepen",
|
||||
"rankCount": "{count} rangen",
|
||||
"missingDeps": "{count} ontbrekende afhankelijkheden",
|
||||
"dependsOn": "afhankelijk van",
|
||||
"notFound": "niet gevonden",
|
||||
"missing": "(ontbreekt)",
|
||||
"rank": "Rang",
|
||||
"count": "Aantal",
|
||||
"searchPermission": "Zoek permissies…",
|
||||
"search": "Zoeken",
|
||||
"manage": "Beheer",
|
||||
"importSelection": "Importeer selectie",
|
||||
"noGroup": "Geen groep",
|
||||
"allGroups": "Alle groepen",
|
||||
"perGroup": "Permissies per groep",
|
||||
"perRank": "Permissies per rang",
|
||||
"rankOverviewHint": "Alle permissies gegroepeerd per rang.",
|
||||
"tabOverview": "Overzicht",
|
||||
"tabManage": "Beheer",
|
||||
"tabImport": "Importeren / Exporteren",
|
||||
"tabRanks": "Rangenoverzicht",
|
||||
"tabPresets": "Presets",
|
||||
"tabTest": "Test",
|
||||
"tabAudit": "Auditlog",
|
||||
"importPermissions": "Permissies importeren",
|
||||
"importHint": "Plak een JSON-array met permissie-objecten. Formaat: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
|
||||
"importPlaceholder": "Plak JSON hier…",
|
||||
"import": "Importeren",
|
||||
"exportTitle": "Permissies exporteren",
|
||||
"exportHint": "Exporteer alle permissies als JSON voor backup of bulkbewerking.",
|
||||
"export": "Exporteer JSON",
|
||||
"copy": "Kopiëren",
|
||||
"copied": "Gekopieerd!",
|
||||
"presets": "Permissie-presets",
|
||||
"presetsHint": "Pas een vooraf gedefinieerde set permissies toe voor veelvoorkomende staffrollen.",
|
||||
"presetModerator": "Moderator",
|
||||
"presetModeratorDesc": "Basis moderatie tools: gebruikers zoeken, bans, CFH afhandeling, chatlogs.",
|
||||
"presetAdmin": "Administrator",
|
||||
"presetAdminDesc": "Volledige admin-toegang: gebruikers, kamers, nieuws, instellingen, winkel, events, radio & meer.",
|
||||
"presetSuperAdmin": "Super Admin",
|
||||
"presetSuperAdminDesc": "Volledige toegang tot alle functies inclusief permissiebeheer en asset-import.",
|
||||
"applyPreset": "Pas preset toe",
|
||||
"clearAll": "Verwijder alle permissies",
|
||||
"testTitle": "Test rangtoegang",
|
||||
"testHint": "Voer een rangnummer in om te zien welke permissies zouden worden toegekend.",
|
||||
"test": "Test",
|
||||
"auditTitle": "Permissie auditlog",
|
||||
"auditHint": "Alle wijzigingen aan housekeeping permissies worden gelogd.",
|
||||
"auditLogsAt": "Auditlogs zijn beschikbaar op de Auditlog-pagina.",
|
||||
"viewAuditLogs": "Bekijk auditlogs"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Kamers",
|
||||
|
||||
Reference in new issue
Block a user