Extend fine-grained ACL to settings, content, shop, and radio.
Local Build and Deploy / deploy (push) Successful in 54s

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 <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-15 20:11:55 +02:00
1 parent f2427b3483
commit 3c8a8ff888
32 files changed
+244 -70

No files matched your search

@@ -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'),
@@ -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
);
+5 -4
View File
@@ -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<string> {
}
export async function createArticle(formData: FormData): Promise<void> {
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<void> {
}
export async function updateArticle(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteArticle(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id")));
try {
await prisma.$transaction([
+4 -3
View File
@@ -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<string> = new Set([
const PERMANENT_SECONDS = 100 * 365 * 24 * 3600;
export async function createBan(formData: FormData): Promise<void> {
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<void> {
}
export async function liftBan(formData: FormData): Promise<void> {
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 } });
+4 -3
View File
@@ -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<void> {
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<void> {
}
export async function updateEmulatorText(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
+5 -4
View File
@@ -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<void> {
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<void> {
}
export async function toggleApiKey(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteApiKey(formData: FormData): Promise<void> {
const staff = await requireStaff();
const staff = await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id"));
if (id == null) return;
+5 -4
View File
@@ -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<void> {
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<void> {
}
export async function toggleTrack(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteTrack(formData: FormData): Promise<void> {
const staff = await requireStaff();
const staff = await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id"));
if (id === null) return;
+10 -9
View File
@@ -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<void> {
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<void> {
* only touch those (and never wipe unrelated settings).
*/
export async function saveRadioSettings(formData: FormData): Promise<void> {
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<void> {
// ── Radio banners CRUD (radio_banners) ─────────────────────────────────────
export async function createRadioBanner(formData: FormData): Promise<void> {
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<void> {
}
export async function updateRadioBanner(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteRadioBanner(formData: FormData): Promise<void> {
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<void> {
// ── Radio ranks CRUD (radio_ranks) ─────────────────────────────────────────
export async function createRadioRank(formData: FormData): Promise<void> {
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<void> {
}
export async function updateRadioRank(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteRadioRank(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id"));
if (id === null) return;
try {
+3 -2
View File
@@ -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<void> {
const staff = await requireStaff();
const staff = await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id"));
if (id === null) return;
+3 -2
View File
@@ -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<void> {
const staff = await requireStaff();
const staff = await requirePermission(PERMS.RADIO_EDIT);
const currencyRaw = str(formData.get("radio_points_currency"))
.trim()
+5 -4
View File
@@ -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<void> {
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<void> {
}
export async function createSetting(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteSetting(formData: FormData): Promise<void> {
await requireStaff();
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim();
+5 -4
View File
@@ -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<void> {
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<void> {
}
export async function updateShopArticle(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff();
const staff = await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
+8 -7
View File
@@ -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<void> {
}
export async function saveTheme(formData: FormData): Promise<void> {
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<void> {
}
export async function applyPreset(formData: FormData): Promise<void> {
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<void> {
}
export async function saveCustomTheme(formData: FormData): Promise<void> {
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<void> {
}
export async function applyCustomTheme(formData: FormData): Promise<void> {
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<void> {
}
export async function renameCustomTheme(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteCustomTheme(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.SETTINGS_EDIT);
const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
+4 -3
View File
@@ -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<void> {
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<void> {
}
export async function deleteWord(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.WORDFILTER_EDIT);
const raw = String(formData.get("id") ?? "").normalize("NFC");
if (!raw) return;
+7 -1
View File
@@ -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");
+7
View File
@@ -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");
+7
View File
@@ -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
+7
View File
@@ -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<ReturnType<typeof prisma.ban.findMany>> = [];
+7
View File
@@ -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 }[] = [];
-3
View File
@@ -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 = "";
+15
View File
@@ -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;
}
-3
View File
@@ -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;
+7
View File
@@ -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<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
+7 -1
View File
@@ -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;
+7
View File
@@ -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 (
+7
View File
@@ -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<
+7
View File
@@ -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;
+7
View File
@@ -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[] = [];
+28
View File
@@ -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\(\)/);
});
});
+15 -13
View File
@@ -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'");
});
});
+10
View File
@@ -39,6 +39,16 @@ export async function requirePermission(permission: string): Promise<StaffUser>
};
}
export async function requirePermissionRateLimited(
permission: string,
): Promise<StaffUser> {
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<StaffUser> {
const staff = await requireStaff();
const ip = await clientIp();
+3
View File
@@ -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",