From 4b596226e030075f0b30580783a171309455061e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 12 Jul 2026 19:12:24 +0200 Subject: [PATCH] fix: complete acl management --- ...14_complete_acl_and_import_permissions.sql | 35 ++++ src/actions/permissions.ts | 159 +++++++++++------- src/app/admin/permissions/page.tsx | 11 -- .../admin/permissions/assignments/route.ts | 23 +++ src/app/api/admin/permissions/roles/route.ts | 31 ++++ src/lib/services/rcon.ts | 3 + 6 files changed, 187 insertions(+), 75 deletions(-) create mode 100644 prisma/migrations/0014_complete_acl_and_import_permissions.sql create mode 100644 src/app/api/admin/permissions/assignments/route.ts create mode 100644 src/app/api/admin/permissions/roles/route.ts diff --git a/prisma/migrations/0014_complete_acl_and_import_permissions.sql b/prisma/migrations/0014_complete_acl_and_import_permissions.sql new file mode 100644 index 00000000..ff15fa25 --- /dev/null +++ b/prisma/migrations/0014_complete_acl_and_import_permissions.sql @@ -0,0 +1,35 @@ +UPDATE `acl_model_permissions` SET `model_type` = 'Role' WHERE LOWER(`model_type`) = 'role'; +UPDATE `acl_model_roles` SET `model_type` = 'User' WHERE LOWER(`model_type`) = 'user'; + +INSERT INTO `acl_permissions` (`slug`, `title`) VALUES + ('admin.assets.import', 'Import assets'), + ('admin.permissions.manage', 'Manage permissions') +ON DUPLICATE KEY UPDATE `title` = VALUES(`title`); + +INSERT INTO `acl_roles` (`slug`, `title`, `description`) +SELECT CONCAT('rank_', pr.id), COALESCE(NULLIF(pr.rank_name, ''), CONCAT('Rank ', pr.id)), 'CMS role synchronized from permission_ranks' +FROM `permission_ranks` pr +WHERE NOT EXISTS (SELECT 1 FROM `acl_roles` ar WHERE ar.slug = CONCAT('rank_', pr.id)); + +INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`) +SELECT 'Role', ar.id, ap.id +FROM `permission_ranks` pr +JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id) +CROSS JOIN `acl_permissions` ap +WHERE pr.id = (SELECT MAX(id) FROM `permission_ranks`) + AND ap.slug IN ('admin.permissions.manage', 'admin.assets.import') + AND NOT EXISTS ( + SELECT 1 FROM `acl_model_permissions` amp + WHERE amp.model_type = 'Role' AND amp.model_id = ar.id AND amp.permission_id = ap.id + ); + +INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`) +SELECT 'Role', ar.id, ap.id +FROM `website_housekeeping_permissions` whp +JOIN `acl_permissions` ap ON ap.slug = whp.permission +JOIN `permission_ranks` pr ON pr.id >= whp.min_rank +JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id) +WHERE NOT EXISTS ( + SELECT 1 FROM `acl_model_permissions` amp + WHERE amp.model_type = 'Role' AND amp.model_id = ar.id AND amp.permission_id = ap.id +); diff --git a/src/actions/permissions.ts b/src/actions/permissions.ts index 777b1d9b..58c153db 100644 --- a/src/actions/permissions.ts +++ b/src/actions/permissions.ts @@ -1,71 +1,102 @@ -"use server"; +'use server' -import { revalidatePath } from "next/cache"; -import { requireStaff } from "@/lib/admin/guard"; -import { prisma } from "@/lib/prisma"; -import { logStaffActivity } from "@/lib/services/staff-activity"; +import { revalidateTag } from 'next/cache' +import { z } from 'zod' +import { PERMS } from '@/lib/permission-slugs' +import { prisma } from '@/lib/prisma' +import { adminAction } from '@/lib/safe-action' +import { ActionError, actionOk } from '@/lib/safe-action-shared' +import { createEmulatorRank, deleteEmulatorRank, updateEmulatorRank } from '@/lib/services/permission-ranks' +import { rcon } from '@/lib/services/rcon' +import { logStaffActivity } from '@/lib/services/staff-activity' -export async function saveRank({ id, fields }: { id: number; fields: Record }) { - const staff = await requireStaff(); - const setClauses = Object.entries(fields) - .map(([key]) => `\`${key}\` = ?`) - .join(", "); - const values = Object.values(fields); - await prisma.$executeRawUnsafe( - `UPDATE permissions SET ${setClauses} WHERE id = ?`, - ...values, - id, - ); - await logStaffActivity({ - staffId: staff.id, - action: "rank_update", - description: `Updated rank #${id}`, - targetType: "rank", - targetId: id, - }); -} +const createRankSchema = z.object({ + rank_name: z.string().trim().min(1).max(25), + level: z.coerce.number().int().min(1), +}) -export async function setCmsPermissions({ roleId, permissionSlugs }: { roleId: number; permissionSlugs: string[] }) { - await requireStaff(); - const existing = await prisma.websiteHousekeepingPermissions.findMany({ - where: { minRank: roleId }, - select: { permission: true }, - }); - const existingSlugs = new Set(existing.map((p) => p.permission)); - const toAdd = permissionSlugs.filter((s) => !existingSlugs.has(s)); - const toRemove = existing.filter((p) => !permissionSlugs.includes(p.permission)); +export const createRank = adminAction( + { schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, + async (ctx) => { + const id = await createEmulatorRank(prisma, ctx.data) + await prisma.aclRole.upsert({ + where: { slug: `rank_${id}` }, + create: { slug: `rank_${id}`, title: ctx.data.rank_name, description: 'CMS role synchronized from permission_ranks' }, + update: { title: ctx.data.rank_name }, + }) + await logStaffActivity({ staffId: ctx.session.user.id, action: 'rank_create', description: `Created rank #${id}`, targetType: 'rank', targetId: id }) + await rcon.send('updatepermissions') + revalidateTag('permissions', { expire: 0 }) + return actionOk({ id }) + }, +) - for (const slug of toAdd) { - await prisma.websiteHousekeepingPermissions.create({ - data: { permission: slug, minRank: roleId, description: null }, - }); - } - for (const p of toRemove) { - await prisma.websiteHousekeepingPermissions.deleteMany({ - where: { permission: p.permission, minRank: roleId }, - }); - } +const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() }) - return { ok: true as const }; -} +export const deleteRank = adminAction( + { schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, + async (ctx) => { + const users = await prisma.user.count({ where: { rank: ctx.data.id } }) + if (users > 0) throw new ActionError(`Cannot delete: ${users} users have this rank`) + const role = await prisma.aclRole.findFirst({ where: { slug: `rank_${ctx.data.id}` } }) + await deleteEmulatorRank(prisma, ctx.data.id) + if (role) { + await prisma.$transaction([ + prisma.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: 'Role' } }), + prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }), + prisma.aclRole.delete({ where: { id: role.id } }), + ]) + } + await logStaffActivity({ staffId: ctx.session.user.id, action: 'rank_delete', description: `Deleted rank #${ctx.data.id}`, targetType: 'rank', targetId: ctx.data.id }) + await rcon.send('updatepermissions') + revalidateTag('permissions', { expire: 0 }) + return actionOk() + }, +) -export async function createRank({ rank_name, level }: { rank_name: string; level: number }) { - const staff = await requireStaff(); - const created = await prisma.websiteTeams.create({ - data: { - rankName: rank_name, - hiddenRank: false, - badge: null, - jobDescription: null, - staffColor: "#ffffff", - staffBackground: "#000000", - }, - }); - return { ok: true as const, data: { id: Number(created.id) } }; -} +const saveRankSchema = z.object({ + id: z.coerce.number().int().positive(), + fields: z.record(z.string(), z.union([z.string(), z.number()])), +}) -export async function deleteRank({ id }: { id: number }) { - await requireStaff(); - await prisma.websiteTeams.delete({ where: { id: BigInt(id) } }); - return { ok: true as const }; -} +export const saveRank = adminAction( + { schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, + async (ctx) => { + await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields) + if (typeof ctx.data.fields.rank_name === 'string') { + await prisma.aclRole.updateMany({ where: { slug: `rank_${ctx.data.id}` }, data: { title: ctx.data.fields.rank_name } }) + } + await logStaffActivity({ staffId: ctx.session.user.id, action: 'rank_update', description: `Updated rank #${ctx.data.id}`, targetType: 'rank', targetId: ctx.data.id }) + await rcon.send('updatepermissions') + revalidateTag('permissions', { expire: 0 }) + return actionOk() + }, +) + +const setCmsPermsSchema = z.object({ + roleId: z.coerce.number().int().positive(), + permissionSlugs: z.array(z.string().trim().min(1)).max(500), +}) + +export const setCmsPermissions = adminAction( + { schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE }, + async (ctx) => { + const role = await prisma.aclRole.findUnique({ where: { id: ctx.data.roleId }, select: { id: true, slug: true } }) + if (!role) throw new ActionError('Role not found') + const permissions = await prisma.aclPermission.findMany({ + where: { slug: { in: ctx.data.permissionSlugs } }, + select: { id: true }, + }) + await prisma.$transaction(async (tx) => { + await tx.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: 'Role' } }) + if (permissions.length) { + await tx.aclModelPermission.createMany({ + data: permissions.map((permission) => ({ modelId: role.id, modelType: 'Role', permissionId: permission.id })), + }) + } + }) + await logStaffActivity({ staffId: ctx.session.user.id, action: 'acl_role_permissions_update', description: `Updated ${permissions.length} permissions for ${role.slug}`, targetType: 'acl_role', targetId: role.id }) + revalidateTag('permissions', { expire: 0 }) + return actionOk() + }, +) diff --git a/src/app/admin/permissions/page.tsx b/src/app/admin/permissions/page.tsx index af1d0cca..64639d62 100644 --- a/src/app/admin/permissions/page.tsx +++ b/src/app/admin/permissions/page.tsx @@ -20,17 +20,6 @@ export default async function PermissionsPage(_props: { params: Promise<{ locale countMap[Number(row.rank)] = Number(row.cnt) } - // Auto-create CMS roles for ranks >= 4 - for (const rank of ranks) { - if (rank.id >= 4) { - await prisma.aclRole.upsert({ - where: { slug: `rank_${rank.id}` }, - create: { slug: `rank_${rank.id}`, title: rank.rank_name || `Rank ${rank.id}` }, - update: {}, - }) - } - } - const ranksWithCounts = ranks.map((rank) => ({ ...rank, userCount: countMap[rank.id] ?? 0, diff --git a/src/app/api/admin/permissions/assignments/route.ts b/src/app/api/admin/permissions/assignments/route.ts new file mode 100644 index 00000000..c0321bc8 --- /dev/null +++ b/src/app/api/admin/permissions/assignments/route.ts @@ -0,0 +1,23 @@ +import { revalidateTag } from 'next/cache' +import { NextResponse } from 'next/server' +import { withAdmin } from '@/lib/api-handler' +import { apiError } from '@/lib/api-response' +import { PERMS } from '@/lib/permissions' +import { prisma } from '@/lib/prisma' + +export const POST = withAdmin({ permission: PERMS.PERMISSIONS_MANAGE }, async (request) => { + const body = await request.json() + const userId = Number(body.userId) + const roleId = Number(body.roleId) + if (!Number.isInteger(userId) || userId <= 0 || !Number.isInteger(roleId) || roleId <= 0) { + return apiError('Valid userId and roleId required', 400) + } + if (body.action === 'remove') { + await prisma.aclModelRole.deleteMany({ where: { modelType: 'User', modelId: userId, roleId } }) + } else { + const existing = await prisma.aclModelRole.findFirst({ where: { modelType: 'User', modelId: userId, roleId } }) + if (!existing) await prisma.aclModelRole.create({ data: { modelType: 'User', modelId: userId, roleId } }) + } + revalidateTag('permissions', { expire: 0 }) + return NextResponse.json({ success: true }) +}) diff --git a/src/app/api/admin/permissions/roles/route.ts b/src/app/api/admin/permissions/roles/route.ts new file mode 100644 index 00000000..5e611567 --- /dev/null +++ b/src/app/api/admin/permissions/roles/route.ts @@ -0,0 +1,31 @@ +import { revalidateTag } from 'next/cache' +import { NextResponse } from 'next/server' +import { withAdmin } from '@/lib/api-handler' +import { apiError } from '@/lib/api-response' +import { PERMS } from '@/lib/permissions' +import { prisma } from '@/lib/prisma' + +export const POST = withAdmin({ permission: PERMS.PERMISSIONS_MANAGE }, async (request) => { + const body = await request.json() + const slug = String(body.slug ?? '').trim().toLowerCase() + const title = String(body.title ?? '').trim() + if (!/^[a-z0-9._-]{2,64}$/.test(slug) || !title) return apiError('Valid slug and title required', 400) + const role = await prisma.aclRole.create({ data: { slug, title } }) + revalidateTag('permissions', { expire: 0 }) + return NextResponse.json({ role }) +}) + +export const DELETE = withAdmin({ permission: PERMS.PERMISSIONS_MANAGE }, async (request) => { + const id = Number(request.nextUrl.searchParams.get('id')) + if (!Number.isInteger(id) || id <= 0) return apiError('Valid ID required', 400) + const role = await prisma.aclRole.findUnique({ where: { id }, select: { slug: true } }) + if (!role) return apiError('Role not found', 404) + if (role.slug.startsWith('rank_')) return apiError('Rank roles must be managed through emulator ranks', 409) + await prisma.$transaction([ + prisma.aclModelPermission.deleteMany({ where: { modelType: 'Role', modelId: id } }), + prisma.aclModelRole.deleteMany({ where: { roleId: id } }), + prisma.aclRole.delete({ where: { id } }), + ]) + revalidateTag('permissions', { expire: 0 }) + return NextResponse.json({ success: true }) +}) diff --git a/src/lib/services/rcon.ts b/src/lib/services/rcon.ts index 88e1256e..1754ea00 100644 --- a/src/lib/services/rcon.ts +++ b/src/lib/services/rcon.ts @@ -110,6 +110,9 @@ export class RconClient { updateCatalog() { return this.send("updatecatalog"); } + updateItems() { + return this.send("updateitems"); + } updateConfig() { return this.send("updateconfig"); }