Extend fine-grained ACL to settings, content, shop, and radio.
Local Build and Deploy / deploy (push) Successful in 54s
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:
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
|
||||
);
|
||||
@@ -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([
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>> = [];
|
||||
|
||||
@@ -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 }[] = [];
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>> = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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\(\)/);
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user