From 3c8a8ff888679a376afeb070d5522512a126d09e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 15 Jul 2026 20:11:55 +0200 Subject: [PATCH] Extend fine-grained ACL to settings, content, shop, and radio. Gate pages and mutations on module PERMS instead of dashboard-only staff checks, add radio view/edit slugs with migration 0015, and expand the operations contract tests. Co-authored-by: Cursor --- .../migrations/0012_seed_acl_permissions.sql | 2 ++ .../migrations/0015_radio_acl_permissions.sql | 33 +++++++++++++++++++ src/actions/admin-articles.ts | 9 ++--- src/actions/admin-bans.ts | 7 ++-- src/actions/admin-emulator.ts | 7 ++-- src/actions/admin-radio-api-keys.ts | 9 ++--- src/actions/admin-radio-autodj.ts | 9 ++--- src/actions/admin-radio-extra.ts | 19 ++++++----- src/actions/admin-radio-moderation.ts | 5 +-- src/actions/admin-radio-points.ts | 5 +-- src/actions/admin-settings.ts | 9 ++--- src/actions/admin-shop.ts | 9 ++--- src/actions/admin-theme.ts | 15 +++++---- src/actions/admin-wordfilter.ts | 7 ++-- src/app/admin/articles/[id]/page.tsx | 8 ++++- src/app/admin/articles/new/page.tsx | 7 ++++ src/app/admin/articles/page.tsx | 7 ++++ src/app/admin/bans/page.tsx | 7 ++++ src/app/admin/emulator/page.tsx | 7 ++++ src/app/admin/radio/embed/page.tsx | 3 -- src/app/admin/radio/layout.tsx | 15 +++++++++ src/app/admin/radio/monitoring/page.tsx | 3 -- src/app/admin/settings/page.tsx | 7 ++++ src/app/admin/shop/[id]/page.tsx | 8 ++++- src/app/admin/shop/new/page.tsx | 7 ++++ src/app/admin/shop/page.tsx | 7 ++++ src/app/admin/theme/page.tsx | 7 ++++ src/app/admin/wordfilter/page.tsx | 7 ++++ src/lib/admin-operations-contract.test.ts | 28 ++++++++++++++++ src/lib/admin/acl-migration-contract.test.ts | 28 ++++++++-------- src/lib/admin/guard.ts | 10 ++++++ src/lib/permission-slugs.ts | 3 ++ 32 files changed, 244 insertions(+), 70 deletions(-) create mode 100644 prisma/migrations/0015_radio_acl_permissions.sql create mode 100644 src/app/admin/radio/layout.tsx diff --git a/prisma/migrations/0012_seed_acl_permissions.sql b/prisma/migrations/0012_seed_acl_permissions.sql index 2727d0b8..4c9c76dc 100644 --- a/prisma/migrations/0012_seed_acl_permissions.sql +++ b/prisma/migrations/0012_seed_acl_permissions.sql @@ -25,6 +25,8 @@ INSERT INTO `acl_permissions` (`slug`, `title`) VALUES ('admin.catalog.edit', 'Edit catalog'), ('admin.rcon.execute', 'Execute RCON commands'), ('admin.export', 'Export data'), + ('admin.radio.view', 'View radio admin'), + ('admin.radio.edit', 'Edit radio admin'), ('admin.prefixes.view', 'View prefixes'), ('admin.prefixes.edit', 'Edit prefixes'), ('admin.tickets.view', 'View tickets'), diff --git a/prisma/migrations/0015_radio_acl_permissions.sql b/prisma/migrations/0015_radio_acl_permissions.sql new file mode 100644 index 00000000..071cba9d --- /dev/null +++ b/prisma/migrations/0015_radio_acl_permissions.sql @@ -0,0 +1,33 @@ +INSERT INTO `acl_permissions` (`slug`, `title`) VALUES + ('admin.radio.view', 'View radio admin'), + ('admin.radio.edit', 'Edit radio admin') +ON DUPLICATE KEY UPDATE `title` = VALUES(`title`); + +-- Rank >= 6 gets radio view (matches other admin.*.view grants from 0012). +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) +JOIN `acl_permissions` ap ON ap.slug = 'admin.radio.view' +WHERE pr.id >= 6 + 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 + ); + +-- Highest rank gets radio edit (same pattern as assets.import / permissions.manage). +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 = 'admin.radio.edit' + 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 + ); diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 9db014e2..e6ccc0ea 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -2,7 +2,8 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { slugify } from "@/lib/format"; import { prisma } from "@/lib/prisma"; @@ -22,7 +23,7 @@ async function uniqueSlug(title: string): Promise { } export async function createArticle(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.NEWS_EDIT); const title = String(formData.get("title") ?? "") .normalize("NFC") .trim(); @@ -61,7 +62,7 @@ export async function createArticle(formData: FormData): Promise { } export async function updateArticle(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.NEWS_EDIT); const id = BigInt(String(formData.get("id"))); try { await prisma.websiteArticles.update({ @@ -93,7 +94,7 @@ export async function updateArticle(formData: FormData): Promise { } export async function deleteArticle(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.NEWS_EDIT); const id = BigInt(String(formData.get("id"))); try { await prisma.$transaction([ diff --git a/src/actions/admin-bans.ts b/src/actions/admin-bans.ts index d1d637f5..933964a7 100644 --- a/src/actions/admin-bans.ts +++ b/src/actions/admin-bans.ts @@ -2,7 +2,8 @@ import { revalidatePath } from "next/cache"; import type { $Enums } from "@/generated/prisma/client"; -import { requireStaffRateLimited as requireStaff } from "@/lib/admin/guard"; +import { requirePermissionRateLimited } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -17,7 +18,7 @@ const BAN_TYPES: ReadonlySet = new Set([ const PERMANENT_SECONDS = 100 * 365 * 24 * 3600; export async function createBan(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermissionRateLimited(PERMS.USERS_BAN); const userId = Number(formData.get("userId")); const reason = String(formData.get("reason") ?? "") @@ -63,7 +64,7 @@ export async function createBan(formData: FormData): Promise { } export async function liftBan(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermissionRateLimited(PERMS.USERS_BAN); const id = Number(formData.get("id")); if (id > 0) { await prisma.ban.delete({ where: { id } }); diff --git a/src/actions/admin-emulator.ts b/src/actions/admin-emulator.ts index 711f3799..962de38b 100644 --- a/src/actions/admin-emulator.ts +++ b/src/actions/admin-emulator.ts @@ -1,7 +1,8 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; // emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512). @@ -10,7 +11,7 @@ import { prisma } from "@/lib/prisma"; // keys via upsert. We never migrate or drop them. export async function updateEmulatorSetting(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim() @@ -28,7 +29,7 @@ export async function updateEmulatorSetting(formData: FormData): Promise { } export async function updateEmulatorText(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim() diff --git a/src/actions/admin-radio-api-keys.ts b/src/actions/admin-radio-api-keys.ts index 5d8bbcc2..b1e84b8a 100644 --- a/src/actions/admin-radio-api-keys.ts +++ b/src/actions/admin-radio-api-keys.ts @@ -3,7 +3,8 @@ import { randomBytes } from "node:crypto"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -35,7 +36,7 @@ function intOr(raw: FormDataEntryValue | null, fallback: number): number { } export async function createApiKey(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.RADIO_EDIT); const name = str(formData.get("name")).trim().slice(0, 255); if (!name) return; @@ -77,7 +78,7 @@ export async function createApiKey(formData: FormData): Promise { } export async function toggleApiKey(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.RADIO_EDIT); const id = parseId(formData.get("id")); if (id == null) return; @@ -109,7 +110,7 @@ export async function toggleApiKey(formData: FormData): Promise { } export async function deleteApiKey(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.RADIO_EDIT); const id = parseId(formData.get("id")); if (id == null) return; diff --git a/src/actions/admin-radio-autodj.ts b/src/actions/admin-radio-autodj.ts index fbc64b77..3ea9cd78 100644 --- a/src/actions/admin-radio-autodj.ts +++ b/src/actions/admin-radio-autodj.ts @@ -1,7 +1,8 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -51,7 +52,7 @@ function optUInt(raw: FormDataEntryValue | null): number | null { // ── AutoDJ playlist CRUD (radio_auto_dj_playlist) ──────────────────────── export async function createTrack(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.RADIO_EDIT); const title = str(formData.get("title")).trim().slice(0, 255); if (!title) return; @@ -91,7 +92,7 @@ export async function createTrack(formData: FormData): Promise { } export async function toggleTrack(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.RADIO_EDIT); const id = parseId(formData.get("id")); if (id === null) return; @@ -117,7 +118,7 @@ export async function toggleTrack(formData: FormData): Promise { } export async function deleteTrack(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.RADIO_EDIT); const id = parseId(formData.get("id")); if (id === null) return; diff --git a/src/actions/admin-radio-extra.ts b/src/actions/admin-radio-extra.ts index 31bb6c1f..e96c9ee8 100644 --- a/src/actions/admin-radio-extra.ts +++ b/src/actions/admin-radio-extra.ts @@ -1,7 +1,8 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; @@ -36,7 +37,7 @@ function bool(raw: FormDataEntryValue | null): boolean { * siteSettings cache so the public radio pages pick the change up immediately. */ export async function saveRadioSetting(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.RADIO_EDIT); const key = str(formData.get("key")).trim().slice(0, 255); const value = str(formData.get("value")); const comment = str(formData.get("comment")).trim().slice(0, 255); @@ -61,7 +62,7 @@ export async function saveRadioSetting(formData: FormData): Promise { * only touch those (and never wipe unrelated settings). */ export async function saveRadioSettings(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.RADIO_EDIT); const keysRaw = str(formData.get("__keys")); const keys = keysRaw .split(",") @@ -90,7 +91,7 @@ export async function saveRadioSettings(formData: FormData): Promise { // ── Radio banners CRUD (radio_banners) ───────────────────────────────────── export async function createRadioBanner(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.RADIO_EDIT); const imagePath = str(formData.get("imagePath")).trim().slice(0, 255); if (!imagePath) return; @@ -123,7 +124,7 @@ export async function createRadioBanner(formData: FormData): Promise { } export async function updateRadioBanner(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.RADIO_EDIT); const id = parseId(formData.get("id")); if (id === null) return; @@ -156,7 +157,7 @@ export async function updateRadioBanner(formData: FormData): Promise { } export async function deleteRadioBanner(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.RADIO_EDIT); const id = parseId(formData.get("id")); if (id === null) return; try { @@ -170,7 +171,7 @@ export async function deleteRadioBanner(formData: FormData): Promise { // ── Radio ranks CRUD (radio_ranks) ───────────────────────────────────────── export async function createRadioRank(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.RADIO_EDIT); const name = str(formData.get("name")).trim().slice(0, 255); if (!name) return; @@ -197,7 +198,7 @@ export async function createRadioRank(formData: FormData): Promise { } export async function updateRadioRank(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.RADIO_EDIT); const id = parseId(formData.get("id")); if (id === null) return; @@ -225,7 +226,7 @@ export async function updateRadioRank(formData: FormData): Promise { } export async function deleteRadioRank(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.RADIO_EDIT); const id = parseId(formData.get("id")); if (id === null) return; try { diff --git a/src/actions/admin-radio-moderation.ts b/src/actions/admin-radio-moderation.ts index b5111b91..779b1773 100644 --- a/src/actions/admin-radio-moderation.ts +++ b/src/actions/admin-radio-moderation.ts @@ -1,7 +1,8 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -22,7 +23,7 @@ function parseId(raw: FormDataEntryValue | null): bigint | null { * moderation route. Fails soft if the row is already gone. */ export async function deleteShout(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.RADIO_EDIT); const id = parseId(formData.get("id")); if (id === null) return; diff --git a/src/actions/admin-radio-points.ts b/src/actions/admin-radio-points.ts index 61855243..96da35db 100644 --- a/src/actions/admin-radio-points.ts +++ b/src/actions/admin-radio-points.ts @@ -2,7 +2,8 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -46,7 +47,7 @@ function intStr(raw: FormDataEntryValue | null): string { } export async function savePoints(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.RADIO_EDIT); const currencyRaw = str(formData.get("radio_points_currency")) .trim() diff --git a/src/actions/admin-settings.ts b/src/actions/admin-settings.ts index 2c3d5054..6793efd8 100644 --- a/src/actions/admin-settings.ts +++ b/src/actions/admin-settings.ts @@ -1,12 +1,13 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireStaffRateLimited as requireStaff } from "@/lib/admin/guard"; +import { requirePermissionRateLimited } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; export async function updateSetting(formData: FormData): Promise { - await requireStaff(); + await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim(); @@ -18,7 +19,7 @@ export async function updateSetting(formData: FormData): Promise { } export async function createSetting(formData: FormData): Promise { - await requireStaff(); + await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim() @@ -39,7 +40,7 @@ export async function createSetting(formData: FormData): Promise { } export async function deleteSetting(formData: FormData): Promise { - await requireStaff(); + await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim(); diff --git a/src/actions/admin-shop.ts b/src/actions/admin-shop.ts index 32187f61..5eaaefc2 100644 --- a/src/actions/admin-shop.ts +++ b/src/actions/admin-shop.ts @@ -2,7 +2,8 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { formPositiveBigInt } from "@/lib/form-data"; import { prisma } from "@/lib/prisma"; import { logServerError } from "@/lib/server-log"; @@ -30,7 +31,7 @@ function reqUInt(formData: FormData, key: string): number { } export async function createShopArticle(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.SHOP_EDIT); const name = String(formData.get("name") ?? "") .normalize("NFC") @@ -90,7 +91,7 @@ export async function createShopArticle(formData: FormData): Promise { } export async function updateShopArticle(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.SHOP_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) return; @@ -152,7 +153,7 @@ export async function updateShopArticle(formData: FormData): Promise { } export async function deleteShopArticle(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.SHOP_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) return; diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts index 39baeb16..030e5e4b 100644 --- a/src/actions/admin-theme.ts +++ b/src/actions/admin-theme.ts @@ -2,7 +2,8 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -30,7 +31,7 @@ async function writeSetting(key: string, value: string): Promise { } export async function saveTheme(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); try { for (const mode of ["light", "dark"] as const) { @@ -96,7 +97,7 @@ export async function saveTheme(formData: FormData): Promise { } export async function applyPreset(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); const name = String(formData.get("preset") ?? "").normalize("NFC"); // eslint-disable-next-line security/detect-object-injection -- guarded by null check below const preset = PRESETS[name]; @@ -120,7 +121,7 @@ export async function applyPreset(formData: FormData): Promise { } export async function saveCustomTheme(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); const name = String(formData.get("name") ?? "") .normalize("NFC") .trim(); @@ -141,7 +142,7 @@ export async function saveCustomTheme(formData: FormData): Promise { } export async function applyCustomTheme(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); const id = String(formData.get("id") ?? "") .normalize("NFC") .trim(); @@ -167,7 +168,7 @@ export async function applyCustomTheme(formData: FormData): Promise { } export async function renameCustomTheme(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SETTINGS_EDIT); const id = String(formData.get("id") ?? "") .normalize("NFC") .trim(); @@ -186,7 +187,7 @@ export async function renameCustomTheme(formData: FormData): Promise { } export async function deleteCustomTheme(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SETTINGS_EDIT); const id = String(formData.get("id") ?? "") .normalize("NFC") .trim(); diff --git a/src/actions/admin-wordfilter.ts b/src/actions/admin-wordfilter.ts index f53bdd6a..cb52a94c 100644 --- a/src/actions/admin-wordfilter.ts +++ b/src/actions/admin-wordfilter.ts @@ -1,12 +1,13 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; export async function addWord(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.WORDFILTER_EDIT); const word = String(formData.get("word") ?? "") .normalize("NFC") .trim() @@ -23,7 +24,7 @@ export async function addWord(formData: FormData): Promise { } export async function deleteWord(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.WORDFILTER_EDIT); const raw = String(formData.get("id") ?? "").normalize("NFC"); if (!raw) return; diff --git a/src/app/admin/articles/[id]/page.tsx b/src/app/admin/articles/[id]/page.tsx index 207ddf43..66ab9b99 100644 --- a/src/app/admin/articles/[id]/page.tsx +++ b/src/app/admin/articles/[id]/page.tsx @@ -1,9 +1,10 @@ import { Newspaper } from "lucide-react"; import Link from "next/link"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { deleteArticle, updateArticle } from "@/actions/admin-articles"; import { ArticleForm } from "@/components/admin/article-form"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; @@ -15,6 +16,11 @@ export default async function EditArticle({ params: Promise<{ id: string }>; searchParams: Promise<{ error?: string }>; }) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const { id } = await params; const { error } = await searchParams; const t = await getTranslations("pages.admin.articles"); diff --git a/src/app/admin/articles/new/page.tsx b/src/app/admin/articles/new/page.tsx index 47c223cb..162665fd 100644 --- a/src/app/admin/articles/new/page.tsx +++ b/src/app/admin/articles/new/page.tsx @@ -1,14 +1,21 @@ import { Newspaper } from "lucide-react"; import Link from "next/link"; +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { createArticle } from "@/actions/admin-articles"; import { ArticleForm } from "@/components/admin/article-form"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function NewArticle({ searchParams, }: { searchParams: Promise<{ error?: string }>; }) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.NEWS_EDIT, session.user.rank)) { + redirect("/admin"); + } + const { error } = await searchParams; const t = await getTranslations("pages.admin.articles"); diff --git a/src/app/admin/articles/page.tsx b/src/app/admin/articles/page.tsx index 1f0cc7b4..c9384bd5 100644 --- a/src/app/admin/articles/page.tsx +++ b/src/app/admin/articles/page.tsx @@ -1,8 +1,10 @@ import { Newspaper } from "lucide-react"; import Link from "next/link"; +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { deleteArticle } from "@/actions/admin-articles"; import { StatusCard } from "@/components/admin/dashboard"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; @@ -12,6 +14,11 @@ export default async function AdminArticles({ }: { searchParams: Promise<{ error?: string }>; }) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const { error } = await searchParams; const t = await getTranslations("pages.admin.articles"); const articles = await prisma.websiteArticles diff --git a/src/app/admin/bans/page.tsx b/src/app/admin/bans/page.tsx index 45240071..5e52fe2c 100644 --- a/src/app/admin/bans/page.tsx +++ b/src/app/admin/bans/page.tsx @@ -1,7 +1,9 @@ import { Ban } from "lucide-react"; +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { createBan, liftBan } from "@/actions/admin-bans"; import { StatusCard } from "@/components/admin/dashboard"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; @@ -11,6 +13,11 @@ function fromUnix(ts: number): string { } export default async function AdminBans() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.BANS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.bans"); const now = Math.floor(Date.now() / 1000); let bans: Awaited> = []; diff --git a/src/app/admin/emulator/page.tsx b/src/app/admin/emulator/page.tsx index db4c8f70..9987459f 100644 --- a/src/app/admin/emulator/page.tsx +++ b/src/app/admin/emulator/page.tsx @@ -1,9 +1,11 @@ +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { updateEmulatorSetting, updateEmulatorText, } from "@/actions/admin-emulator"; import { StatusCard } from "@/components/admin/dashboard"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; @@ -11,6 +13,11 @@ export const dynamic = "force-dynamic"; const TEXTS_LIMIT = 300; export default async function AdminEmulator() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.emulator"); let settings: { key: string; value: string }[] = []; diff --git a/src/app/admin/radio/embed/page.tsx b/src/app/admin/radio/embed/page.tsx index f7d66b74..14edaac3 100644 --- a/src/app/admin/radio/embed/page.tsx +++ b/src/app/admin/radio/embed/page.tsx @@ -2,7 +2,6 @@ import { Code } from "lucide-react"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; -import { requireStaff } from "@/lib/admin/guard"; import { siteSettings } from "@/lib/services/site-settings"; import { EmbedSnippet } from "./embed-snippet"; @@ -11,8 +10,6 @@ export const dynamic = "force-dynamic"; export const metadata = { title: "Radio Embed" }; export default async function AdminRadioEmbedPage() { - await requireStaff(); - const t = await getTranslations("pages.admin.radio"); let streamUrl = ""; diff --git a/src/app/admin/radio/layout.tsx b/src/app/admin/radio/layout.tsx new file mode 100644 index 00000000..ac93c7c8 --- /dev/null +++ b/src/app/admin/radio/layout.tsx @@ -0,0 +1,15 @@ +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; + +export default async function RadioAdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.RADIO_VIEW, session.user.rank)) { + redirect("/admin"); + } + + return children; +} diff --git a/src/app/admin/radio/monitoring/page.tsx b/src/app/admin/radio/monitoring/page.tsx index 987e5c34..4b240574 100644 --- a/src/app/admin/radio/monitoring/page.tsx +++ b/src/app/admin/radio/monitoring/page.tsx @@ -2,7 +2,6 @@ import { Radio } from "lucide-react"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { DiagnosticRow, StatusCard } from "@/components/admin/dashboard"; -import { requireStaff } from "@/lib/admin/guard"; import { siteSettings } from "@/lib/services/site-settings"; export const dynamic = "force-dynamic"; @@ -166,8 +165,6 @@ function shortUrl(url: string): string { } export default async function AdminRadioMonitoringPage() { - await requireStaff(); - const t = await getTranslations("pages.admin.radio"); let nowPlayingUrl: string | null = null; diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 08636461..e991aa7e 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -1,14 +1,21 @@ +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { createSetting, deleteSetting, updateSetting, } from "@/actions/admin-settings"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; export default async function AdminSettings() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.settings"); let settings: Awaited> = []; diff --git a/src/app/admin/shop/[id]/page.tsx b/src/app/admin/shop/[id]/page.tsx index bd2d01e4..27cf70d2 100644 --- a/src/app/admin/shop/[id]/page.tsx +++ b/src/app/admin/shop/[id]/page.tsx @@ -1,8 +1,9 @@ import { Package } from "lucide-react"; import Link from "next/link"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; @@ -12,6 +13,11 @@ export default async function EditShopArticle({ }: { params: Promise<{ id: string }>; }) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.shop"); const { id } = await params; diff --git a/src/app/admin/shop/new/page.tsx b/src/app/admin/shop/new/page.tsx index 50911974..e6c7fd14 100644 --- a/src/app/admin/shop/new/page.tsx +++ b/src/app/admin/shop/new/page.tsx @@ -1,9 +1,16 @@ import { Package } from "lucide-react"; import Link from "next/link"; +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { createShopArticle } from "@/actions/admin-shop"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function NewShopArticle() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SHOP_EDIT, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.shop"); return ( diff --git a/src/app/admin/shop/page.tsx b/src/app/admin/shop/page.tsx index ac01556f..3283a773 100644 --- a/src/app/admin/shop/page.tsx +++ b/src/app/admin/shop/page.tsx @@ -1,6 +1,8 @@ import Link from "next/link"; +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; @@ -25,6 +27,11 @@ function statusState( } export default async function AdminShop() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.shop"); let packages: Awaited< diff --git a/src/app/admin/theme/page.tsx b/src/app/admin/theme/page.tsx index a4f864e7..e0febe18 100644 --- a/src/app/admin/theme/page.tsx +++ b/src/app/admin/theme/page.tsx @@ -1,3 +1,4 @@ +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { applyCustomTheme, @@ -7,6 +8,7 @@ import { saveTheme, } from "@/actions/admin-theme"; import { RenameTheme } from "@/components/admin/theme/rename-theme"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { siteSettings } from "@/lib/services/site-settings"; import { listCustomThemes } from "@/lib/theme-custom-store"; import { FONTS, PRESETS } from "@/lib/theme-presets"; @@ -317,6 +319,11 @@ export default async function AdminTheme({ deletedTheme?: string; }>; }) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.theme"); const sp = await searchParams; diff --git a/src/app/admin/wordfilter/page.tsx b/src/app/admin/wordfilter/page.tsx index 2c2f13ed..ac240b9d 100644 --- a/src/app/admin/wordfilter/page.tsx +++ b/src/app/admin/wordfilter/page.tsx @@ -1,7 +1,9 @@ import { Filter } from "lucide-react"; +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { addWord, deleteWord } from "@/actions/admin-wordfilter"; import { StatusCard } from "@/components/admin/dashboard"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; @@ -9,6 +11,11 @@ export const dynamic = "force-dynamic"; type WordRow = { id: bigint; word: string }; export default async function AdminWordFilter() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.WORDFILTER_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.wordfilter"); let words: WordRow[] = []; diff --git a/src/lib/admin-operations-contract.test.ts b/src/lib/admin-operations-contract.test.ts index d43b9444..0dd0a3b3 100644 --- a/src/lib/admin-operations-contract.test.ts +++ b/src/lib/admin-operations-contract.test.ts @@ -19,6 +19,13 @@ const ROUTES: Array<[string, string]> = [ ["online", "PERMS.USERS_VIEW"], ["commandocentrum", "PERMS.RCON_EXECUTE"], ["users/edit/[id]", "PERMS.USERS_EDIT"], + ["settings", "PERMS.SETTINGS_VIEW"], + ["theme", "PERMS.SETTINGS_VIEW"], + ["emulator", "PERMS.SETTINGS_VIEW"], + ["bans", "PERMS.BANS_VIEW"], + ["wordfilter", "PERMS.WORDFILTER_VIEW"], + ["articles", "PERMS.NEWS_VIEW"], + ["shop", "PERMS.SHOP_VIEW"], ]; describe("admin operations route contract", () => { @@ -28,6 +35,12 @@ describe("admin operations route contract", () => { expect(readFileSync(path, "utf8"), path).toContain(permission); }); + it("guards radio section via layout", () => { + const path = "src/app/admin/radio/layout.tsx"; + expect(existsSync(path), path).toBe(true); + expect(readFileSync(path, "utf8"), path).toContain("PERMS.RADIO_VIEW"); + }); + it.each([ ["analytics/export", "PERMS.ANALYTICS_EXPORT"], ["devops/health", "PERMS.DEVOPS_VIEW"], @@ -55,4 +68,19 @@ describe("admin operations route contract", () => { expect(source).toContain("PERMS.RCON_EXECUTE"); expect(source).not.toContain("requireStaff()"); }); + + it.each([ + ["admin-settings.ts", "PERMS.SETTINGS_EDIT"], + ["admin-theme.ts", "PERMS.SETTINGS_EDIT"], + ["admin-emulator.ts", "PERMS.SETTINGS_EDIT"], + ["admin-bans.ts", "PERMS.USERS_BAN"], + ["admin-wordfilter.ts", "PERMS.WORDFILTER_EDIT"], + ["admin-articles.ts", "PERMS.NEWS_EDIT"], + ["admin-shop.ts", "PERMS.SHOP_EDIT"], + ["admin-radio-autodj.ts", "PERMS.RADIO_EDIT"], + ])("guards %s mutations with %s", (file, permission) => { + const source = readFileSync(`src/actions/${file}`, "utf8"); + expect(source).toContain(permission); + expect(source).not.toMatch(/await requireStaff\(\)/); + }); }); diff --git a/src/lib/admin/acl-migration-contract.test.ts b/src/lib/admin/acl-migration-contract.test.ts index ae886f06..a4afeeed 100644 --- a/src/lib/admin/acl-migration-contract.test.ts +++ b/src/lib/admin/acl-migration-contract.test.ts @@ -1,32 +1,34 @@ -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { PERMS } from "@/lib/permission-slugs"; describe("production ACL migration", () => { - const migrationPath = resolve( - process.cwd(), - "prisma/migrations/0012_seed_acl_permissions.sql", - ); + const migrationsDir = resolve(process.cwd(), "prisma/migrations"); + const allSql = readdirSync(migrationsDir) + .filter((name) => name.endsWith(".sql")) + .map((name) => readFileSync(resolve(migrationsDir, name), "utf8")) + .join("\n"); it("seeds every permission used by the application", () => { - const sql = readFileSync(migrationPath, "utf8"); - for (const slug of Object.values(PERMS)) { - expect(sql, `missing ACL seed for ${slug}`).toContain(`'${slug}'`); + expect(allSql, `missing ACL seed for ${slug}`).toContain(`'${slug}'`); } }); it("assigns dashboard access after seeding permissions", () => { - const sql = readFileSync(migrationPath, "utf8"); - const seedPosition = sql.indexOf("INSERT INTO `acl_permissions`"); - const assignmentPosition = sql.indexOf( + const seedSql = readFileSync( + resolve(migrationsDir, "0012_seed_acl_permissions.sql"), + "utf8", + ); + const seedPosition = seedSql.indexOf("INSERT INTO `acl_permissions`"); + const assignmentPosition = seedSql.indexOf( "INSERT INTO `acl_model_permissions`", ); expect(seedPosition).toBeGreaterThanOrEqual(0); expect(assignmentPosition).toBeGreaterThan(seedPosition); - expect(sql).toContain("ap.slug = 'admin.dashboard'"); - expect(sql).toContain("ap.slug = 'mod.dashboard'"); + expect(seedSql).toContain("ap.slug = 'admin.dashboard'"); + expect(seedSql).toContain("ap.slug = 'mod.dashboard'"); }); }); diff --git a/src/lib/admin/guard.ts b/src/lib/admin/guard.ts index 4a539a8b..eb977571 100644 --- a/src/lib/admin/guard.ts +++ b/src/lib/admin/guard.ts @@ -39,6 +39,16 @@ export async function requirePermission(permission: string): Promise }; } +export async function requirePermissionRateLimited( + permission: string, +): Promise { + const staff = await requirePermission(permission); + const ip = await clientIp(); + if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok) + redirectSafe("/admin?error=ratelimit", "/admin"); + return staff; +} + export async function requireStaffRateLimited(): Promise { const staff = await requireStaff(); const ip = await clientIp(); diff --git a/src/lib/permission-slugs.ts b/src/lib/permission-slugs.ts index 8341badd..120ff8a5 100644 --- a/src/lib/permission-slugs.ts +++ b/src/lib/permission-slugs.ts @@ -26,6 +26,9 @@ export const PERMS = { CATALOG_EDIT: "admin.catalog.edit", RCON_EXECUTE: "admin.rcon.execute", EXPORT: "admin.export", + // ── Radio Module ── + RADIO_VIEW: "admin.radio.view", + RADIO_EDIT: "admin.radio.edit", PREFIXES_VIEW: "admin.prefixes.view", PREFIXES_EDIT: "admin.prefixes.edit", TICKETS_VIEW: "admin.tickets.view",