diff --git a/src/actions/admin-housekeeping.ts b/src/actions/admin-housekeeping.ts
index 5b841d99bd..097881864e 100644
--- a/src/actions/admin-housekeeping.ts
+++ b/src/actions/admin-housekeeping.ts
@@ -1,194 +1,31 @@
"use server";
-import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
-import { auth } from "@/lib/auth";
-import { sessionUserId } from "@/lib/auth/session-user";
+import { redirectSafe } from "@/lib/foundation/security";
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;
-}
-
-export async function upsertPermission(formData: FormData): Promise {
+/**
+ * Housekeeping table writes are retired. Runtime access uses ACL only.
+ * Redirect editors to live Permissions.
+ */
+async function rejectLegacyHousekeepingWrite(): Promise {
await requirePermission(PERMS.SETTINGS_EDIT);
-
- const permission = sanitize(String(formData.get("permission") ?? ""));
- const minRank = Number(formData.get("minRank"));
- 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 {
- const existing = await prisma.websiteHousekeepingPermissions.findUnique({
- where: { permission },
- });
-
- 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
- }
-
- revalidatePath("/admin/housekeeping");
+ redirectSafe("/admin/permissions?from=housekeeping", "/admin/permissions");
}
-export async function deletePermission(formData: FormData): Promise {
- await requirePermission(PERMS.SETTINGS_EDIT);
+export async function upsertPermission(_formData: FormData): Promise {
+ await rejectLegacyHousekeepingWrite();
+}
- const raw = String(formData.get("id") ?? "").normalize("NFC");
- if (!raw) return;
-
- const userId = await currentUserId();
-
- try {
- 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.
- }
-
- revalidatePath("/admin/housekeeping");
+export async function deletePermission(_formData: FormData): Promise {
+ await rejectLegacyHousekeepingWrite();
}
export async function bulkImportPermissions(
- formData: FormData,
+ _formData: FormData,
): Promise<{ count: number; errors: string[] }> {
- await requirePermission(PERMS.SETTINGS_EDIT);
-
- const raw = sanitize(String(formData.get("json") ?? ""));
- if (!raw) return { count: 0, errors: ["Empty input"] };
-
- let parsed: unknown;
- try {
- parsed = JSON.parse(raw);
- } catch {
- return { count: 0, errors: ["Invalid JSON"] };
- }
-
- if (!Array.isArray(parsed)) {
- return { count: 0, errors: ["Expected an array"] };
- }
-
- const userId = await currentUserId();
- const errors: string[] = [];
- let count = 0;
-
- 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");
- return { count, errors };
+ await rejectLegacyHousekeepingWrite();
}
export async function exportPermissions(): Promise {
@@ -208,688 +45,16 @@ export async function exportPermissions(): Promise {
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,
- },
- });
- }
-
- await logAudit({
- userId: userId ?? 0,
- action: "housekeeping.preset.apply",
- target: "WebsiteHousekeepingPermissions",
- after: { preset, count: selected.length },
- });
-
- revalidatePath("/admin/housekeeping");
+export async function applyPreset(_formData: FormData): Promise {
+ await rejectLegacyHousekeepingWrite();
}
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");
+ await rejectLegacyHousekeepingWrite();
}
export async function bulkDeletePermissions(
- ids: bigint[],
+ _ids: bigint[],
): Promise<{ count: number }> {
- await requirePermission(PERMS.SETTINGS_EDIT);
-
- if (!ids.length) return { count: 0 };
-
- const userId = await currentUserId();
-
- await prisma.websiteHousekeepingPermissions.deleteMany({
- where: { id: { in: ids } },
- });
-
- await logAudit({
- userId: userId ?? 0,
- action: "housekeeping.permission.bulk_delete",
- target: "WebsiteHousekeepingPermissions",
- after: { deletedCount: ids.length },
- });
-
- revalidatePath("/admin/housekeeping");
- return { count: ids.length };
+ await rejectLegacyHousekeepingWrite();
}
diff --git a/src/actions/tickets.ts b/src/actions/tickets.ts
index 4d67e013c5..81fa9abf7c 100644
--- a/src/actions/tickets.ts
+++ b/src/actions/tickets.ts
@@ -121,7 +121,10 @@ export const closeTicketByUser = authAction(
// ── Admin actions ────────────────────────────────────────────────────
export const adminReplyTicket = adminAction(
- { permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema },
+ {
+ permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
+ schema: replyTicketSchema,
+ },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
@@ -161,7 +164,10 @@ export const adminReplyTicket = adminAction(
);
export const updateTicketStatus = adminAction(
- { permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
+ {
+ permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
+ schema: updateTicketStatusSchema,
+ },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
@@ -196,7 +202,10 @@ export const updateTicketStatus = adminAction(
);
export const assignTicket = adminAction(
- { permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
+ {
+ permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
+ schema: assignTicketSchema,
+ },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
@@ -226,7 +235,10 @@ export const assignTicket = adminAction(
);
export const updateTicketPriority = adminAction(
- { permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
+ {
+ permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
+ schema: updateTicketPrioritySchema,
+ },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
diff --git a/src/app/admin/housekeeping/page.tsx b/src/app/admin/housekeeping/page.tsx
index 6646bfb6fe..def8e48ce4 100644
--- a/src/app/admin/housekeeping/page.tsx
+++ b/src/app/admin/housekeeping/page.tsx
@@ -4,44 +4,10 @@ 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";
+import { AuditSection, ExportSection } 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,
}: {
@@ -53,7 +19,8 @@ export default async function AdminHousekeeping({
| "ranks"
| "presets"
| "test"
- | "audit";
+ | "audit"
+ | "compare";
group?: string;
}>;
}) {
@@ -64,7 +31,10 @@ export default async function AdminHousekeeping({
const t = await getTranslations("pages.admin.housekeeping");
const sp = await searchParams;
- const tab = sp.tab ?? "overview";
+ let tab = sp.tab ?? "overview";
+ if (tab === "manage" || tab === "presets" || tab === "test") {
+ tab = "compare";
+ }
type HKPerm = Awaited<
ReturnType
@@ -89,15 +59,48 @@ export default async function AdminHousekeeping({
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 aclSlugs = await prisma.aclPermission
+ .findMany({ select: { slug: true } })
+ .catch(() => [] as { slug: string }[]);
+ const aclSlugSet = new Set(aclSlugs.map((p) => p.slug));
+
+ const aclRolePerms = await prisma.aclModelPermission
+ .findMany({
+ where: { modelType: "Role" },
+ include: {
+ permission: { select: { slug: true } },
+ role: { select: { slug: true } },
+ },
+ })
+ .catch(() => []);
+
+ const aclMinRankBySlug = new Map();
+ for (const row of aclRolePerms) {
+ const roleSlug = row.role.slug;
+ if (!roleSlug.startsWith("rank_")) continue;
+ const rank = Number(roleSlug.slice(5));
+ if (!Number.isFinite(rank)) continue;
+ const slug = row.permission.slug;
+ const prev = aclMinRankBySlug.get(slug);
+ if (prev === undefined || rank < prev) aclMinRankBySlug.set(slug, rank);
}
- const allGroups = [...new Set([...GROUPS, ...Object.keys(groupCounts)])];
+ const compareRows = allPermissions.map((p) => {
+ const inAcl = aclSlugSet.has(p.permission);
+ const aclMin = aclMinRankBySlug.get(p.permission);
+ return {
+ permission: p.permission,
+ hkMinRank: p.minRank,
+ inAcl,
+ aclMinRank: aclMin ?? null,
+ aligned: inAcl && aclMin !== undefined ? aclMin === p.minRank : false,
+ };
+ });
+ const missingInAcl = compareRows.filter((r) => !r.inAcl).length;
+ const rankMismatch = compareRows.filter(
+ (r) => r.inAcl && r.aclMinRank !== null && r.aclMinRank !== r.hkMinRank,
+ ).length;
+ const alignedCount = compareRows.filter((r) => r.aligned).length;
const auditLogs = await getAuditLogs({
search: "housekeeping",
@@ -120,6 +123,9 @@ export default async function AdminHousekeeping({
{t("legacyBannerCta")}
+
+ {t("writesRetired")}
+
@@ -130,16 +136,10 @@ export default async function AdminHousekeeping({
{t("tabOverview")}
- {t("tabManage")}
-
-
- {t("tabImport")}
+ {t("tabCompare")}
- {t("tabPresets")}
-
-
- {t("tabTest")}
+ {t("tabExportOnly")}
- {t("rankCount", { count: Object.keys(rankGroups).length })}
+ {t("compareAligned", { count: alignedCount })}
+
+
+ {t("compareMissingAcl", { count: missingInAcl })}
+
+
+ {t("compareMismatch", { count: rankMismatch })}
- {missingDeps.length > 0 && (
-
- {t("missingDeps", { count: missingDeps.length })}
-
- )}
@@ -201,19 +193,12 @@ export default async function AdminHousekeeping({
{Object.entries(groupCounts)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, count]) => (
-
+
{group}
{count} {count === 1 ? t("permission") : t("permissions")}
-
+
))}
@@ -245,52 +230,60 @@ export default async function AdminHousekeeping({
-
- {missingDeps.length > 0 && (
-
-
- {t("missingDeps")}
-
-
-
- {missingDeps.map((d) => (
-
- {d.permission} {t("dependsOn")}{" "}
- {d.dependsOn} — {t("notFound")}
-
- ))}
-
-
-
- )}
>
)}
- {tab === "manage" && (
-
+ {tab === "compare" && (
+
+ {t("tabCompare")}
+
+ {t("compareHint")}
+
+
+
+
+
+ {t("colPermission")}
+ {t("colMinRank")}
+ {t("colAclPresent")}
+ {t("colAclMinRank")}
+ {t("colStatus")}
+
+
+
+ {compareRows.map((row) => {
+ const status = !row.inAcl
+ ? t("statusMissingAcl")
+ : row.aligned
+ ? t("statusAligned")
+ : t("statusMismatch");
+ return (
+
+
+ {row.permission}
+
+ {row.hkMinRank}
+ {row.inAcl ? "✓" : "—"}
+ {row.aclMinRank ?? "—"}
+ {status}
+
+ );
+ })}
+
+
+
+
)}
{tab === "import" && (
- <>
-
-
-
-
- >
+
+
+ {t("exportOnlyHint")}
+
+
+
)}
- {tab === "presets" && }
-
- {tab === "test" && }
-
{tab === "ranks" && (
{t("perRank")}
diff --git a/src/app/admin/permissions/page.tsx b/src/app/admin/permissions/page.tsx
index 3ab1da5fbc..4a260617f3 100644
--- a/src/app/admin/permissions/page.tsx
+++ b/src/app/admin/permissions/page.tsx
@@ -5,16 +5,20 @@ import { prisma } from "@/lib/prisma";
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
import { PermissionsList } from "./permissions-list";
-export default async function PermissionsPage(_props: {
+export default async function PermissionsPage({
+ searchParams,
+}: {
params: Promise<{ locale: string }>;
+ searchParams?: Promise<{ from?: string }>;
}) {
const { session, permissions } = await getAdminContext();
- if (
- !canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)
- ) {
+ if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)) {
redirect("/admin");
}
+ const sp = (await searchParams) ?? {};
+ const fromHousekeeping = sp.from === "housekeeping";
+
const ranks = await fetchEmulatorRankSummaries(prisma);
// User counts per rank
@@ -31,5 +35,10 @@ export default async function PermissionsPage(_props: {
userCount: countMap[rank.id] ?? 0,
}));
- return ;
+ return (
+
+ );
}
diff --git a/src/app/admin/permissions/permissions-list.tsx b/src/app/admin/permissions/permissions-list.tsx
index 5230ae235c..aef41e0c04 100644
--- a/src/app/admin/permissions/permissions-list.tsx
+++ b/src/app/admin/permissions/permissions-list.tsx
@@ -42,6 +42,7 @@ interface RankSummary {
interface Props {
ranks: RankSummary[];
+ fromHousekeeping?: boolean;
}
const RANK_ICONS: Record = {
@@ -58,7 +59,10 @@ const RANK_COLORS: Record = {
4: "border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]",
};
-export function PermissionsList({ ranks: initialRanks }: Props) {
+export function PermissionsList({
+ ranks: initialRanks,
+ fromHousekeeping = false,
+}: Props) {
const { run, isPending } = useServerAction();
const [ranks, setRanks] = useState(initialRanks);
const [deleteTarget, setDeleteTarget] = useState(null);
@@ -98,14 +102,23 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
return (
+ {fromHousekeeping ? (
+
+ Housekeeping writes are retired. Edit live ACL ranks and permissions
+ here.
+
+ ) : null}
Live ACL
- Rank ACL here controls real admin access. The Housekeeping table is
- legacy reference only and does not gate routes.
+ Rank ACL here controls real admin and mod access. The Housekeeping
+ table is a read-only legacy archive with an ACL compare view.
diff --git a/src/app/admin/tickets/[id]/admin-ticket-detail.tsx b/src/app/admin/tickets/[id]/admin-ticket-detail.tsx
index 026c82f708..e8fa50082d 100644
--- a/src/app/admin/tickets/[id]/admin-ticket-detail.tsx
+++ b/src/app/admin/tickets/[id]/admin-ticket-detail.tsx
@@ -85,18 +85,29 @@ export function AdminTicketDetail({
staffMembers,
currentUserId,
canEdit,
+ listHref = "/admin/tickets",
+ userLinkMode = "admin",
}: {
ticket: TicketInfo;
messages: TicketMessage[];
staffMembers: StaffMember[];
currentUserId: number;
canEdit: boolean;
+ listHref?: string;
+ userLinkMode?: "admin" | "public";
}) {
const [messages] = useState(initialMessages);
const [reply, setReply] = useState("");
const { run, isPending } = useServerAction();
const messagesEndRef = useRef
(null);
+ function creatorHref() {
+ if (userLinkMode === "public") {
+ return `/u/${encodeURIComponent(ticket.creator.username)}`;
+ }
+ return `/admin/users/show/${ticket.creator.id}`;
+ }
+
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -152,7 +163,7 @@ export function AdminTicketDetail({
{/* Header */}
-
+
@@ -382,7 +393,7 @@ export function AdminTicketDetail({
diff --git a/src/app/admin/tickets/tickets-table.tsx b/src/app/admin/tickets/tickets-table.tsx
index 953363020b..760b04ddac 100644
--- a/src/app/admin/tickets/tickets-table.tsx
+++ b/src/app/admin/tickets/tickets-table.tsx
@@ -29,7 +29,13 @@ const STATUS_VARIANT: Record<
closed: "secondary",
};
-export function TicketsTable({ data }: { data: PaginatedResult }) {
+export function TicketsTable({
+ data,
+ basePath = "/admin/tickets",
+}: {
+ data: PaginatedResult;
+ basePath?: string;
+}) {
const t = useTranslations("pages.admin.tickets");
const statusLabel: Record = {
@@ -51,7 +57,7 @@ export function TicketsTable({ data }: { data: PaginatedResult }) {
#{row.id}
{row.subject}
diff --git a/src/app/mod/layout.tsx b/src/app/mod/layout.tsx
index 88efe2bc7f..8c7104c482 100644
--- a/src/app/mod/layout.tsx
+++ b/src/app/mod/layout.tsx
@@ -1,4 +1,4 @@
-import { Ban, Gavel, LogOut, Radio, Shield } from "lucide-react";
+import { Ban, Gavel, LogOut, Radio, Shield, Ticket, Users } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
@@ -42,11 +42,23 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
const showBans =
canAccess(permissions, PERMS.MOD_BANS_VIEW, staff.rank) ||
canAccess(permissions, PERMS.BANS_VIEW, staff.rank);
+ const showTickets =
+ canAccess(permissions, PERMS.MOD_TICKETS_VIEW, staff.rank) ||
+ canAccess(permissions, PERMS.TICKETS_VIEW, staff.rank);
+ const showTeam =
+ canAccess(permissions, PERMS.MOD_TEAM_VIEW, staff.rank) ||
+ canAccess(permissions, PERMS.MODERATION_VIEW, staff.rank);
const showAdmin = canAccess(permissions, PERMS.ADMIN_DASHBOARD, staff.rank);
const nav = [
{ href: "/mod", label: t("nav.overview"), icon: Shield, show: true },
{ href: "/mod/cfh", label: t("nav.cfh"), icon: Radio, show: showCfh },
+ {
+ href: "/mod/tickets",
+ label: t("nav.tickets"),
+ icon: Ticket,
+ show: showTickets,
+ },
{
href: "/mod/actions",
label: t("nav.actions"),
@@ -54,6 +66,7 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
show: showActions,
},
{ href: "/mod/bans", label: t("nav.bans"), icon: Ban, show: showBans },
+ { href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
].filter((item) => item.show);
return (
diff --git a/src/app/mod/page.tsx b/src/app/mod/page.tsx
index 9f25f4f44b..bc1d96cb85 100644
--- a/src/app/mod/page.tsx
+++ b/src/app/mod/page.tsx
@@ -1,4 +1,4 @@
-import { Ban, Gavel, Radio, Shield } from "lucide-react";
+import { Ban, Gavel, Radio, Shield, Ticket, Users } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatsCard } from "@/components/admin/stats-card";
@@ -23,8 +23,14 @@ export default async function ModDashboardPage() {
const showBans =
canAccess(permissions, PERMS.MOD_BANS_VIEW, rank) ||
canAccess(permissions, PERMS.BANS_VIEW, rank);
+ const showTickets =
+ canAccess(permissions, PERMS.MOD_TICKETS_VIEW, rank) ||
+ canAccess(permissions, PERMS.TICKETS_VIEW, rank);
+ const showTeam =
+ canAccess(permissions, PERMS.MOD_TEAM_VIEW, rank) ||
+ canAccess(permissions, PERMS.MODERATION_VIEW, rank);
- const [openCfh, todayBans, modsOnline] = await Promise.all([
+ const [openCfh, todayBans, modsOnline, openTickets] = await Promise.all([
showCfh
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
: Promise.resolve(0),
@@ -36,6 +42,11 @@ export default async function ModDashboardPage() {
prisma.user
.count({ where: { online: "1", rank: { gte: minStaffRank } } })
.catch(() => 0),
+ showTickets
+ ? prisma.websiteTicket
+ .count({ where: { status: { not: "closed" } } })
+ .catch(() => 0)
+ : Promise.resolve(0),
]);
return (
@@ -47,7 +58,7 @@ export default async function ModDashboardPage() {
-
+
{showCfh ? (
) : null}
+ {showTickets ? (
+ }
+ href="/mod/tickets"
+ />
+ ) : null}
{showBans ? (
}
+ href={showTeam ? "/mod/team" : undefined}
/>
{showActions ? (
) : null}
+ {showTeam ? (
+ }
+ href="/mod/team"
+ />
+ ) : null}
@@ -90,6 +119,16 @@ export default async function ModDashboardPage() {
) : null}
+ {showTickets ? (
+
+
+ {t("nav.tickets")}
+
+
+ ) : null}
{showActions ? (
) : null}
+ {showTeam ? (
+
+
+ {t("nav.team")}
+
+
+ ) : null}
diff --git a/src/app/mod/team/page.tsx b/src/app/mod/team/page.tsx
new file mode 100644
index 0000000000..0f5856894e
--- /dev/null
+++ b/src/app/mod/team/page.tsx
@@ -0,0 +1,149 @@
+import Link from "next/link";
+import { AvatarImage } from "@/components/shared/avatar-image";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import { requireModPermission } from "@/lib/admin/guard";
+import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
+import { PERMS } from "@/lib/permissions";
+import { prisma } from "@/lib/prisma";
+
+export default async function ModTeamLitePage() {
+ await requireModPermission([PERMS.MOD_TEAM_VIEW, PERMS.MODERATION_VIEW]);
+
+ const minStaffRank = await getMinStaffRank();
+ const staff = await prisma.user.findMany({
+ where: { rank: { gte: minStaffRank } },
+ select: {
+ id: true,
+ username: true,
+ look: true,
+ rank: true,
+ online: true,
+ lastLogin: true,
+ lastOnline: true,
+ },
+ orderBy: [{ online: "desc" }, { rank: "desc" }],
+ });
+
+ const [cfhCounts, webTicketCounts, modActionCounts] = await Promise.all([
+ prisma.supportTickets.groupBy({
+ by: ["modId"],
+ where: { modId: { in: staff.map((s) => s.id) } },
+ _count: { _all: true },
+ }),
+ prisma.websiteTicket.groupBy({
+ by: ["assigneeId"],
+ where: { assigneeId: { in: staff.map((s) => s.id) } },
+ _count: { _all: true },
+ }),
+ prisma.adminAuditLog.groupBy({
+ by: ["userId"],
+ where: {
+ userId: { in: staff.map((s) => s.id) },
+ action: { startsWith: "mod_" },
+ },
+ _count: { _all: true },
+ }),
+ ]);
+
+ const cfhMap = new Map(cfhCounts.map((c) => [c.modId, c._count._all]));
+ const webMap = new Map(
+ webTicketCounts.map((c) => [c.assigneeId, c._count._all]),
+ );
+ const actionMap = new Map(
+ modActionCounts.map((c) => [c.userId, c._count._all]),
+ );
+
+ return (
+
+
+
+
+
+ {staff.filter((s) => s.online === "1").length}
+
+ Online Now
+
+
+
+
+ {staff.length}
+ Total Staff
+
+
+
+
+
+ {staff.filter((s) => s.rank > minStaffRank).length}
+
+ Above min rank
+
+
+
+
+
+
+
+ Name
+ Rank
+ CFH
+ Tickets
+ Actions
+ Last Seen
+
+
+ {staff.map((member) => (
+
+
+
+
+ Rank {member.rank}
+
+
+
{cfhMap.get(member.id) ?? 0}
+
CFH
+
+
+
{webMap.get(member.id) ?? 0}
+
Web
+
+
+
+ {actionMap.get(member.id) ?? 0}
+
+
Mod
+
+
+ {member.online === "1"
+ ? "Online"
+ : member.lastOnline
+ ? new Date(member.lastOnline * 1000).toLocaleDateString()
+ : "—"}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/mod/tickets/[id]/page.tsx b/src/app/mod/tickets/[id]/page.tsx
new file mode 100644
index 0000000000..bdb18c45cf
--- /dev/null
+++ b/src/app/mod/tickets/[id]/page.tsx
@@ -0,0 +1,101 @@
+import { notFound } from "next/navigation";
+import { AdminTicketDetail } from "@/app/admin/tickets/[id]/admin-ticket-detail";
+import { requireModPermission } from "@/lib/admin/guard";
+import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
+import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
+import { prisma } from "@/lib/prisma";
+
+export default async function ModTicketDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
+
+ const { session, permissions } = await getAdminContext();
+ const { id } = await params;
+ const ticketId = Number(id);
+ if (Number.isNaN(ticketId)) notFound();
+
+ const ticket = await prisma.websiteTicket.findUnique({
+ where: { id: ticketId },
+ include: {
+ creator: {
+ select: {
+ id: true,
+ username: true,
+ look: true,
+ rank: true,
+ ipRegister: true,
+ mail: true,
+ accountCreated: true,
+ },
+ },
+ assignee: { select: { id: true, username: true } },
+ messages: {
+ orderBy: { createdAt: "asc" },
+ include: {
+ user: {
+ select: { id: true, username: true, look: true, rank: true },
+ },
+ },
+ },
+ },
+ });
+
+ if (!ticket) notFound();
+
+ const minStaffRank = await getMinStaffRank();
+ const staffMembers = await prisma.user.findMany({
+ where: { rank: { gte: minStaffRank } },
+ select: { id: true, username: true },
+ orderBy: { username: "asc" },
+ });
+
+ const canEdit =
+ canAccess(permissions, PERMS.MOD_TICKETS_EDIT, session.user.rank) ||
+ canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
+
+ return (
+
({
+ id: m.id,
+ message: m.message,
+ isStaff: m.isStaff === 1,
+ createdAt: m.createdAt.toISOString(),
+ user: {
+ id: m.user.id,
+ username: m.user.username,
+ look: m.user.look,
+ rank: m.user.rank,
+ },
+ }))}
+ staffMembers={staffMembers}
+ currentUserId={session.user.id}
+ canEdit={canEdit}
+ listHref="/mod/tickets"
+ userLinkMode="public"
+ />
+ );
+}
diff --git a/src/app/mod/tickets/page.tsx b/src/app/mod/tickets/page.tsx
new file mode 100644
index 0000000000..b29f7bf701
--- /dev/null
+++ b/src/app/mod/tickets/page.tsx
@@ -0,0 +1,155 @@
+import { getTranslations } from "next-intl/server";
+import {
+ type TicketRow,
+ TicketsTable,
+} from "@/app/admin/tickets/tickets-table";
+import { StatusCard } from "@/components/admin/dashboard";
+import type { Prisma } from "@/generated/prisma/client";
+import { requireModPermission } from "@/lib/admin/guard";
+import { calcPagination, parseListParams } from "@/lib/admin-helpers";
+import { PERMS } from "@/lib/permissions";
+import { prisma } from "@/lib/prisma";
+
+export const dynamic = "force-dynamic";
+
+function fromDate(d: Date | null | undefined): string {
+ return d ? d.toISOString().slice(0, 10) : "—";
+}
+
+export default async function ModTicketsPage({
+ searchParams,
+}: {
+ searchParams: Promise>;
+}) {
+ await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
+
+ const t = await getTranslations("pages.admin.tickets");
+ const raw = await searchParams;
+ const parsed = parseListParams(new URLSearchParams(raw));
+ const statusFilter = raw.filter_status || raw.status || "active";
+
+ const conditions: Prisma.WebsiteTicketWhereInput[] = [];
+
+ if (statusFilter === "active") {
+ conditions.push({ status: { not: "closed" } });
+ } else if (statusFilter && statusFilter !== "all") {
+ conditions.push({ status: statusFilter });
+ }
+
+ if (parsed.search.trim()) {
+ const q = parsed.search.trim();
+ const asId = Number(q);
+ conditions.push({
+ OR: [
+ { subject: { contains: q } },
+ { category: { contains: q } },
+ { creator: { username: { contains: q } } },
+ { assignee: { username: { contains: q } } },
+ ...(Number.isFinite(asId) && asId > 0 ? [{ id: asId }] : []),
+ ],
+ });
+ }
+
+ const where: Prisma.WebsiteTicketWhereInput =
+ conditions.length === 0
+ ? {}
+ : conditions.length === 1
+ ? conditions[0]
+ : { AND: conditions };
+
+ const SORT_MAP: Record =
+ {
+ subject: { subject: "asc" },
+ status: { status: "asc" },
+ priority: { priority: "asc" },
+ assignee: { assigneeId: "asc" },
+ date: { createdAt: "desc" },
+ id: { id: "desc" },
+ };
+
+ const baseOrder = SORT_MAP[parsed.sort ?? "date"] ?? { createdAt: "desc" };
+ const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
+ const orderBy = {
+ [orderField]: parsed.order,
+ } as Prisma.WebsiteTicketOrderByWithRelationInput;
+
+ const [total, openCount, inProgressCount, waitingCount, closedCount] =
+ await Promise.all([
+ prisma.websiteTicket.count({ where }).catch(() => 0),
+ prisma.websiteTicket.count({ where: { status: "open" } }).catch(() => 0),
+ prisma.websiteTicket
+ .count({ where: { status: "in_progress" } })
+ .catch(() => 0),
+ prisma.websiteTicket
+ .count({ where: { status: "waiting" } })
+ .catch(() => 0),
+ prisma.websiteTicket
+ .count({ where: { status: "closed" } })
+ .catch(() => 0),
+ ]);
+
+ const pagination = calcPagination(total, parsed.page, parsed.perPage);
+
+ const tickets = await prisma.websiteTicket
+ .findMany({
+ where,
+ orderBy,
+ skip: pagination.offset,
+ take: pagination.perPage,
+ include: {
+ creator: { select: { id: true, username: true } },
+ assignee: { select: { id: true, username: true } },
+ _count: { select: { messages: true } },
+ },
+ })
+ .catch(() => []);
+
+ const rows: TicketRow[] = tickets.map((ticket) => ({
+ id: ticket.id,
+ subject: ticket.subject,
+ creator: ticket.creator.username,
+ creatorId: ticket.creatorId,
+ category: ticket.category,
+ messages: ticket._count.messages,
+ status: ticket.status,
+ priority: ticket.priority,
+ assignee: ticket.assignee?.username ?? "—",
+ date: fromDate(ticket.createdAt),
+ }));
+
+ return (
+
+
+
+ 0 ? "warn" : "neutral"}
+ icon="⏳"
+ />
+ 0 ? "warn" : "neutral"}
+ icon="🕒"
+ />
+
+
+
+ {total === 0 && !parsed.search && statusFilter === "active" ? (
+
{t("noTickets")}
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/lib/admin/guard.ts b/src/lib/admin/guard.ts
index 196a6addd6..e33b59887b 100644
--- a/src/lib/admin/guard.ts
+++ b/src/lib/admin/guard.ts
@@ -70,7 +70,10 @@ export async function requireMod(): Promise {
canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank) ||
canAccess(permissions, PERMS.MOD_CFH_VIEW, session.user.rank) ||
canAccess(permissions, PERMS.MOD_ACTIONS, session.user.rank) ||
- canAccess(permissions, PERMS.MOD_BANS_VIEW, session.user.rank);
+ canAccess(permissions, PERMS.MOD_BANS_VIEW, session.user.rank) ||
+ canAccess(permissions, PERMS.MOD_TICKETS_VIEW, session.user.rank) ||
+ canAccess(permissions, PERMS.MOD_TEAM_VIEW, session.user.rank) ||
+ canAccess(permissions, PERMS.TICKETS_VIEW, session.user.rank);
if (!ok) redirectSafe("/", "/");
return {
id: session.user.id,
diff --git a/src/messages/en.json b/src/messages/en.json
index 9f50e560eb..8c12222963 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -2726,7 +2726,21 @@
"importTitle": "Import permissions",
"legacyBannerTitle": "This table is not live ACL",
"legacyBannerBody": "Runtime access uses the ACL permissions system. Use Housekeeping only as a legacy reference.",
- "legacyBannerCta": "Open live Permissions"
+ "legacyBannerCta": "Open live Permissions",
+ "writesRetired": "Create/edit/import/presets on this table are disabled. Change live access under Permissions.",
+ "tabCompare": "ACL compare",
+ "tabExportOnly": "Export archive",
+ "compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
+ "compareAligned": "{count} aligned",
+ "compareMissingAcl": "{count} missing in ACL",
+ "compareMismatch": "{count} rank mismatch",
+ "colAclPresent": "In ACL",
+ "colAclMinRank": "ACL min rank",
+ "colStatus": "Status",
+ "statusAligned": "Aligned",
+ "statusMissingAcl": "Missing in ACL",
+ "statusMismatch": "Rank differs",
+ "exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Rooms",
@@ -3028,14 +3042,19 @@
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
- "backToSite": "Back to site"
+ "backToSite": "Back to site",
+ "tickets": "Tickets",
+ "team": "Team"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
- "quickActionsHint": "Kick, mute, alert"
+ "quickActionsHint": "Kick, mute, alert",
+ "openTickets": "Open tickets",
+ "team": "Mod team",
+ "teamHint": "Online staff & workload"
}
}
}
diff --git a/src/messages/it.json b/src/messages/it.json
index 66a3dfa6b1..9f0a2a655c 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -2573,7 +2573,21 @@
"importTitle": "Import permissions",
"legacyBannerTitle": "Questa tabella non e ACL live",
"legacyBannerBody": "L'accesso runtime usa il sistema ACL. Usa Housekeeping solo come riferimento legacy.",
- "legacyBannerCta": "Apri Permessi live"
+ "legacyBannerCta": "Apri Permessi live",
+ "writesRetired": "Create/modifica/import/preset su questa tabella sono disabilitati. Cambia l'accesso live in Permessi.",
+ "tabCompare": "Confronto ACL",
+ "tabExportOnly": "Esporta archivio",
+ "compareHint": "Confronta le righe legacy housekeeping con i grant ACL live rank_* (grado minimo con lo slug).",
+ "compareAligned": "{count} allineati",
+ "compareMissingAcl": "{count} mancanti in ACL",
+ "compareMismatch": "{count} mismatch grado",
+ "colAclPresent": "In ACL",
+ "colAclMinRank": "Min grado ACL",
+ "colStatus": "Stato",
+ "statusAligned": "Allineato",
+ "statusMissingAcl": "Manca in ACL",
+ "statusMismatch": "Grado diverso",
+ "exportOnlyHint": "Le scritture sono disabilitate. Puoi ancora esportare la tabella legacy per archivio."
},
"rooms": {
"title": "Stanze",
@@ -3031,14 +3045,19 @@
"actions": "Azioni rapide",
"bans": "Ban",
"fullAdmin": "Admin completo",
- "backToSite": "Torna al sito"
+ "backToSite": "Torna al sito",
+ "tickets": "Ticket",
+ "team": "Team"
},
"stats": {
"openCfh": "CFH aperti",
"bansToday": "Ban oggi",
"modsOnline": "Mod online",
"quickActions": "Azioni rapide",
- "quickActionsHint": "Kick, mute, alert"
+ "quickActionsHint": "Kick, mute, alert",
+ "openTickets": "Ticket aperti",
+ "team": "Team mod",
+ "teamHint": "Staff online e carico"
}
}
}
diff --git a/src/messages/nl.json b/src/messages/nl.json
index de5c92e1a8..09cc70500c 100644
--- a/src/messages/nl.json
+++ b/src/messages/nl.json
@@ -2548,7 +2548,21 @@
"importTitle": "Permissies importeren",
"legacyBannerTitle": "Deze tabel is geen live ACL",
"legacyBannerBody": "Runtime-toegang gebruikt het ACL-systeem. Gebruik Housekeeping alleen als legacy-referentie.",
- "legacyBannerCta": "Open live Permissions"
+ "legacyBannerCta": "Open live Permissions",
+ "writesRetired": "Aanmaken/bewerken/import/presets op deze tabel zijn uitgeschakeld. Wijzig live toegang onder Permissions.",
+ "tabCompare": "ACL vergelijking",
+ "tabExportOnly": "Exporteer archief",
+ "compareHint": "Vergelijkt legacy housekeeping min-rank rijen met live ACL rank_* grants (laagste rang met de slug).",
+ "compareAligned": "{count} uitgelijnd",
+ "compareMissingAcl": "{count} ontbreekt in ACL",
+ "compareMismatch": "{count} rang mismatch",
+ "colAclPresent": "In ACL",
+ "colAclMinRank": "ACL min rang",
+ "colStatus": "Status",
+ "statusAligned": "Uitgelijnd",
+ "statusMissingAcl": "Ontbreekt in ACL",
+ "statusMismatch": "Rang verschilt",
+ "exportOnlyHint": "Schrijven is uitgeschakeld. Je kunt de legacy-tabel nog exporteren voor archief."
},
"rooms": {
"title": "Kamers",
@@ -3029,14 +3043,19 @@
"actions": "Snelle acties",
"bans": "Bans",
"fullAdmin": "Volledige admin",
- "backToSite": "Terug naar site"
+ "backToSite": "Terug naar site",
+ "tickets": "Tickets",
+ "team": "Team"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans vandaag",
"modsOnline": "Mods online",
"quickActions": "Snelle acties",
- "quickActionsHint": "Kick, mute, alert"
+ "quickActionsHint": "Kick, mute, alert",
+ "openTickets": "Open tickets",
+ "team": "Mod team",
+ "teamHint": "Online staff & werklast"
}
}
}