feat(mod): tickets+team tabs; retire HK writes for live ACL
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 30s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-22 21:35:35 +02:00
1 parent 084cca6ea4
commit 75cace41ea
16 files changed
+735 -1002

No files matched your search

+19 -854
View File
@@ -1,194 +1,31 @@
"use server"; "use server";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { auth } from "@/lib/auth"; import { redirectSafe } from "@/lib/foundation/security";
import { sessionUserId } from "@/lib/auth/session-user";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/services/audit";
function sanitize(str: string, max = 255): string { /**
return String(str ?? "") * Housekeeping table writes are retired. Runtime access uses ACL only.
.normalize("NFC") * Redirect editors to live Permissions.
.trim() */
.slice(0, max); async function rejectLegacyHousekeepingWrite(): Promise<never> {
}
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> {
await requirePermission(PERMS.SETTINGS_EDIT); await requirePermission(PERMS.SETTINGS_EDIT);
redirectSafe("/admin/permissions?from=housekeeping", "/admin/permissions");
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");
} }
export async function deletePermission(formData: FormData): Promise<void> { export async function upsertPermission(_formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT); await rejectLegacyHousekeepingWrite();
}
const raw = String(formData.get("id") ?? "").normalize("NFC"); export async function deletePermission(_formData: FormData): Promise<void> {
if (!raw) return; await rejectLegacyHousekeepingWrite();
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 bulkImportPermissions( export async function bulkImportPermissions(
formData: FormData, _formData: FormData,
): Promise<{ count: number; errors: string[] }> { ): Promise<{ count: number; errors: string[] }> {
await requirePermission(PERMS.SETTINGS_EDIT); await rejectLegacyHousekeepingWrite();
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 };
} }
export async function exportPermissions(): Promise<string> { export async function exportPermissions(): Promise<string> {
@@ -208,688 +45,16 @@ export async function exportPermissions(): Promise<string> {
return JSON.stringify(perms, null, 2); return JSON.stringify(perms, null, 2);
} }
export async function applyPreset(formData: FormData): Promise<void> { export async function applyPreset(_formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT); await rejectLegacyHousekeepingWrite();
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 clearAllPermissions(): Promise<void> { export async function clearAllPermissions(): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT); await rejectLegacyHousekeepingWrite();
const userId = await currentUserId();
const count = await prisma.websiteHousekeepingPermissions.count();
await prisma.websiteHousekeepingPermissions.deleteMany();
await logAudit({
userId: userId ?? 0,
action: "housekeeping.permission.clear_all",
target: "WebsiteHousekeepingPermissions",
after: { deletedCount: count },
});
revalidatePath("/admin/housekeeping");
} }
export async function bulkDeletePermissions( export async function bulkDeletePermissions(
ids: bigint[], _ids: bigint[],
): Promise<{ count: number }> { ): Promise<{ count: number }> {
await requirePermission(PERMS.SETTINGS_EDIT); await rejectLegacyHousekeepingWrite();
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 };
} }
+16 -4
View File
@@ -121,7 +121,10 @@ export const closeTicketByUser = authAction(
// ── Admin actions ──────────────────────────────────────────────────── // ── Admin actions ────────────────────────────────────────────────────
export const adminReplyTicket = adminAction( export const adminReplyTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema }, {
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
schema: replyTicketSchema,
},
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
@@ -161,7 +164,10 @@ export const adminReplyTicket = adminAction(
); );
export const updateTicketStatus = adminAction( export const updateTicketStatus = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema }, {
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
schema: updateTicketStatusSchema,
},
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
@@ -196,7 +202,10 @@ export const updateTicketStatus = adminAction(
); );
export const assignTicket = adminAction( export const assignTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema }, {
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
schema: assignTicketSchema,
},
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
@@ -226,7 +235,10 @@ export const assignTicket = adminAction(
); );
export const updateTicketPriority = adminAction( export const updateTicketPriority = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema }, {
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
schema: updateTicketPrioritySchema,
},
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
+111 -118
View File
@@ -4,44 +4,10 @@ import { getTranslations } from "next-intl/server";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getAuditLogs } from "@/lib/services/audit"; import { getAuditLogs } from "@/lib/services/audit";
import { import { AuditSection, ExportSection } from "./client";
AuditSection,
ExportSection,
ImportSection,
ManageSection,
PresetsSection,
TestSection,
} from "./client";
export const dynamic = "force-dynamic"; 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({ export default async function AdminHousekeeping({
searchParams, searchParams,
}: { }: {
@@ -53,7 +19,8 @@ export default async function AdminHousekeeping({
| "ranks" | "ranks"
| "presets" | "presets"
| "test" | "test"
| "audit"; | "audit"
| "compare";
group?: string; group?: string;
}>; }>;
}) { }) {
@@ -64,7 +31,10 @@ export default async function AdminHousekeeping({
const t = await getTranslations("pages.admin.housekeeping"); const t = await getTranslations("pages.admin.housekeeping");
const sp = await searchParams; 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< type HKPerm = Awaited<
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany> ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
@@ -89,15 +59,48 @@ export default async function AdminHousekeeping({
rankGroups[p.minRank].push(p); rankGroups[p.minRank].push(p);
} }
const missingDeps: Array<{ permission: string; dependsOn: string }> = []; const aclSlugs = await prisma.aclPermission
const permSet = new Set(allPermissions.map((p) => p.permission)); .findMany({ select: { slug: true } })
for (const p of allPermissions) { .catch(() => [] as { slug: string }[]);
if (p.dependsOn && !permSet.has(p.dependsOn)) { const aclSlugSet = new Set(aclSlugs.map((p) => p.slug));
missingDeps.push({ permission: p.permission, dependsOn: p.dependsOn });
} 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({ const auditLogs = await getAuditLogs({
search: "housekeeping", search: "housekeeping",
@@ -120,6 +123,9 @@ export default async function AdminHousekeeping({
{t("legacyBannerCta")} {t("legacyBannerCta")}
</Link> </Link>
</p> </p>
<p className="m-0 mt-2 text-xs text-[var(--admin-text-muted)]">
{t("writesRetired")}
</p>
</div> </div>
<div className="admin-tabs mb-4"> <div className="admin-tabs mb-4">
@@ -130,16 +136,10 @@ export default async function AdminHousekeeping({
{t("tabOverview")} {t("tabOverview")}
</Link> </Link>
<Link <Link
href="/admin/housekeeping?tab=manage" href="/admin/housekeeping?tab=compare"
className={`admin-tab ${tab === "manage" ? "active" : ""}`} className={`admin-tab ${tab === "compare" ? "active" : ""}`}
> >
{t("tabManage")} {t("tabCompare")}
</Link>
<Link
href="/admin/housekeeping?tab=import"
className={`admin-tab ${tab === "import" ? "active" : ""}`}
>
{t("tabImport")}
</Link> </Link>
<Link <Link
href="/admin/housekeeping?tab=ranks" href="/admin/housekeeping?tab=ranks"
@@ -148,16 +148,10 @@ export default async function AdminHousekeeping({
{t("tabRanks")} {t("tabRanks")}
</Link> </Link>
<Link <Link
href="/admin/housekeeping?tab=presets" href="/admin/housekeeping?tab=import"
className={`admin-tab ${tab === "presets" ? "active" : ""}`} className={`admin-tab ${tab === "import" ? "active" : ""}`}
> >
{t("tabPresets")} {t("tabExportOnly")}
</Link>
<Link
href="/admin/housekeeping?tab=test"
className={`admin-tab ${tab === "test" ? "active" : ""}`}
>
{t("tabTest")}
</Link> </Link>
<Link <Link
href="/admin/housekeeping?tab=audit" href="/admin/housekeeping?tab=audit"
@@ -183,16 +177,14 @@ export default async function AdminHousekeeping({
{t("groupCount", { count: Object.keys(groupCounts).length })} {t("groupCount", { count: Object.keys(groupCounts).length })}
</span> </span>
<span className="admin-stat-chip"> <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> </span>
{missingDeps.length > 0 && (
<span
className="admin-stat-chip"
style={{ color: "var(--color-danger)" }}
>
{t("missingDeps", { count: missingDeps.length })}
</span>
)}
</div> </div>
<section className="mb-6"> <section className="mb-6">
@@ -201,19 +193,12 @@ export default async function AdminHousekeeping({
{Object.entries(groupCounts) {Object.entries(groupCounts)
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.map(([group, count]) => ( .map(([group, count]) => (
<Link <div key={group} className="admin-card p-3">
key={group}
href={
"/admin/housekeeping?tab=manage&group=" +
encodeURIComponent(group)
}
className="admin-card block p-3 hover:opacity-80 transition-opacity"
>
<div className="font-semibold text-sm">{group}</div> <div className="font-semibold text-sm">{group}</div>
<div className="text-xs opacity-60"> <div className="text-xs opacity-60">
{count} {count === 1 ? t("permission") : t("permissions")} {count} {count === 1 ? t("permission") : t("permissions")}
</div> </div>
</Link> </div>
))} ))}
</div> </div>
</section> </section>
@@ -245,52 +230,60 @@ export default async function AdminHousekeeping({
</table> </table>
</div> </div>
</section> </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> &mdash; {t("notFound")}
</li>
))}
</ul>
</div>
</section>
)}
</> </>
)} )}
{tab === "manage" && ( {tab === "compare" && (
<ManageSection <section>
permissions={allPermissions} <h2 className="admin-section-title">{t("tabCompare")}</h2>
groups={allGroups} <p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
groupCounts={groupCounts} {t("compareHint")}
permSet={permSet} </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" && ( {tab === "import" && (
<> <div className="mt-2">
<ImportSection /> <p className="mb-4 text-sm text-[var(--admin-text-muted)]">
<div className="mt-8"> {t("exportOnlyHint")}
<ExportSection /> </p>
</div> <ExportSection />
</> </div>
)} )}
{tab === "presets" && <PresetsSection />}
{tab === "test" && <TestSection permissions={allPermissions} />}
{tab === "ranks" && ( {tab === "ranks" && (
<section> <section>
<h2 className="admin-section-title">{t("perRank")}</h2> <h2 className="admin-section-title">{t("perRank")}</h2>
+14 -5
View File
@@ -5,16 +5,20 @@ import { prisma } from "@/lib/prisma";
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks"; import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
import { PermissionsList } from "./permissions-list"; import { PermissionsList } from "./permissions-list";
export default async function PermissionsPage(_props: { export default async function PermissionsPage({
searchParams,
}: {
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
searchParams?: Promise<{ from?: string }>;
}) { }) {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if ( if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)) {
!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)
) {
redirect("/admin"); redirect("/admin");
} }
const sp = (await searchParams) ?? {};
const fromHousekeeping = sp.from === "housekeeping";
const ranks = await fetchEmulatorRankSummaries(prisma); const ranks = await fetchEmulatorRankSummaries(prisma);
// User counts per rank // User counts per rank
@@ -31,5 +35,10 @@ export default async function PermissionsPage(_props: {
userCount: countMap[rank.id] ?? 0, userCount: countMap[rank.id] ?? 0,
})); }));
return <PermissionsList ranks={ranksWithCounts} />; return (
<PermissionsList
ranks={ranksWithCounts}
fromHousekeeping={fromHousekeeping}
/>
);
} }
+16 -3
View File
@@ -42,6 +42,7 @@ interface RankSummary {
interface Props { interface Props {
ranks: RankSummary[]; ranks: RankSummary[];
fromHousekeeping?: boolean;
} }
const RANK_ICONS: Record<number, React.ReactNode> = { 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)]", 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 { run, isPending } = useServerAction();
const [ranks, setRanks] = useState(initialRanks); const [ranks, setRanks] = useState(initialRanks);
const [deleteTarget, setDeleteTarget] = useState<RankSummary | null>(null); const [deleteTarget, setDeleteTarget] = useState<RankSummary | null>(null);
@@ -98,14 +102,23 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
return ( return (
<div className="space-y-6"> <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 <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)]" 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" role="status"
> >
<p className="m-0 font-semibold">Live ACL</p> <p className="m-0 font-semibold">Live ACL</p>
<p className="m-0 mt-1 text-muted-foreground"> <p className="m-0 mt-1 text-muted-foreground">
Rank ACL here controls real admin access. The Housekeeping table is Rank ACL here controls real admin and mod access. The Housekeeping
legacy reference only and does not gate routes. table is a read-only legacy archive with an ACL compare view.
</p> </p>
</div> </div>
@@ -85,18 +85,29 @@ export function AdminTicketDetail({
staffMembers, staffMembers,
currentUserId, currentUserId,
canEdit, canEdit,
listHref = "/admin/tickets",
userLinkMode = "admin",
}: { }: {
ticket: TicketInfo; ticket: TicketInfo;
messages: TicketMessage[]; messages: TicketMessage[];
staffMembers: StaffMember[]; staffMembers: StaffMember[];
currentUserId: number; currentUserId: number;
canEdit: boolean; canEdit: boolean;
listHref?: string;
userLinkMode?: "admin" | "public";
}) { }) {
const [messages] = useState(initialMessages); const [messages] = useState(initialMessages);
const [reply, setReply] = useState(""); const [reply, setReply] = useState("");
const { run, isPending } = useServerAction(); const { run, isPending } = useServerAction();
const messagesEndRef = useRef<HTMLDivElement>(null); 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 // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -152,7 +163,7 @@ export function AdminTicketDetail({
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<Link href="/admin/tickets"> <Link href={listHref}>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
@@ -382,7 +393,7 @@ export function AdminTicketDetail({
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<Link <Link
href={`/admin/users/show/${ticket.creator.id}`} href={creatorHref()}
className="flex items-center gap-2 text-sm font-medium hover:text-primary" className="flex items-center gap-2 text-sm font-medium hover:text-primary"
> >
<User className="h-3.5 w-3.5" /> <User className="h-3.5 w-3.5" />
+8 -2
View File
@@ -29,7 +29,13 @@ const STATUS_VARIANT: Record<
closed: "secondary", 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 t = useTranslations("pages.admin.tickets");
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
@@ -51,7 +57,7 @@ export function TicketsTable({ data }: { data: PaginatedResult<TicketRow> }) {
#{row.id} #{row.id}
</span> </span>
<Link <Link
href={`/admin/tickets/${row.id}`} href={`${basePath}/${row.id}`}
className="font-medium truncate hover:underline" className="font-medium truncate hover:underline"
> >
{row.subject} {row.subject}
+14 -1
View File
@@ -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 Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
@@ -42,11 +42,23 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
const showBans = const showBans =
canAccess(permissions, PERMS.MOD_BANS_VIEW, staff.rank) || canAccess(permissions, PERMS.MOD_BANS_VIEW, staff.rank) ||
canAccess(permissions, PERMS.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 showAdmin = canAccess(permissions, PERMS.ADMIN_DASHBOARD, staff.rank);
const nav = [ const nav = [
{ href: "/mod", label: t("nav.overview"), icon: Shield, show: true }, { href: "/mod", label: t("nav.overview"), icon: Shield, show: true },
{ href: "/mod/cfh", label: t("nav.cfh"), icon: Radio, show: showCfh }, { 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", href: "/mod/actions",
label: t("nav.actions"), label: t("nav.actions"),
@@ -54,6 +66,7 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
show: showActions, show: showActions,
}, },
{ href: "/mod/bans", label: t("nav.bans"), icon: Ban, show: showBans }, { 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); ].filter((item) => item.show);
return ( return (
+49 -3
View File
@@ -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 Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { StatsCard } from "@/components/admin/stats-card"; import { StatsCard } from "@/components/admin/stats-card";
@@ -23,8 +23,14 @@ export default async function ModDashboardPage() {
const showBans = const showBans =
canAccess(permissions, PERMS.MOD_BANS_VIEW, rank) || canAccess(permissions, PERMS.MOD_BANS_VIEW, rank) ||
canAccess(permissions, PERMS.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 showCfh
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0) ? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
: Promise.resolve(0), : Promise.resolve(0),
@@ -36,6 +42,11 @@ export default async function ModDashboardPage() {
prisma.user prisma.user
.count({ where: { online: "1", rank: { gte: minStaffRank } } }) .count({ where: { online: "1", rank: { gte: minStaffRank } } })
.catch(() => 0), .catch(() => 0),
showTickets
? prisma.websiteTicket
.count({ where: { status: { not: "closed" } } })
.catch(() => 0)
: Promise.resolve(0),
]); ]);
return ( return (
@@ -47,7 +58,7 @@ export default async function ModDashboardPage() {
</p> </p>
</div> </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 ? ( {showCfh ? (
<StatsCard <StatsCard
title={t("stats.openCfh")} title={t("stats.openCfh")}
@@ -56,6 +67,14 @@ export default async function ModDashboardPage() {
href="/mod/cfh" href="/mod/cfh"
/> />
) : null} ) : null}
{showTickets ? (
<StatsCard
title={t("stats.openTickets")}
value={openTickets}
icon={<Ticket className="h-4 w-4" />}
href="/mod/tickets"
/>
) : null}
{showBans ? ( {showBans ? (
<StatsCard <StatsCard
title={t("stats.bansToday")} title={t("stats.bansToday")}
@@ -68,6 +87,7 @@ export default async function ModDashboardPage() {
title={t("stats.modsOnline")} title={t("stats.modsOnline")}
value={modsOnline} value={modsOnline}
icon={<Shield className="h-4 w-4" />} icon={<Shield className="h-4 w-4" />}
href={showTeam ? "/mod/team" : undefined}
/> />
{showActions ? ( {showActions ? (
<StatsCard <StatsCard
@@ -78,6 +98,15 @@ export default async function ModDashboardPage() {
href="/mod/actions" href="/mod/actions"
/> />
) : null} ) : null}
{showTeam ? (
<StatsCard
title={t("stats.team")}
value="→"
description={t("stats.teamHint")}
icon={<Users className="h-4 w-4" />}
href="/mod/team"
/>
) : null}
</div> </div>
<section className="admin-card space-y-2 p-4"> <section className="admin-card space-y-2 p-4">
@@ -90,6 +119,16 @@ export default async function ModDashboardPage() {
</Link> </Link>
</li> </li>
) : null} ) : null}
{showTickets ? (
<li>
<Link
href="/mod/tickets"
className="text-primary hover:underline"
>
{t("nav.tickets")}
</Link>
</li>
) : null}
{showActions ? ( {showActions ? (
<li> <li>
<Link <Link
@@ -107,6 +146,13 @@ export default async function ModDashboardPage() {
</Link> </Link>
</li> </li>
) : null} ) : null}
{showTeam ? (
<li>
<Link href="/mod/team" className="text-primary hover:underline">
{t("nav.team")}
</Link>
</li>
) : null}
</ul> </ul>
</section> </section>
</div> </div>
+149
View File
@@ -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>
);
}
+101
View File
@@ -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"
/>
);
}
+155
View File
@@ -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>
);
}
+4 -1
View File
@@ -70,7 +70,10 @@ export async function requireMod(): Promise<StaffUser> {
canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank) || canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank) ||
canAccess(permissions, PERMS.MOD_CFH_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_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("/", "/"); if (!ok) redirectSafe("/", "/");
return { return {
id: session.user.id, id: session.user.id,
+22 -3
View File
@@ -2726,7 +2726,21 @@
"importTitle": "Import permissions", "importTitle": "Import permissions",
"legacyBannerTitle": "This table is not live ACL", "legacyBannerTitle": "This table is not live ACL",
"legacyBannerBody": "Runtime access uses the ACL permissions system. Use Housekeeping only as a legacy reference.", "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": { "rooms": {
"title": "Rooms", "title": "Rooms",
@@ -3028,14 +3042,19 @@
"actions": "Quick actions", "actions": "Quick actions",
"bans": "Bans", "bans": "Bans",
"fullAdmin": "Full admin", "fullAdmin": "Full admin",
"backToSite": "Back to site" "backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team"
}, },
"stats": { "stats": {
"openCfh": "Open CFH", "openCfh": "Open CFH",
"bansToday": "Bans today", "bansToday": "Bans today",
"modsOnline": "Mods online", "modsOnline": "Mods online",
"quickActions": "Quick actions", "quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert" "quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload"
} }
} }
} }
+22 -3
View File
@@ -2573,7 +2573,21 @@
"importTitle": "Import permissions", "importTitle": "Import permissions",
"legacyBannerTitle": "Questa tabella non e ACL live", "legacyBannerTitle": "Questa tabella non e ACL live",
"legacyBannerBody": "L'accesso runtime usa il sistema ACL. Usa Housekeeping solo come riferimento legacy.", "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": { "rooms": {
"title": "Stanze", "title": "Stanze",
@@ -3031,14 +3045,19 @@
"actions": "Azioni rapide", "actions": "Azioni rapide",
"bans": "Ban", "bans": "Ban",
"fullAdmin": "Admin completo", "fullAdmin": "Admin completo",
"backToSite": "Torna al sito" "backToSite": "Torna al sito",
"tickets": "Ticket",
"team": "Team"
}, },
"stats": { "stats": {
"openCfh": "CFH aperti", "openCfh": "CFH aperti",
"bansToday": "Ban oggi", "bansToday": "Ban oggi",
"modsOnline": "Mod online", "modsOnline": "Mod online",
"quickActions": "Azioni rapide", "quickActions": "Azioni rapide",
"quickActionsHint": "Kick, mute, alert" "quickActionsHint": "Kick, mute, alert",
"openTickets": "Ticket aperti",
"team": "Team mod",
"teamHint": "Staff online e carico"
} }
} }
} }
+22 -3
View File
@@ -2548,7 +2548,21 @@
"importTitle": "Permissies importeren", "importTitle": "Permissies importeren",
"legacyBannerTitle": "Deze tabel is geen live ACL", "legacyBannerTitle": "Deze tabel is geen live ACL",
"legacyBannerBody": "Runtime-toegang gebruikt het ACL-systeem. Gebruik Housekeeping alleen als legacy-referentie.", "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": { "rooms": {
"title": "Kamers", "title": "Kamers",
@@ -3029,14 +3043,19 @@
"actions": "Snelle acties", "actions": "Snelle acties",
"bans": "Bans", "bans": "Bans",
"fullAdmin": "Volledige admin", "fullAdmin": "Volledige admin",
"backToSite": "Terug naar site" "backToSite": "Terug naar site",
"tickets": "Tickets",
"team": "Team"
}, },
"stats": { "stats": {
"openCfh": "Open CFH", "openCfh": "Open CFH",
"bansToday": "Bans vandaag", "bansToday": "Bans vandaag",
"modsOnline": "Mods online", "modsOnline": "Mods online",
"quickActions": "Snelle acties", "quickActions": "Snelle acties",
"quickActionsHint": "Kick, mute, alert" "quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & werklast"
} }
} }
} }