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";
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
View File
@@ -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 },