diff --git a/src/actions/admin-housekeeping.ts b/src/actions/admin-housekeeping.ts index 5b841d99bd..097881864e 100644 --- a/src/actions/admin-housekeeping.ts +++ b/src/actions/admin-housekeeping.ts @@ -1,194 +1,31 @@ "use server"; -import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; -import { auth } from "@/lib/auth"; -import { sessionUserId } from "@/lib/auth/session-user"; +import { redirectSafe } from "@/lib/foundation/security"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; -import { logAudit } from "@/lib/services/audit"; -function sanitize(str: string, max = 255): string { - return String(str ?? "") - .normalize("NFC") - .trim() - .slice(0, max); -} - -async function currentUserId(): Promise { - const session = await auth(); - return session?.user ? sessionUserId(session.user.id) : null; -} - -export async function upsertPermission(formData: FormData): Promise { +/** + * Housekeeping table writes are retired. Runtime access uses ACL only. + * Redirect editors to live Permissions. + */ +async function rejectLegacyHousekeepingWrite(): Promise { await requirePermission(PERMS.SETTINGS_EDIT); - - const permission = sanitize(String(formData.get("permission") ?? "")); - const minRank = Number(formData.get("minRank")); - const descriptionRaw = sanitize(String(formData.get("description") ?? "")); - const description = descriptionRaw.length > 0 ? descriptionRaw : null; - const groupName = - sanitize(String(formData.get("groupName") ?? ""), 100) || null; - const dependsOn = sanitize(String(formData.get("dependsOn") ?? "")) || null; - - if (!permission || !Number.isFinite(minRank) || minRank < 0) return; - - const userId = await currentUserId(); - - try { - const existing = await prisma.websiteHousekeepingPermissions.findUnique({ - where: { permission }, - }); - - if (existing) { - const before = { - minRank: existing.minRank, - description: existing.description, - groupName: existing.groupName, - dependsOn: existing.dependsOn, - }; - await prisma.websiteHousekeepingPermissions.update({ - where: { permission }, - data: { - minRank, - description, - groupName, - dependsOn, - updatedBy: userId ?? undefined, - }, - }); - await logAudit({ - userId: userId ?? 0, - action: "housekeeping.permission.update", - target: "WebsiteHousekeepingPermissions", - targetId: Number(existing.id), - before, - after: { minRank, description, groupName, dependsOn }, - }); - } else { - await prisma.websiteHousekeepingPermissions.create({ - data: { - permission, - minRank, - description, - groupName, - dependsOn, - updatedBy: userId ?? undefined, - }, - }); - await logAudit({ - userId: userId ?? 0, - action: "housekeeping.permission.create", - target: "WebsiteHousekeepingPermissions", - targetId: undefined, - after: { permission, minRank, description, groupName, dependsOn }, - }); - } - } catch { - // Swallow - } - - revalidatePath("/admin/housekeeping"); + redirectSafe("/admin/permissions?from=housekeeping", "/admin/permissions"); } -export async function deletePermission(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); +export async function upsertPermission(_formData: FormData): Promise { + await rejectLegacyHousekeepingWrite(); +} - const raw = String(formData.get("id") ?? "").normalize("NFC"); - if (!raw) return; - - const userId = await currentUserId(); - - try { - const existing = await prisma.websiteHousekeepingPermissions.findUnique({ - where: { id: BigInt(raw) }, - }); - if (existing) { - await prisma.websiteHousekeepingPermissions.delete({ - where: { id: BigInt(raw) }, - }); - await logAudit({ - userId: userId ?? 0, - action: "housekeeping.permission.delete", - target: "WebsiteHousekeepingPermissions", - targetId: Number(existing.id), - before: { - permission: existing.permission, - minRank: existing.minRank, - description: existing.description, - groupName: existing.groupName, - }, - }); - } - } catch { - // Already gone / invalid id. - } - - revalidatePath("/admin/housekeeping"); +export async function deletePermission(_formData: FormData): Promise { + await rejectLegacyHousekeepingWrite(); } export async function bulkImportPermissions( - formData: FormData, + _formData: FormData, ): Promise<{ count: number; errors: string[] }> { - await requirePermission(PERMS.SETTINGS_EDIT); - - const raw = sanitize(String(formData.get("json") ?? "")); - if (!raw) return { count: 0, errors: ["Empty input"] }; - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return { count: 0, errors: ["Invalid JSON"] }; - } - - if (!Array.isArray(parsed)) { - return { count: 0, errors: ["Expected an array"] }; - } - - const userId = await currentUserId(); - const errors: string[] = []; - let count = 0; - - for (const item of parsed) { - const permission = sanitize(String(item.permission ?? "")); - const minRank = Number(item.minRank); - const description = sanitize(String(item.description ?? "")) || null; - const groupName = sanitize(String(item.groupName ?? ""), 100) || null; - const dependsOn = sanitize(String(item.dependsOn ?? "")) || null; - - if (!permission || !Number.isFinite(minRank) || minRank < 0) { - errors.push(`Invalid entry: ${JSON.stringify(item)}`); - continue; - } - - try { - await prisma.websiteHousekeepingPermissions.upsert({ - where: { permission }, - update: { - minRank, - description, - groupName, - dependsOn, - updatedBy: userId ?? undefined, - }, - create: { - permission, - minRank, - description, - groupName, - dependsOn, - updatedBy: userId ?? undefined, - }, - }); - count++; - } catch { - errors.push(`Failed to upsert: ${permission}`); - } - } - - revalidatePath("/admin/housekeeping"); - return { count, errors }; + await rejectLegacyHousekeepingWrite(); } export async function exportPermissions(): Promise { @@ -208,688 +45,16 @@ export async function exportPermissions(): Promise { return JSON.stringify(perms, null, 2); } -export async function applyPreset(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); - - const preset = String(formData.get("preset") ?? "").trim(); - const userId = await currentUserId(); - - const presets: Record< - string, - Array<{ - permission: string; - minRank: number; - description?: string; - groupName?: string; - }> - > = { - moderator: [ - { - permission: "admin.dashboard", - minRank: 5, - description: "Access admin dashboard", - groupName: "Core", - }, - { - permission: "admin.users.view", - minRank: 5, - description: "View user profiles", - groupName: "Users", - }, - { - permission: "admin.users.ban", - minRank: 5, - description: "Ban users", - groupName: "Users", - }, - { - permission: "admin.room.view", - minRank: 5, - description: "View rooms", - groupName: "Rooms", - }, - { - permission: "admin.bans.view", - minRank: 5, - description: "View bans", - groupName: "Bans", - }, - { - permission: "admin.logs.view", - minRank: 5, - description: "View logs", - groupName: "Logs", - }, - { - permission: "admin.wordfilter.view", - minRank: 5, - description: "View wordfilter", - groupName: "Wordfilter", - }, - { - permission: "mod.dashboard", - minRank: 5, - description: "Mod dashboard", - groupName: "Moderation", - }, - { - permission: "mod.cfh.view", - minRank: 5, - description: "View CFH", - groupName: "Moderation", - }, - { - permission: "mod.cfh.edit", - minRank: 5, - description: "Handle CFH", - groupName: "Moderation", - }, - { - permission: "mod.actions", - minRank: 5, - description: "Mod actions", - groupName: "Moderation", - }, - { - permission: "mod.users.view", - minRank: 5, - description: "View users (mod)", - groupName: "Moderation", - }, - { - permission: "mod.bans.view", - minRank: 5, - description: "View bans (mod)", - groupName: "Moderation", - }, - ], - admin: [ - { - permission: "admin.dashboard", - minRank: 7, - description: "Access admin dashboard", - groupName: "Core", - }, - { - permission: "admin.users.view", - minRank: 7, - description: "View user profiles", - groupName: "Users", - }, - { - permission: "admin.users.edit", - minRank: 7, - description: "Edit users", - groupName: "Users", - }, - { - permission: "admin.users.ban", - minRank: 7, - description: "Ban users", - groupName: "Users", - }, - { - permission: "admin.users.reset_password", - minRank: 7, - description: "Reset passwords", - groupName: "Users", - }, - { - permission: "admin.room.view", - minRank: 7, - description: "View rooms", - groupName: "Rooms", - }, - { - permission: "admin.room.edit", - minRank: 7, - description: "Edit rooms", - groupName: "Rooms", - }, - { - permission: "admin.room.delete", - minRank: 7, - description: "Delete rooms", - groupName: "Rooms", - }, - { - permission: "admin.bans.view", - minRank: 7, - description: "View bans", - groupName: "Bans", - }, - { - permission: "admin.news.view", - minRank: 7, - description: "View news", - groupName: "News", - }, - { - permission: "admin.news.edit", - minRank: 7, - description: "Edit news", - groupName: "News", - }, - { - permission: "admin.logs.view", - minRank: 7, - description: "View logs", - groupName: "Logs", - }, - { - permission: "admin.settings.view", - minRank: 7, - description: "View settings", - groupName: "Settings", - }, - { - permission: "admin.wordfilter.view", - minRank: 7, - description: "View wordfilter", - groupName: "Wordfilter", - }, - { - permission: "admin.wordfilter.edit", - minRank: 7, - description: "Edit wordfilter", - groupName: "Wordfilter", - }, - { - permission: "admin.catalog.view", - minRank: 7, - description: "View catalog", - groupName: "Catalog", - }, - { - permission: "admin.shop.view", - minRank: 7, - description: "View shop", - groupName: "Shop", - }, - { - permission: "admin.events.view", - minRank: 7, - description: "View events", - groupName: "Events", - }, - { - permission: "admin.tickets.view", - minRank: 7, - description: "View tickets", - groupName: "Tickets", - }, - { - permission: "admin.moderation.view", - minRank: 7, - description: "View moderation", - groupName: "Moderation", - }, - { - permission: "admin.polls.view", - minRank: 7, - description: "View polls", - groupName: "Polls", - }, - { - permission: "admin.analytics.view", - minRank: 7, - description: "View analytics", - groupName: "Analytics", - }, - { - permission: "admin.prefixes.view", - minRank: 7, - description: "View prefixes", - groupName: "Prefixes", - }, - { - permission: "admin.banners.view", - minRank: 7, - description: "View banners", - groupName: "Banners", - }, - { - permission: "admin.radio.view", - minRank: 7, - description: "View radio", - groupName: "Radio", - }, - { - permission: "admin.rcon.execute", - minRank: 7, - description: "Execute RCON", - groupName: "RCON", - }, - { - permission: "mod.dashboard", - minRank: 7, - description: "Mod dashboard", - groupName: "Moderation", - }, - { - permission: "mod.cfh.view", - minRank: 7, - description: "View CFH", - groupName: "Moderation", - }, - { - permission: "mod.cfh.edit", - minRank: 7, - description: "Handle CFH", - groupName: "Moderation", - }, - { - permission: "mod.actions", - minRank: 7, - description: "Mod actions", - groupName: "Moderation", - }, - ], - superadmin: [ - { - permission: "admin.dashboard", - minRank: 8, - description: "Access admin dashboard", - groupName: "Core", - }, - { - permission: "admin.users.view", - minRank: 8, - description: "View user profiles", - groupName: "Users", - }, - { - permission: "admin.users.edit", - minRank: 8, - description: "Edit users", - groupName: "Users", - }, - { - permission: "admin.users.ban", - minRank: 8, - description: "Ban users", - groupName: "Users", - }, - { - permission: "admin.users.reset_password", - minRank: 8, - description: "Reset passwords", - groupName: "Users", - }, - { - permission: "admin.room.view", - minRank: 8, - description: "View rooms", - groupName: "Rooms", - }, - { - permission: "admin.room.edit", - minRank: 8, - description: "Edit rooms", - groupName: "Rooms", - }, - { - permission: "admin.room.delete", - minRank: 8, - description: "Delete rooms", - groupName: "Rooms", - }, - { - permission: "admin.bans.view", - minRank: 8, - description: "View bans", - groupName: "Bans", - }, - { - permission: "admin.news.view", - minRank: 8, - description: "View news", - groupName: "News", - }, - { - permission: "admin.news.edit", - minRank: 8, - description: "Edit news", - groupName: "News", - }, - { - permission: "admin.logs.view", - minRank: 8, - description: "View logs", - groupName: "Logs", - }, - { - permission: "admin.settings.view", - minRank: 8, - description: "View settings", - groupName: "Settings", - }, - { - permission: "admin.settings.edit", - minRank: 8, - description: "Edit settings", - groupName: "Settings", - }, - { - permission: "admin.permissions.manage", - minRank: 8, - description: "Manage permissions", - groupName: "Settings", - }, - { - permission: "admin.wordfilter.view", - minRank: 8, - description: "View wordfilter", - groupName: "Wordfilter", - }, - { - permission: "admin.wordfilter.edit", - minRank: 8, - description: "Edit wordfilter", - groupName: "Wordfilter", - }, - { - permission: "admin.catalog.view", - minRank: 8, - description: "View catalog", - groupName: "Catalog", - }, - { - permission: "admin.catalog.edit", - minRank: 8, - description: "Edit catalog", - groupName: "Catalog", - }, - { - permission: "admin.shop.view", - minRank: 8, - description: "View shop", - groupName: "Shop", - }, - { - permission: "admin.shop.edit", - minRank: 8, - description: "Edit shop", - groupName: "Shop", - }, - { - permission: "admin.events.view", - minRank: 8, - description: "View events", - groupName: "Events", - }, - { - permission: "admin.events.edit", - minRank: 8, - description: "Edit events", - groupName: "Events", - }, - { - permission: "admin.tickets.view", - minRank: 8, - description: "View tickets", - groupName: "Tickets", - }, - { - permission: "admin.tickets.edit", - minRank: 8, - description: "Edit tickets", - groupName: "Tickets", - }, - { - permission: "admin.moderation.view", - minRank: 8, - description: "View moderation", - groupName: "Moderation", - }, - { - permission: "admin.moderation.edit", - minRank: 8, - description: "Edit moderation", - groupName: "Moderation", - }, - { - permission: "admin.polls.view", - minRank: 8, - description: "View polls", - groupName: "Polls", - }, - { - permission: "admin.polls.edit", - minRank: 8, - description: "Edit polls", - groupName: "Polls", - }, - { - permission: "admin.analytics.view", - minRank: 8, - description: "View analytics", - groupName: "Analytics", - }, - { - permission: "admin.analytics.export", - minRank: 8, - description: "Export analytics", - groupName: "Analytics", - }, - { - permission: "admin.devops.view", - minRank: 8, - description: "View devops", - groupName: "DevOps", - }, - { - permission: "admin.devops.edit", - minRank: 8, - description: "Edit devops", - groupName: "DevOps", - }, - { - permission: "admin.prefixes.view", - minRank: 8, - description: "View prefixes", - groupName: "Prefixes", - }, - { - permission: "admin.prefixes.edit", - minRank: 8, - description: "Edit prefixes", - groupName: "Prefixes", - }, - { - permission: "admin.banners.view", - minRank: 8, - description: "View banners", - groupName: "Banners", - }, - { - permission: "admin.banners.edit", - minRank: 8, - description: "Edit banners", - groupName: "Banners", - }, - { - permission: "admin.radio.view", - minRank: 8, - description: "View radio", - groupName: "Radio", - }, - { - permission: "admin.radio.edit", - minRank: 8, - description: "Edit radio", - groupName: "Radio", - }, - { - permission: "admin.rcon.execute", - minRank: 8, - description: "Execute RCON", - groupName: "RCON", - }, - { - permission: "admin.assets.import", - minRank: 8, - description: "Import assets", - groupName: "Assets", - }, - { - permission: "admin.export", - minRank: 8, - description: "Export data", - groupName: "Export", - }, - { - permission: "admin.notifications.view", - minRank: 8, - description: "View notifications", - groupName: "Notifications", - }, - { - permission: "admin.notifications.edit", - minRank: 8, - description: "Edit notifications", - groupName: "Notifications", - }, - { - permission: "admin.pages.view", - minRank: 8, - description: "View CMS pages", - groupName: "CMS Pages", - }, - { - permission: "admin.pages.edit", - minRank: 8, - description: "Edit CMS pages", - groupName: "CMS Pages", - }, - { - permission: "mod.dashboard", - minRank: 8, - description: "Mod dashboard", - groupName: "Moderation", - }, - { - permission: "mod.cfh.view", - minRank: 8, - description: "View CFH", - groupName: "Moderation", - }, - { - permission: "mod.cfh.edit", - minRank: 8, - description: "Handle CFH", - groupName: "Moderation", - }, - { - permission: "mod.actions", - minRank: 8, - description: "Mod actions", - groupName: "Moderation", - }, - { - permission: "mod.team.view", - minRank: 8, - description: "View mod team", - groupName: "Moderation", - }, - { - permission: "mod.tickets.view", - minRank: 8, - description: "View mod tickets", - groupName: "Moderation", - }, - { - permission: "mod.tickets.edit", - minRank: 8, - description: "Edit mod tickets", - groupName: "Moderation", - }, - { - permission: "mod.users.view", - minRank: 8, - description: "View users (mod)", - groupName: "Moderation", - }, - { - permission: "mod.bans.view", - minRank: 8, - description: "View bans (mod)", - groupName: "Moderation", - }, - ], - }; - - const selected = presets[preset]; - if (!selected) return; - - for (const item of selected) { - const { permission, minRank, description, groupName } = item; - await prisma.websiteHousekeepingPermissions.upsert({ - where: { permission }, - update: { - minRank, - description, - groupName, - updatedBy: userId ?? undefined, - }, - create: { - permission, - minRank, - description, - groupName, - updatedBy: userId ?? undefined, - }, - }); - } - - await logAudit({ - userId: userId ?? 0, - action: "housekeeping.preset.apply", - target: "WebsiteHousekeepingPermissions", - after: { preset, count: selected.length }, - }); - - revalidatePath("/admin/housekeeping"); +export async function applyPreset(_formData: FormData): Promise { + await rejectLegacyHousekeepingWrite(); } export async function clearAllPermissions(): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); - - const userId = await currentUserId(); - const count = await prisma.websiteHousekeepingPermissions.count(); - - await prisma.websiteHousekeepingPermissions.deleteMany(); - - await logAudit({ - userId: userId ?? 0, - action: "housekeeping.permission.clear_all", - target: "WebsiteHousekeepingPermissions", - after: { deletedCount: count }, - }); - - revalidatePath("/admin/housekeeping"); + await rejectLegacyHousekeepingWrite(); } export async function bulkDeletePermissions( - ids: bigint[], + _ids: bigint[], ): Promise<{ count: number }> { - await requirePermission(PERMS.SETTINGS_EDIT); - - if (!ids.length) return { count: 0 }; - - const userId = await currentUserId(); - - await prisma.websiteHousekeepingPermissions.deleteMany({ - where: { id: { in: ids } }, - }); - - await logAudit({ - userId: userId ?? 0, - action: "housekeeping.permission.bulk_delete", - target: "WebsiteHousekeepingPermissions", - after: { deletedCount: ids.length }, - }); - - revalidatePath("/admin/housekeeping"); - return { count: ids.length }; + await rejectLegacyHousekeepingWrite(); } diff --git a/src/actions/tickets.ts b/src/actions/tickets.ts index 4d67e013c5..81fa9abf7c 100644 --- a/src/actions/tickets.ts +++ b/src/actions/tickets.ts @@ -121,7 +121,10 @@ export const closeTicketByUser = authAction( // ── Admin actions ──────────────────────────────────────────────────── export const adminReplyTicket = adminAction( - { permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema }, + { + permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT], + schema: replyTicketSchema, + }, async (ctx) => { const ticket = await prisma.websiteTicket.findUnique({ where: { id: ctx.data.ticketId }, @@ -161,7 +164,10 @@ export const adminReplyTicket = adminAction( ); export const updateTicketStatus = adminAction( - { permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema }, + { + permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT], + schema: updateTicketStatusSchema, + }, async (ctx) => { const ticket = await prisma.websiteTicket.findUnique({ where: { id: ctx.data.ticketId }, @@ -196,7 +202,10 @@ export const updateTicketStatus = adminAction( ); export const assignTicket = adminAction( - { permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema }, + { + permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT], + schema: assignTicketSchema, + }, async (ctx) => { const ticket = await prisma.websiteTicket.findUnique({ where: { id: ctx.data.ticketId }, @@ -226,7 +235,10 @@ export const assignTicket = adminAction( ); export const updateTicketPriority = adminAction( - { permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema }, + { + permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT], + schema: updateTicketPrioritySchema, + }, async (ctx) => { const ticket = await prisma.websiteTicket.findUnique({ where: { id: ctx.data.ticketId }, diff --git a/src/app/admin/housekeeping/page.tsx b/src/app/admin/housekeeping/page.tsx index 6646bfb6fe..def8e48ce4 100644 --- a/src/app/admin/housekeeping/page.tsx +++ b/src/app/admin/housekeeping/page.tsx @@ -4,44 +4,10 @@ import { getTranslations } from "next-intl/server"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { getAuditLogs } from "@/lib/services/audit"; -import { - AuditSection, - ExportSection, - ImportSection, - ManageSection, - PresetsSection, - TestSection, -} from "./client"; +import { AuditSection, ExportSection } from "./client"; export const dynamic = "force-dynamic"; -const GROUPS = [ - "Core", - "Users", - "Rooms", - "Bans", - "News", - "Logs", - "Settings", - "Wordfilter", - "Catalog", - "Shop", - "Events", - "Tickets", - "Moderation", - "Polls", - "Analytics", - "DevOps", - "Prefixes", - "Banners", - "Radio", - "RCON", - "Assets", - "Export", - "Notifications", - "CMS Pages", -]; - export default async function AdminHousekeeping({ searchParams, }: { @@ -53,7 +19,8 @@ export default async function AdminHousekeeping({ | "ranks" | "presets" | "test" - | "audit"; + | "audit" + | "compare"; group?: string; }>; }) { @@ -64,7 +31,10 @@ export default async function AdminHousekeeping({ const t = await getTranslations("pages.admin.housekeeping"); const sp = await searchParams; - const tab = sp.tab ?? "overview"; + let tab = sp.tab ?? "overview"; + if (tab === "manage" || tab === "presets" || tab === "test") { + tab = "compare"; + } type HKPerm = Awaited< ReturnType @@ -89,15 +59,48 @@ export default async function AdminHousekeeping({ rankGroups[p.minRank].push(p); } - const missingDeps: Array<{ permission: string; dependsOn: string }> = []; - const permSet = new Set(allPermissions.map((p) => p.permission)); - for (const p of allPermissions) { - if (p.dependsOn && !permSet.has(p.dependsOn)) { - missingDeps.push({ permission: p.permission, dependsOn: p.dependsOn }); - } + const aclSlugs = await prisma.aclPermission + .findMany({ select: { slug: true } }) + .catch(() => [] as { slug: string }[]); + const aclSlugSet = new Set(aclSlugs.map((p) => p.slug)); + + const aclRolePerms = await prisma.aclModelPermission + .findMany({ + where: { modelType: "Role" }, + include: { + permission: { select: { slug: true } }, + role: { select: { slug: true } }, + }, + }) + .catch(() => []); + + const aclMinRankBySlug = new Map(); + for (const row of aclRolePerms) { + const roleSlug = row.role.slug; + if (!roleSlug.startsWith("rank_")) continue; + const rank = Number(roleSlug.slice(5)); + if (!Number.isFinite(rank)) continue; + const slug = row.permission.slug; + const prev = aclMinRankBySlug.get(slug); + if (prev === undefined || rank < prev) aclMinRankBySlug.set(slug, rank); } - const allGroups = [...new Set([...GROUPS, ...Object.keys(groupCounts)])]; + const compareRows = allPermissions.map((p) => { + const inAcl = aclSlugSet.has(p.permission); + const aclMin = aclMinRankBySlug.get(p.permission); + return { + permission: p.permission, + hkMinRank: p.minRank, + inAcl, + aclMinRank: aclMin ?? null, + aligned: inAcl && aclMin !== undefined ? aclMin === p.minRank : false, + }; + }); + const missingInAcl = compareRows.filter((r) => !r.inAcl).length; + const rankMismatch = compareRows.filter( + (r) => r.inAcl && r.aclMinRank !== null && r.aclMinRank !== r.hkMinRank, + ).length; + const alignedCount = compareRows.filter((r) => r.aligned).length; const auditLogs = await getAuditLogs({ search: "housekeeping", @@ -120,6 +123,9 @@ export default async function AdminHousekeeping({ {t("legacyBannerCta")}

+

+ {t("writesRetired")} +

@@ -130,16 +136,10 @@ export default async function AdminHousekeeping({ {t("tabOverview")} - {t("tabManage")} - - - {t("tabImport")} + {t("tabCompare")} - {t("tabPresets")} - - - {t("tabTest")} + {t("tabExportOnly")} - {t("rankCount", { count: Object.keys(rankGroups).length })} + {t("compareAligned", { count: alignedCount })} + + + {t("compareMissingAcl", { count: missingInAcl })} + + + {t("compareMismatch", { count: rankMismatch })} - {missingDeps.length > 0 && ( - - {t("missingDeps", { count: missingDeps.length })} - - )}
@@ -201,19 +193,12 @@ export default async function AdminHousekeeping({ {Object.entries(groupCounts) .sort(([a], [b]) => a.localeCompare(b)) .map(([group, count]) => ( - +
{group}
{count} {count === 1 ? t("permission") : t("permissions")}
- +
))}
@@ -245,52 +230,60 @@ export default async function AdminHousekeeping({ - - {missingDeps.length > 0 && ( -
-

- {t("missingDeps")} -

-
-
    - {missingDeps.map((d) => ( -
  • - {d.permission} {t("dependsOn")}{" "} - {d.dependsOn} — {t("notFound")} -
  • - ))} -
-
-
- )} )} - {tab === "manage" && ( - + {tab === "compare" && ( +
+

{t("tabCompare")}

+

+ {t("compareHint")} +

+
+ + + + + + + + + + + + {compareRows.map((row) => { + const status = !row.inAcl + ? t("statusMissingAcl") + : row.aligned + ? t("statusAligned") + : t("statusMismatch"); + return ( + + + + + + + + ); + })} + +
{t("colPermission")}{t("colMinRank")}{t("colAclPresent")}{t("colAclMinRank")}{t("colStatus")}
+ {row.permission} + {row.hkMinRank}{row.inAcl ? "✓" : "—"}{row.aclMinRank ?? "—"}{status}
+
+
)} {tab === "import" && ( - <> - -
- -
- +
+

+ {t("exportOnlyHint")} +

+ +
)} - {tab === "presets" && } - - {tab === "test" && } - {tab === "ranks" && (

{t("perRank")}

diff --git a/src/app/admin/permissions/page.tsx b/src/app/admin/permissions/page.tsx index 3ab1da5fbc..4a260617f3 100644 --- a/src/app/admin/permissions/page.tsx +++ b/src/app/admin/permissions/page.tsx @@ -5,16 +5,20 @@ import { prisma } from "@/lib/prisma"; import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks"; import { PermissionsList } from "./permissions-list"; -export default async function PermissionsPage(_props: { +export default async function PermissionsPage({ + searchParams, +}: { params: Promise<{ locale: string }>; + searchParams?: Promise<{ from?: string }>; }) { const { session, permissions } = await getAdminContext(); - if ( - !canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank) - ) { + if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)) { redirect("/admin"); } + const sp = (await searchParams) ?? {}; + const fromHousekeeping = sp.from === "housekeeping"; + const ranks = await fetchEmulatorRankSummaries(prisma); // User counts per rank @@ -31,5 +35,10 @@ export default async function PermissionsPage(_props: { userCount: countMap[rank.id] ?? 0, })); - return ; + return ( + + ); } diff --git a/src/app/admin/permissions/permissions-list.tsx b/src/app/admin/permissions/permissions-list.tsx index 5230ae235c..aef41e0c04 100644 --- a/src/app/admin/permissions/permissions-list.tsx +++ b/src/app/admin/permissions/permissions-list.tsx @@ -42,6 +42,7 @@ interface RankSummary { interface Props { ranks: RankSummary[]; + fromHousekeeping?: boolean; } const RANK_ICONS: Record = { @@ -58,7 +59,10 @@ const RANK_COLORS: Record = { 4: "border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]", }; -export function PermissionsList({ ranks: initialRanks }: Props) { +export function PermissionsList({ + ranks: initialRanks, + fromHousekeeping = false, +}: Props) { const { run, isPending } = useServerAction(); const [ranks, setRanks] = useState(initialRanks); const [deleteTarget, setDeleteTarget] = useState(null); @@ -98,14 +102,23 @@ export function PermissionsList({ ranks: initialRanks }: Props) { return (
+ {fromHousekeeping ? ( +
+ Housekeeping writes are retired. Edit live ACL ranks and permissions + here. +
+ ) : null}

Live ACL

- Rank ACL here controls real admin access. The Housekeeping table is - legacy reference only and does not gate routes. + Rank ACL here controls real admin and mod access. The Housekeeping + table is a read-only legacy archive with an ACL compare view.

diff --git a/src/app/admin/tickets/[id]/admin-ticket-detail.tsx b/src/app/admin/tickets/[id]/admin-ticket-detail.tsx index 026c82f708..e8fa50082d 100644 --- a/src/app/admin/tickets/[id]/admin-ticket-detail.tsx +++ b/src/app/admin/tickets/[id]/admin-ticket-detail.tsx @@ -85,18 +85,29 @@ export function AdminTicketDetail({ staffMembers, currentUserId, canEdit, + listHref = "/admin/tickets", + userLinkMode = "admin", }: { ticket: TicketInfo; messages: TicketMessage[]; staffMembers: StaffMember[]; currentUserId: number; canEdit: boolean; + listHref?: string; + userLinkMode?: "admin" | "public"; }) { const [messages] = useState(initialMessages); const [reply, setReply] = useState(""); const { run, isPending } = useServerAction(); const messagesEndRef = useRef(null); + function creatorHref() { + if (userLinkMode === "public") { + return `/u/${encodeURIComponent(ticket.creator.username)}`; + } + return `/admin/users/show/${ticket.creator.id}`; + } + // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -152,7 +163,7 @@ export function AdminTicketDetail({
{/* Header */}
- + @@ -382,7 +393,7 @@ export function AdminTicketDetail({ diff --git a/src/app/admin/tickets/tickets-table.tsx b/src/app/admin/tickets/tickets-table.tsx index 953363020b..760b04ddac 100644 --- a/src/app/admin/tickets/tickets-table.tsx +++ b/src/app/admin/tickets/tickets-table.tsx @@ -29,7 +29,13 @@ const STATUS_VARIANT: Record< closed: "secondary", }; -export function TicketsTable({ data }: { data: PaginatedResult }) { +export function TicketsTable({ + data, + basePath = "/admin/tickets", +}: { + data: PaginatedResult; + basePath?: string; +}) { const t = useTranslations("pages.admin.tickets"); const statusLabel: Record = { @@ -51,7 +57,7 @@ export function TicketsTable({ data }: { data: PaginatedResult }) { #{row.id} {row.subject} diff --git a/src/app/mod/layout.tsx b/src/app/mod/layout.tsx index 88efe2bc7f..8c7104c482 100644 --- a/src/app/mod/layout.tsx +++ b/src/app/mod/layout.tsx @@ -1,4 +1,4 @@ -import { Ban, Gavel, LogOut, Radio, Shield } from "lucide-react"; +import { Ban, Gavel, LogOut, Radio, Shield, Ticket, Users } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; @@ -42,11 +42,23 @@ export default async function ModLayout({ children }: { children: ReactNode }) { const showBans = canAccess(permissions, PERMS.MOD_BANS_VIEW, staff.rank) || canAccess(permissions, PERMS.BANS_VIEW, staff.rank); + const showTickets = + canAccess(permissions, PERMS.MOD_TICKETS_VIEW, staff.rank) || + canAccess(permissions, PERMS.TICKETS_VIEW, staff.rank); + const showTeam = + canAccess(permissions, PERMS.MOD_TEAM_VIEW, staff.rank) || + canAccess(permissions, PERMS.MODERATION_VIEW, staff.rank); const showAdmin = canAccess(permissions, PERMS.ADMIN_DASHBOARD, staff.rank); const nav = [ { href: "/mod", label: t("nav.overview"), icon: Shield, show: true }, { href: "/mod/cfh", label: t("nav.cfh"), icon: Radio, show: showCfh }, + { + href: "/mod/tickets", + label: t("nav.tickets"), + icon: Ticket, + show: showTickets, + }, { href: "/mod/actions", label: t("nav.actions"), @@ -54,6 +66,7 @@ export default async function ModLayout({ children }: { children: ReactNode }) { show: showActions, }, { href: "/mod/bans", label: t("nav.bans"), icon: Ban, show: showBans }, + { href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam }, ].filter((item) => item.show); return ( diff --git a/src/app/mod/page.tsx b/src/app/mod/page.tsx index 9f25f4f44b..bc1d96cb85 100644 --- a/src/app/mod/page.tsx +++ b/src/app/mod/page.tsx @@ -1,4 +1,4 @@ -import { Ban, Gavel, Radio, Shield } from "lucide-react"; +import { Ban, Gavel, Radio, Shield, Ticket, Users } from "lucide-react"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { StatsCard } from "@/components/admin/stats-card"; @@ -23,8 +23,14 @@ export default async function ModDashboardPage() { const showBans = canAccess(permissions, PERMS.MOD_BANS_VIEW, rank) || canAccess(permissions, PERMS.BANS_VIEW, rank); + const showTickets = + canAccess(permissions, PERMS.MOD_TICKETS_VIEW, rank) || + canAccess(permissions, PERMS.TICKETS_VIEW, rank); + const showTeam = + canAccess(permissions, PERMS.MOD_TEAM_VIEW, rank) || + canAccess(permissions, PERMS.MODERATION_VIEW, rank); - const [openCfh, todayBans, modsOnline] = await Promise.all([ + const [openCfh, todayBans, modsOnline, openTickets] = await Promise.all([ showCfh ? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0) : Promise.resolve(0), @@ -36,6 +42,11 @@ export default async function ModDashboardPage() { prisma.user .count({ where: { online: "1", rank: { gte: minStaffRank } } }) .catch(() => 0), + showTickets + ? prisma.websiteTicket + .count({ where: { status: { not: "closed" } } }) + .catch(() => 0) + : Promise.resolve(0), ]); return ( @@ -47,7 +58,7 @@ export default async function ModDashboardPage() {

-
+
{showCfh ? ( ) : null} + {showTickets ? ( + } + href="/mod/tickets" + /> + ) : null} {showBans ? ( } + href={showTeam ? "/mod/team" : undefined} /> {showActions ? ( ) : null} + {showTeam ? ( + } + href="/mod/team" + /> + ) : null}
@@ -90,6 +119,16 @@ export default async function ModDashboardPage() { ) : null} + {showTickets ? ( +
  • + + {t("nav.tickets")} + +
  • + ) : null} {showActions ? (
  • ) : null} + {showTeam ? ( +
  • + + {t("nav.team")} + +
  • + ) : null}
    diff --git a/src/app/mod/team/page.tsx b/src/app/mod/team/page.tsx new file mode 100644 index 0000000000..0f5856894e --- /dev/null +++ b/src/app/mod/team/page.tsx @@ -0,0 +1,149 @@ +import Link from "next/link"; +import { AvatarImage } from "@/components/shared/avatar-image"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { requireModPermission } from "@/lib/admin/guard"; +import { getMinStaffRank } from "@/lib/admin/min-staff-rank"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; + +export default async function ModTeamLitePage() { + await requireModPermission([PERMS.MOD_TEAM_VIEW, PERMS.MODERATION_VIEW]); + + const minStaffRank = await getMinStaffRank(); + const staff = await prisma.user.findMany({ + where: { rank: { gte: minStaffRank } }, + select: { + id: true, + username: true, + look: true, + rank: true, + online: true, + lastLogin: true, + lastOnline: true, + }, + orderBy: [{ online: "desc" }, { rank: "desc" }], + }); + + const [cfhCounts, webTicketCounts, modActionCounts] = await Promise.all([ + prisma.supportTickets.groupBy({ + by: ["modId"], + where: { modId: { in: staff.map((s) => s.id) } }, + _count: { _all: true }, + }), + prisma.websiteTicket.groupBy({ + by: ["assigneeId"], + where: { assigneeId: { in: staff.map((s) => s.id) } }, + _count: { _all: true }, + }), + prisma.adminAuditLog.groupBy({ + by: ["userId"], + where: { + userId: { in: staff.map((s) => s.id) }, + action: { startsWith: "mod_" }, + }, + _count: { _all: true }, + }), + ]); + + const cfhMap = new Map(cfhCounts.map((c) => [c.modId, c._count._all])); + const webMap = new Map( + webTicketCounts.map((c) => [c.assigneeId, c._count._all]), + ); + const actionMap = new Map( + modActionCounts.map((c) => [c.userId, c._count._all]), + ); + + return ( +
    +
    + + +

    + {staff.filter((s) => s.online === "1").length} +

    +

    Online Now

    +
    +
    + + +

    {staff.length}

    +

    Total Staff

    +
    +
    + + +

    + {staff.filter((s) => s.rank > minStaffRank).length} +

    +

    Above min rank

    +
    +
    +
    + +
    +
    + + Name + Rank + CFH + Tickets + Actions + Last Seen +
    + + {staff.map((member) => ( + +
    + + +
    +
    +

    {member.username}

    +
    + + Rank {member.rank} + +
    +

    {cfhMap.get(member.id) ?? 0}

    +

    CFH

    +
    +
    +

    {webMap.get(member.id) ?? 0}

    +

    Web

    +
    +
    +

    + {actionMap.get(member.id) ?? 0} +

    +

    Mod

    +
    + + {member.online === "1" + ? "Online" + : member.lastOnline + ? new Date(member.lastOnline * 1000).toLocaleDateString() + : "—"} + + + ))} +
    +
    + ); +} diff --git a/src/app/mod/tickets/[id]/page.tsx b/src/app/mod/tickets/[id]/page.tsx new file mode 100644 index 0000000000..bdb18c45cf --- /dev/null +++ b/src/app/mod/tickets/[id]/page.tsx @@ -0,0 +1,101 @@ +import { notFound } from "next/navigation"; +import { AdminTicketDetail } from "@/app/admin/tickets/[id]/admin-ticket-detail"; +import { requireModPermission } from "@/lib/admin/guard"; +import { getMinStaffRank } from "@/lib/admin/min-staff-rank"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; + +export default async function ModTicketDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]); + + const { session, permissions } = await getAdminContext(); + const { id } = await params; + const ticketId = Number(id); + if (Number.isNaN(ticketId)) notFound(); + + const ticket = await prisma.websiteTicket.findUnique({ + where: { id: ticketId }, + include: { + creator: { + select: { + id: true, + username: true, + look: true, + rank: true, + ipRegister: true, + mail: true, + accountCreated: true, + }, + }, + assignee: { select: { id: true, username: true } }, + messages: { + orderBy: { createdAt: "asc" }, + include: { + user: { + select: { id: true, username: true, look: true, rank: true }, + }, + }, + }, + }, + }); + + if (!ticket) notFound(); + + const minStaffRank = await getMinStaffRank(); + const staffMembers = await prisma.user.findMany({ + where: { rank: { gte: minStaffRank } }, + select: { id: true, username: true }, + orderBy: { username: "asc" }, + }); + + const canEdit = + canAccess(permissions, PERMS.MOD_TICKETS_EDIT, session.user.rank) || + canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank); + + return ( + ({ + id: m.id, + message: m.message, + isStaff: m.isStaff === 1, + createdAt: m.createdAt.toISOString(), + user: { + id: m.user.id, + username: m.user.username, + look: m.user.look, + rank: m.user.rank, + }, + }))} + staffMembers={staffMembers} + currentUserId={session.user.id} + canEdit={canEdit} + listHref="/mod/tickets" + userLinkMode="public" + /> + ); +} diff --git a/src/app/mod/tickets/page.tsx b/src/app/mod/tickets/page.tsx new file mode 100644 index 0000000000..b29f7bf701 --- /dev/null +++ b/src/app/mod/tickets/page.tsx @@ -0,0 +1,155 @@ +import { getTranslations } from "next-intl/server"; +import { + type TicketRow, + TicketsTable, +} from "@/app/admin/tickets/tickets-table"; +import { StatusCard } from "@/components/admin/dashboard"; +import type { Prisma } from "@/generated/prisma/client"; +import { requireModPermission } from "@/lib/admin/guard"; +import { calcPagination, parseListParams } from "@/lib/admin-helpers"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +function fromDate(d: Date | null | undefined): string { + return d ? d.toISOString().slice(0, 10) : "—"; +} + +export default async function ModTicketsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]); + + const t = await getTranslations("pages.admin.tickets"); + const raw = await searchParams; + const parsed = parseListParams(new URLSearchParams(raw)); + const statusFilter = raw.filter_status || raw.status || "active"; + + const conditions: Prisma.WebsiteTicketWhereInput[] = []; + + if (statusFilter === "active") { + conditions.push({ status: { not: "closed" } }); + } else if (statusFilter && statusFilter !== "all") { + conditions.push({ status: statusFilter }); + } + + if (parsed.search.trim()) { + const q = parsed.search.trim(); + const asId = Number(q); + conditions.push({ + OR: [ + { subject: { contains: q } }, + { category: { contains: q } }, + { creator: { username: { contains: q } } }, + { assignee: { username: { contains: q } } }, + ...(Number.isFinite(asId) && asId > 0 ? [{ id: asId }] : []), + ], + }); + } + + const where: Prisma.WebsiteTicketWhereInput = + conditions.length === 0 + ? {} + : conditions.length === 1 + ? conditions[0] + : { AND: conditions }; + + const SORT_MAP: Record = + { + subject: { subject: "asc" }, + status: { status: "asc" }, + priority: { priority: "asc" }, + assignee: { assigneeId: "asc" }, + date: { createdAt: "desc" }, + id: { id: "desc" }, + }; + + const baseOrder = SORT_MAP[parsed.sort ?? "date"] ?? { createdAt: "desc" }; + const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; + const orderBy = { + [orderField]: parsed.order, + } as Prisma.WebsiteTicketOrderByWithRelationInput; + + const [total, openCount, inProgressCount, waitingCount, closedCount] = + await Promise.all([ + prisma.websiteTicket.count({ where }).catch(() => 0), + prisma.websiteTicket.count({ where: { status: "open" } }).catch(() => 0), + prisma.websiteTicket + .count({ where: { status: "in_progress" } }) + .catch(() => 0), + prisma.websiteTicket + .count({ where: { status: "waiting" } }) + .catch(() => 0), + prisma.websiteTicket + .count({ where: { status: "closed" } }) + .catch(() => 0), + ]); + + const pagination = calcPagination(total, parsed.page, parsed.perPage); + + const tickets = await prisma.websiteTicket + .findMany({ + where, + orderBy, + skip: pagination.offset, + take: pagination.perPage, + include: { + creator: { select: { id: true, username: true } }, + assignee: { select: { id: true, username: true } }, + _count: { select: { messages: true } }, + }, + }) + .catch(() => []); + + const rows: TicketRow[] = tickets.map((ticket) => ({ + id: ticket.id, + subject: ticket.subject, + creator: ticket.creator.username, + creatorId: ticket.creatorId, + category: ticket.category, + messages: ticket._count.messages, + status: ticket.status, + priority: ticket.priority, + assignee: ticket.assignee?.username ?? "—", + date: fromDate(ticket.createdAt), + })); + + return ( +
    +
    + + 0 ? "warn" : "neutral"} + icon="⏳" + /> + 0 ? "warn" : "neutral"} + icon="🕒" + /> + +
    + + {total === 0 && !parsed.search && statusFilter === "active" ? ( +
    {t("noTickets")}
    + ) : ( + + )} +
    + ); +} diff --git a/src/lib/admin/guard.ts b/src/lib/admin/guard.ts index 196a6addd6..e33b59887b 100644 --- a/src/lib/admin/guard.ts +++ b/src/lib/admin/guard.ts @@ -70,7 +70,10 @@ export async function requireMod(): Promise { canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank) || canAccess(permissions, PERMS.MOD_CFH_VIEW, session.user.rank) || canAccess(permissions, PERMS.MOD_ACTIONS, session.user.rank) || - canAccess(permissions, PERMS.MOD_BANS_VIEW, session.user.rank); + canAccess(permissions, PERMS.MOD_BANS_VIEW, session.user.rank) || + canAccess(permissions, PERMS.MOD_TICKETS_VIEW, session.user.rank) || + canAccess(permissions, PERMS.MOD_TEAM_VIEW, session.user.rank) || + canAccess(permissions, PERMS.TICKETS_VIEW, session.user.rank); if (!ok) redirectSafe("/", "/"); return { id: session.user.id, diff --git a/src/messages/en.json b/src/messages/en.json index 9f50e560eb..8c12222963 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2726,7 +2726,21 @@ "importTitle": "Import permissions", "legacyBannerTitle": "This table is not live ACL", "legacyBannerBody": "Runtime access uses the ACL permissions system. Use Housekeeping only as a legacy reference.", - "legacyBannerCta": "Open live Permissions" + "legacyBannerCta": "Open live Permissions", + "writesRetired": "Create/edit/import/presets on this table are disabled. Change live access under Permissions.", + "tabCompare": "ACL compare", + "tabExportOnly": "Export archive", + "compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).", + "compareAligned": "{count} aligned", + "compareMissingAcl": "{count} missing in ACL", + "compareMismatch": "{count} rank mismatch", + "colAclPresent": "In ACL", + "colAclMinRank": "ACL min rank", + "colStatus": "Status", + "statusAligned": "Aligned", + "statusMissingAcl": "Missing in ACL", + "statusMismatch": "Rank differs", + "exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival." }, "rooms": { "title": "Rooms", @@ -3028,14 +3042,19 @@ "actions": "Quick actions", "bans": "Bans", "fullAdmin": "Full admin", - "backToSite": "Back to site" + "backToSite": "Back to site", + "tickets": "Tickets", + "team": "Team" }, "stats": { "openCfh": "Open CFH", "bansToday": "Bans today", "modsOnline": "Mods online", "quickActions": "Quick actions", - "quickActionsHint": "Kick, mute, alert" + "quickActionsHint": "Kick, mute, alert", + "openTickets": "Open tickets", + "team": "Mod team", + "teamHint": "Online staff & workload" } } } diff --git a/src/messages/it.json b/src/messages/it.json index 66a3dfa6b1..9f0a2a655c 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -2573,7 +2573,21 @@ "importTitle": "Import permissions", "legacyBannerTitle": "Questa tabella non e ACL live", "legacyBannerBody": "L'accesso runtime usa il sistema ACL. Usa Housekeeping solo come riferimento legacy.", - "legacyBannerCta": "Apri Permessi live" + "legacyBannerCta": "Apri Permessi live", + "writesRetired": "Create/modifica/import/preset su questa tabella sono disabilitati. Cambia l'accesso live in Permessi.", + "tabCompare": "Confronto ACL", + "tabExportOnly": "Esporta archivio", + "compareHint": "Confronta le righe legacy housekeeping con i grant ACL live rank_* (grado minimo con lo slug).", + "compareAligned": "{count} allineati", + "compareMissingAcl": "{count} mancanti in ACL", + "compareMismatch": "{count} mismatch grado", + "colAclPresent": "In ACL", + "colAclMinRank": "Min grado ACL", + "colStatus": "Stato", + "statusAligned": "Allineato", + "statusMissingAcl": "Manca in ACL", + "statusMismatch": "Grado diverso", + "exportOnlyHint": "Le scritture sono disabilitate. Puoi ancora esportare la tabella legacy per archivio." }, "rooms": { "title": "Stanze", @@ -3031,14 +3045,19 @@ "actions": "Azioni rapide", "bans": "Ban", "fullAdmin": "Admin completo", - "backToSite": "Torna al sito" + "backToSite": "Torna al sito", + "tickets": "Ticket", + "team": "Team" }, "stats": { "openCfh": "CFH aperti", "bansToday": "Ban oggi", "modsOnline": "Mod online", "quickActions": "Azioni rapide", - "quickActionsHint": "Kick, mute, alert" + "quickActionsHint": "Kick, mute, alert", + "openTickets": "Ticket aperti", + "team": "Team mod", + "teamHint": "Staff online e carico" } } } diff --git a/src/messages/nl.json b/src/messages/nl.json index de5c92e1a8..09cc70500c 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -2548,7 +2548,21 @@ "importTitle": "Permissies importeren", "legacyBannerTitle": "Deze tabel is geen live ACL", "legacyBannerBody": "Runtime-toegang gebruikt het ACL-systeem. Gebruik Housekeeping alleen als legacy-referentie.", - "legacyBannerCta": "Open live Permissions" + "legacyBannerCta": "Open live Permissions", + "writesRetired": "Aanmaken/bewerken/import/presets op deze tabel zijn uitgeschakeld. Wijzig live toegang onder Permissions.", + "tabCompare": "ACL vergelijking", + "tabExportOnly": "Exporteer archief", + "compareHint": "Vergelijkt legacy housekeeping min-rank rijen met live ACL rank_* grants (laagste rang met de slug).", + "compareAligned": "{count} uitgelijnd", + "compareMissingAcl": "{count} ontbreekt in ACL", + "compareMismatch": "{count} rang mismatch", + "colAclPresent": "In ACL", + "colAclMinRank": "ACL min rang", + "colStatus": "Status", + "statusAligned": "Uitgelijnd", + "statusMissingAcl": "Ontbreekt in ACL", + "statusMismatch": "Rang verschilt", + "exportOnlyHint": "Schrijven is uitgeschakeld. Je kunt de legacy-tabel nog exporteren voor archief." }, "rooms": { "title": "Kamers", @@ -3029,14 +3043,19 @@ "actions": "Snelle acties", "bans": "Bans", "fullAdmin": "Volledige admin", - "backToSite": "Terug naar site" + "backToSite": "Terug naar site", + "tickets": "Tickets", + "team": "Team" }, "stats": { "openCfh": "Open CFH", "bansToday": "Bans vandaag", "modsOnline": "Mods online", "quickActions": "Snelle acties", - "quickActionsHint": "Kick, mute, alert" + "quickActionsHint": "Kick, mute, alert", + "openTickets": "Open tickets", + "team": "Mod team", + "teamHint": "Online staff & werklast" } } }