feat(mod): tickets+team tabs; retire HK writes for live ACL
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
084cca6ea4
commit
75cace41ea
16 files changed
+735
-1002
No files matched your search
@@ -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<number | null> {
|
||||
const session = await auth();
|
||||
return session?.user ? sessionUserId(session.user.id) : null;
|
||||
}
|
||||
|
||||
export async function upsertPermission(formData: FormData): Promise<void> {
|
||||
/**
|
||||
* Housekeeping table writes are retired. Runtime access uses ACL only.
|
||||
* Redirect editors to live Permissions.
|
||||
*/
|
||||
async function rejectLegacyHousekeepingWrite(): Promise<never> {
|
||||
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<void> {
|
||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
export async function upsertPermission(_formData: FormData): Promise<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
@@ -208,688 +45,16 @@ export async function exportPermissions(): Promise<string> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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<void> {
|
||||
await rejectLegacyHousekeepingWrite();
|
||||
}
|
||||
|
||||
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");
|
||||
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();
|
||||
}
|
||||
+16
-4
@@ -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 },
|
||||
|
||||
+111
-118
@@ -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<typeof prisma.websiteHousekeepingPermissions.findMany>
|
||||
@@ -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<string, number>();
|
||||
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")}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="m-0 mt-2 text-xs text-[var(--admin-text-muted)]">
|
||||
{t("writesRetired")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs mb-4">
|
||||
@@ -130,16 +136,10 @@ export default async function AdminHousekeeping({
|
||||
{t("tabOverview")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/housekeeping?tab=manage"
|
||||
className={`admin-tab ${tab === "manage" ? "active" : ""}`}
|
||||
href="/admin/housekeeping?tab=compare"
|
||||
className={`admin-tab ${tab === "compare" ? "active" : ""}`}
|
||||
>
|
||||
{t("tabManage")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/housekeeping?tab=import"
|
||||
className={`admin-tab ${tab === "import" ? "active" : ""}`}
|
||||
>
|
||||
{t("tabImport")}
|
||||
{t("tabCompare")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/housekeeping?tab=ranks"
|
||||
@@ -148,16 +148,10 @@ export default async function AdminHousekeeping({
|
||||
{t("tabRanks")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/housekeeping?tab=presets"
|
||||
className={`admin-tab ${tab === "presets" ? "active" : ""}`}
|
||||
href="/admin/housekeeping?tab=import"
|
||||
className={`admin-tab ${tab === "import" ? "active" : ""}`}
|
||||
>
|
||||
{t("tabPresets")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/housekeeping?tab=test"
|
||||
className={`admin-tab ${tab === "test" ? "active" : ""}`}
|
||||
>
|
||||
{t("tabTest")}
|
||||
{t("tabExportOnly")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/housekeeping?tab=audit"
|
||||
@@ -183,16 +177,14 @@ export default async function AdminHousekeeping({
|
||||
{t("groupCount", { count: Object.keys(groupCounts).length })}
|
||||
</span>
|
||||
<span className="admin-stat-chip">
|
||||
{t("rankCount", { count: Object.keys(rankGroups).length })}
|
||||
{t("compareAligned", { count: alignedCount })}
|
||||
</span>
|
||||
<span className="admin-stat-chip">
|
||||
{t("compareMissingAcl", { count: missingInAcl })}
|
||||
</span>
|
||||
<span className="admin-stat-chip">
|
||||
{t("compareMismatch", { count: rankMismatch })}
|
||||
</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">
|
||||
@@ -201,19 +193,12 @@ export default async function AdminHousekeeping({
|
||||
{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 key={group} className="admin-card p-3">
|
||||
<div className="font-semibold text-sm">{group}</div>
|
||||
<div className="text-xs opacity-60">
|
||||
{count} {count === 1 ? t("permission") : t("permissions")}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -245,52 +230,60 @@ export default async function AdminHousekeeping({
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{missingDeps.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<h2
|
||||
className="admin-section-title"
|
||||
style={{ color: "var(--color-danger)" }}
|
||||
>
|
||||
{t("missingDeps")}
|
||||
</h2>
|
||||
<div className="admin-card">
|
||||
<ul className="list-disc pl-5 text-sm">
|
||||
{missingDeps.map((d) => (
|
||||
<li key={d.permission}>
|
||||
<strong>{d.permission}</strong> {t("dependsOn")}{" "}
|
||||
<code>{d.dependsOn}</code> — {t("notFound")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "manage" && (
|
||||
<ManageSection
|
||||
permissions={allPermissions}
|
||||
groups={allGroups}
|
||||
groupCounts={groupCounts}
|
||||
permSet={permSet}
|
||||
/>
|
||||
{tab === "compare" && (
|
||||
<section>
|
||||
<h2 className="admin-section-title">{t("tabCompare")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
||||
{t("compareHint")}
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colPermission")}</th>
|
||||
<th>{t("colMinRank")}</th>
|
||||
<th>{t("colAclPresent")}</th>
|
||||
<th>{t("colAclMinRank")}</th>
|
||||
<th>{t("colStatus")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{compareRows.map((row) => {
|
||||
const status = !row.inAcl
|
||||
? t("statusMissingAcl")
|
||||
: row.aligned
|
||||
? t("statusAligned")
|
||||
: t("statusMismatch");
|
||||
return (
|
||||
<tr key={row.permission}>
|
||||
<td>
|
||||
<code className="text-xs">{row.permission}</code>
|
||||
</td>
|
||||
<td>{row.hkMinRank}</td>
|
||||
<td>{row.inAcl ? "✓" : "—"}</td>
|
||||
<td>{row.aclMinRank ?? "—"}</td>
|
||||
<td className="text-xs">{status}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "import" && (
|
||||
<>
|
||||
<ImportSection />
|
||||
<div className="mt-8">
|
||||
<ExportSection />
|
||||
</div>
|
||||
</>
|
||||
<div className="mt-2">
|
||||
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
|
||||
{t("exportOnlyHint")}
|
||||
</p>
|
||||
<ExportSection />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "presets" && <PresetsSection />}
|
||||
|
||||
{tab === "test" && <TestSection permissions={allPermissions} />}
|
||||
|
||||
{tab === "ranks" && (
|
||||
<section>
|
||||
<h2 className="admin-section-title">{t("perRank")}</h2>
|
||||
|
||||
@@ -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 <PermissionsList ranks={ranksWithCounts} />;
|
||||
return (
|
||||
<PermissionsList
|
||||
ranks={ranksWithCounts}
|
||||
fromHousekeeping={fromHousekeeping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ interface RankSummary {
|
||||
|
||||
interface Props {
|
||||
ranks: RankSummary[];
|
||||
fromHousekeeping?: boolean;
|
||||
}
|
||||
|
||||
const RANK_ICONS: Record<number, React.ReactNode> = {
|
||||
@@ -58,7 +59,10 @@ const RANK_COLORS: Record<number, string> = {
|
||||
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<RankSummary | null>(null);
|
||||
@@ -98,14 +102,23 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{fromHousekeeping ? (
|
||||
<div
|
||||
className="rounded-lg border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-4 py-3 text-sm"
|
||||
role="status"
|
||||
>
|
||||
Housekeeping writes are retired. Edit live ACL ranks and permissions
|
||||
here.
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm text-[var(--admin-text)]"
|
||||
role="status"
|
||||
>
|
||||
<p className="m-0 font-semibold">Live ACL</p>
|
||||
<p className="m-0 mt-1 text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>(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({
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<Link href="/admin/tickets">
|
||||
<Link href={listHref}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -382,7 +393,7 @@ export function AdminTicketDetail({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Link
|
||||
href={`/admin/users/show/${ticket.creator.id}`}
|
||||
href={creatorHref()}
|
||||
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
|
||||
>
|
||||
<User className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -29,7 +29,13 @@ const STATUS_VARIANT: Record<
|
||||
closed: "secondary",
|
||||
};
|
||||
|
||||
export function TicketsTable({ data }: { data: PaginatedResult<TicketRow> }) {
|
||||
export function TicketsTable({
|
||||
data,
|
||||
basePath = "/admin/tickets",
|
||||
}: {
|
||||
data: PaginatedResult<TicketRow>;
|
||||
basePath?: string;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.tickets");
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
@@ -51,7 +57,7 @@ export function TicketsTable({ data }: { data: PaginatedResult<TicketRow> }) {
|
||||
#{row.id}
|
||||
</span>
|
||||
<Link
|
||||
href={`/admin/tickets/${row.id}`}
|
||||
href={`${basePath}/${row.id}`}
|
||||
className="font-medium truncate hover:underline"
|
||||
>
|
||||
{row.subject}
|
||||
|
||||
+14
-1
@@ -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 (
|
||||
|
||||
+49
-3
@@ -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() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{showCfh ? (
|
||||
<StatsCard
|
||||
title={t("stats.openCfh")}
|
||||
@@ -56,6 +67,14 @@ export default async function ModDashboardPage() {
|
||||
href="/mod/cfh"
|
||||
/>
|
||||
) : null}
|
||||
{showTickets ? (
|
||||
<StatsCard
|
||||
title={t("stats.openTickets")}
|
||||
value={openTickets}
|
||||
icon={<Ticket className="h-4 w-4" />}
|
||||
href="/mod/tickets"
|
||||
/>
|
||||
) : null}
|
||||
{showBans ? (
|
||||
<StatsCard
|
||||
title={t("stats.bansToday")}
|
||||
@@ -68,6 +87,7 @@ export default async function ModDashboardPage() {
|
||||
title={t("stats.modsOnline")}
|
||||
value={modsOnline}
|
||||
icon={<Shield className="h-4 w-4" />}
|
||||
href={showTeam ? "/mod/team" : undefined}
|
||||
/>
|
||||
{showActions ? (
|
||||
<StatsCard
|
||||
@@ -78,6 +98,15 @@ export default async function ModDashboardPage() {
|
||||
href="/mod/actions"
|
||||
/>
|
||||
) : null}
|
||||
{showTeam ? (
|
||||
<StatsCard
|
||||
title={t("stats.team")}
|
||||
value="→"
|
||||
description={t("stats.teamHint")}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
href="/mod/team"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="admin-card space-y-2 p-4">
|
||||
@@ -90,6 +119,16 @@ export default async function ModDashboardPage() {
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
{showTickets ? (
|
||||
<li>
|
||||
<Link
|
||||
href="/mod/tickets"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t("nav.tickets")}
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
{showActions ? (
|
||||
<li>
|
||||
<Link
|
||||
@@ -107,6 +146,13 @@ export default async function ModDashboardPage() {
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
{showTeam ? (
|
||||
<li>
|
||||
<Link href="/mod/team" className="text-primary hover:underline">
|
||||
{t("nav.team")}
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-center">
|
||||
<p className="text-2xl font-bold text-[var(--admin-success)]">
|
||||
{staff.filter((s) => s.online === "1").length}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Online Now</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-center">
|
||||
<p className="text-2xl font-bold">{staff.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Staff</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-center">
|
||||
<p className="text-2xl font-bold text-primary">
|
||||
{staff.filter((s) => s.rank > minStaffRank).length}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Above min rank</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-4 p-3 bg-muted/50 text-xs font-medium text-muted-foreground border-b">
|
||||
<span />
|
||||
<span>Name</span>
|
||||
<span>Rank</span>
|
||||
<span>CFH</span>
|
||||
<span>Tickets</span>
|
||||
<span>Actions</span>
|
||||
<span>Last Seen</span>
|
||||
</div>
|
||||
|
||||
{staff.map((member) => (
|
||||
<Link
|
||||
key={member.id}
|
||||
href={`/u/${encodeURIComponent(member.username)}`}
|
||||
className="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-4 p-3 items-center hover:bg-accent/50 transition-all duration-150 border-b last:border-0"
|
||||
>
|
||||
<div className="relative">
|
||||
<AvatarImage
|
||||
figure={member.look}
|
||||
options={{ headOnly: true, size: "l" }}
|
||||
alt={member.username}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-background ${
|
||||
member.online === "1"
|
||||
? "bg-[var(--admin-success)]"
|
||||
: "bg-[var(--admin-text-muted)]"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{member.username}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Rank {member.rank}
|
||||
</Badge>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-bold">{cfhMap.get(member.id) ?? 0}</p>
|
||||
<p className="text-[9px] text-muted-foreground">CFH</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-bold">{webMap.get(member.id) ?? 0}</p>
|
||||
<p className="text-[9px] text-muted-foreground">Web</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-bold">
|
||||
{actionMap.get(member.id) ?? 0}
|
||||
</p>
|
||||
<p className="text-[9px] text-muted-foreground">Mod</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{member.online === "1"
|
||||
? "Online"
|
||||
: member.lastOnline
|
||||
? new Date(member.lastOnline * 1000).toLocaleDateString()
|
||||
: "—"}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<AdminTicketDetail
|
||||
ticket={{
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
category: ticket.category,
|
||||
priority: ticket.priority,
|
||||
status: ticket.status,
|
||||
createdAt: ticket.createdAt.toISOString(),
|
||||
closedAt: ticket.closedAt?.toISOString() ?? null,
|
||||
creator: {
|
||||
id: ticket.creator.id,
|
||||
username: ticket.creator.username,
|
||||
look: ticket.creator.look,
|
||||
rank: ticket.creator.rank,
|
||||
mail: ticket.creator.mail ?? "",
|
||||
ipRegister: ticket.creator.ipRegister,
|
||||
accountCreated: ticket.creator.accountCreated,
|
||||
},
|
||||
assignee: ticket.assignee
|
||||
? { id: ticket.assignee.id, username: ticket.assignee.username }
|
||||
: null,
|
||||
}}
|
||||
messages={ticket.messages.map((m) => ({
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, string>>;
|
||||
}) {
|
||||
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<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
|
||||
{
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||
<StatusCard
|
||||
label={t("statusInProgress")}
|
||||
value={inProgressCount}
|
||||
state={inProgressCount > 0 ? "warn" : "neutral"}
|
||||
icon="⏳"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("statusWaiting")}
|
||||
value={waitingCount}
|
||||
state={waitingCount > 0 ? "warn" : "neutral"}
|
||||
icon="🕒"
|
||||
/>
|
||||
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
||||
</div>
|
||||
|
||||
{total === 0 && !parsed.search && statusFilter === "active" ? (
|
||||
<div className="admin-empty">{t("noTickets")}</div>
|
||||
) : (
|
||||
<TicketsTable
|
||||
basePath="/mod/tickets"
|
||||
data={{
|
||||
rows,
|
||||
total,
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
lastPage: pagination.lastPage,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,10 @@ export async function requireMod(): Promise<StaffUser> {
|
||||
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,
|
||||
|
||||
+22
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user