From 0e89d039409d21497bf3685c13ad976e74373eed Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 15 Jul 2026 20:15:22 +0200 Subject: [PATCH] Finish fine-grained ACL across remaining admin pages and actions. Replace leftover requireStaff gates with module PERMS, drop hardcoded room rank thresholds, and expand contract tests so admin mutations cannot regress to dashboard-only checks. Co-authored-by: Cursor --- src/actions/admin-ads.ts | 9 +- src/actions/admin-alerts.ts | 5 +- src/actions/admin-applications.ts | 5 +- src/actions/admin-badge-upload.ts | 5 +- src/actions/admin-badges.ts | 5 +- src/actions/admin-email-templates.ts | 9 +- src/actions/admin-help.ts | 9 +- src/actions/admin-housekeeping.ts | 7 +- src/actions/admin-ip.ts | 11 ++- src/actions/admin-maintenance.ts | 5 +- src/actions/admin-media.ts | 9 +- src/actions/admin-permissions.ts | 9 +- src/actions/admin-photos.ts | 5 +- src/actions/admin-rare-values.ts | 11 ++- src/actions/admin-tags.ts | 9 +- src/actions/admin-teams.ts | 7 +- src/actions/admin-users.ts | 13 +-- src/actions/admin-vouchers.ts | 7 +- src/actions/admin-vpn.ts | 5 +- src/actions/admin-writeable-boxes.ts | 11 ++- src/actions/badges.ts | 7 +- src/actions/bulk-users.ts | 11 ++- src/actions/catalog-bc.ts | 15 +-- src/actions/catalog-items.ts | 15 +-- src/actions/catalog.ts | 15 +-- src/actions/import-badges.ts | 5 +- src/actions/multi-account-detect.ts | 5 +- src/actions/rooms.ts | 15 +-- src/actions/soundtracks.ts | 7 +- src/app/admin/achievements/page.tsx | 7 ++ src/app/admin/ads/[id]/page.tsx | 8 +- src/app/admin/ads/new/page.tsx | 9 +- src/app/admin/ads/page.tsx | 7 ++ src/app/admin/alerts/page.tsx | 7 ++ src/app/admin/applications/page.tsx | 7 ++ src/app/admin/badges/page.tsx | 7 ++ src/app/admin/calendar/[id]/page.tsx | 8 +- src/app/admin/calendar/page.tsx | 7 ++ src/app/admin/email-templates/page.tsx | 7 ++ src/app/admin/favicon/page.tsx | 7 ++ src/app/admin/help-questions/[id]/page.tsx | 8 +- src/app/admin/help-questions/new/page.tsx | 7 ++ src/app/admin/help-questions/page.tsx | 7 ++ src/app/admin/housekeeping/page.tsx | 7 ++ src/app/admin/ip/page.tsx | 7 ++ src/app/admin/logs/page.tsx | 7 ++ src/app/admin/maintenance/page.tsx | 7 ++ src/app/admin/media/page.tsx | 7 ++ src/app/admin/navigation/page.tsx | 7 ++ src/app/admin/photos/page.tsx | 7 ++ src/app/admin/rare-values/page.tsx | 7 ++ src/app/admin/rooms/[id]/furni/page.tsx | 2 +- src/app/admin/rooms/edit/[id]/page.tsx | 5 +- src/app/admin/rooms/page.tsx | 4 +- src/app/admin/rooms/show/[id]/page.tsx | 2 +- src/app/admin/subscriptions/page.tsx | 7 ++ src/app/admin/tags/page.tsx | 7 ++ src/app/admin/teams/page.tsx | 7 ++ src/app/admin/transactions/page.tsx | 8 +- src/app/admin/vouchers/page.tsx | 7 ++ src/app/admin/vpn/page.tsx | 7 ++ src/app/admin/writeable-boxes/page.tsx | 7 ++ src/lib/admin-operations-contract.test.ts | 110 ++++++++++++++------- 63 files changed, 420 insertions(+), 163 deletions(-) diff --git a/src/actions/admin-ads.ts b/src/actions/admin-ads.ts index 0e380313..b369e93b 100644 --- a/src/actions/admin-ads.ts +++ b/src/actions/admin-ads.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 { logStaffActivity } from "@/lib/services/staff-activity"; @@ -11,7 +12,7 @@ import { logStaffActivity } from "@/lib/services/staff-activity"; // table; it only stores an image URL rendered in the site layout/widgets. export async function createAd(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const image = String(formData.get("image") ?? "") .normalize("NFC") .trim() @@ -39,7 +40,7 @@ export async function createAd(formData: FormData): Promise { } export async function updateAd(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const raw = String(formData.get("id") ?? "").normalize("NFC"); if (!/^\d+$/.test(raw)) return; const id = BigInt(raw); @@ -70,7 +71,7 @@ export async function updateAd(formData: FormData): Promise { } export async function deleteAd(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) return; diff --git a/src/actions/admin-alerts.ts b/src/actions/admin-alerts.ts index 787e3e40..f2eaa14e 100644 --- a/src/actions/admin-alerts.ts +++ b/src/actions/admin-alerts.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 { rcon } from "@/lib/services/rcon"; /** @@ -11,7 +12,7 @@ import { rcon } from "@/lib/services/rcon"; * `message` payload. Staff-gated; the message is trimmed/bounded before send. */ export async function sendHotelAlert(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.NOTIFICATIONS_EDIT); const message = String(formData.get("message") ?? "") .normalize("NFC") diff --git a/src/actions/admin-applications.ts b/src/actions/admin-applications.ts index ad560890..1088fe35 100644 --- a/src/actions/admin-applications.ts +++ b/src/actions/admin-applications.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 { formPositiveBigInt } from "@/lib/form-data"; import { prisma } from "@/lib/prisma"; export async function dismissApplication(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.USERS_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) return; diff --git a/src/actions/admin-badge-upload.ts b/src/actions/admin-badge-upload.ts index f8d48d02..e93f335f 100644 --- a/src/actions/admin-badge-upload.ts +++ b/src/actions/admin-badge-upload.ts @@ -3,7 +3,8 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; import { redirect } from "next/navigation"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { logStaffActivity } from "@/lib/services/staff-activity"; // Writes a badge image to the configured emulator badge directory. The path is @@ -20,7 +21,7 @@ function back(param: string, value: string): never { } export async function uploadBadge(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); const dir = process.env.BADGE_UPLOAD_DIR; if (!dir) { diff --git a/src/actions/admin-badges.ts b/src/actions/admin-badges.ts index 608d1b0e..1835b6c2 100644 --- a/src/actions/admin-badges.ts +++ b/src/actions/admin-badges.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 giveBadge(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.CATALOG_EDIT); const userId = Number(formData.get("userId")); const code = String(formData.get("code") ?? "") diff --git a/src/actions/admin-email-templates.ts b/src/actions/admin-email-templates.ts index d73521f7..f1e98d46 100644 --- a/src/actions/admin-email-templates.ts +++ b/src/actions/admin-email-templates.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 { formPositiveBigInt } from "@/lib/form-data"; import { prisma } from "@/lib/prisma"; export async function createEmailTemplate(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.PAGES_EDIT); const name = String(formData.get("name") ?? "") .normalize("NFC") .trim() @@ -35,7 +36,7 @@ export async function createEmailTemplate(formData: FormData): Promise { } export async function updateEmailTemplate(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.PAGES_EDIT); const raw = String(formData.get("id") ?? "").normalize("NFC"); if (!raw) return; let id: bigint; @@ -68,7 +69,7 @@ export async function updateEmailTemplate(formData: FormData): Promise { } export async function deleteEmailTemplate(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.PAGES_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) return; await prisma.emailTemplates.delete({ where: { id } }); diff --git a/src/actions/admin-help.ts b/src/actions/admin-help.ts index 1cf277a2..f703f79a 100644 --- a/src/actions/admin-help.ts +++ b/src/actions/admin-help.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 { logStaffActivity } from "@/lib/services/staff-activity"; @@ -16,7 +17,7 @@ function parsePosition(value: FormDataEntryValue | null): number { } export async function createHelpQuestion(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const name = String(formData.get("name") ?? "") .normalize("NFC") .trim() @@ -82,7 +83,7 @@ export async function createHelpQuestion(formData: FormData): Promise { } export async function updateHelpQuestion(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) return; @@ -149,7 +150,7 @@ export async function updateHelpQuestion(formData: FormData): Promise { } export async function deleteHelpQuestion(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) return; diff --git a/src/actions/admin-housekeeping.ts b/src/actions/admin-housekeeping.ts index 3b3068cc..277451c0 100644 --- a/src/actions/admin-housekeeping.ts +++ b/src/actions/admin-housekeeping.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"; /** @@ -9,7 +10,7 @@ import { prisma } from "@/lib/prisma"; * string). Mirrors AtomCMS' housekeeping permission management. */ export async function upsertPermission(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SETTINGS_EDIT); const permission = String(formData.get("permission") ?? "") .normalize("NFC") @@ -38,7 +39,7 @@ export async function upsertPermission(formData: FormData): Promise { } export async function deletePermission(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SETTINGS_EDIT); const raw = String(formData.get("id") ?? "").normalize("NFC"); if (!raw) return; diff --git a/src/actions/admin-ip.ts b/src/actions/admin-ip.ts index 8900f54a..797b101d 100644 --- a/src/actions/admin-ip.ts +++ b/src/actions/admin-ip.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"; function parseIp(formData: FormData): string { @@ -20,7 +21,7 @@ function parseAsn(formData: FormData): string | null { } export async function addWhitelist(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SETTINGS_EDIT); const ipAddress = parseIp(formData); if (!ipAddress) return; const asn = parseAsn(formData); @@ -31,7 +32,7 @@ export async function addWhitelist(formData: FormData): Promise { } export async function deleteWhitelist(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SETTINGS_EDIT); const raw = String(formData.get("id") ?? "") .normalize("NFC") .trim(); @@ -41,7 +42,7 @@ export async function deleteWhitelist(formData: FormData): Promise { } export async function addBlacklist(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SETTINGS_EDIT); const ipAddress = parseIp(formData); if (!ipAddress) return; const asn = parseAsn(formData); @@ -52,7 +53,7 @@ export async function addBlacklist(formData: FormData): Promise { } export async function deleteBlacklist(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SETTINGS_EDIT); const raw = String(formData.get("id") ?? "") .normalize("NFC") .trim(); diff --git a/src/actions/admin-maintenance.ts b/src/actions/admin-maintenance.ts index 2c7d6d4b..5636c55b 100644 --- a/src/actions/admin-maintenance.ts +++ b/src/actions/admin-maintenance.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"; @@ -35,7 +36,7 @@ async function upsertSetting(key: string, value: string): Promise { } export async function saveMaintenance(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SETTINGS_EDIT); // Checkbox: present only when ticked. Normalise to the '1'/'0' string the // emulator/Laravel side expects. diff --git a/src/actions/admin-media.ts b/src/actions/admin-media.ts index 6487af56..1d7982b9 100644 --- a/src/actions/admin-media.ts +++ b/src/actions/admin-media.ts @@ -3,14 +3,15 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { revalidatePath } from "next/cache"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; const MEDIA_DIR = "public/assets/images/media"; const MAX_SIZE = 5 * 1024 * 1024; // 5MB const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"]; export async function uploadMedia(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.PAGES_EDIT); const file = formData.get("file") as File | null; if (!file || file.size === 0) return; if (file.size > MAX_SIZE) return; @@ -33,7 +34,7 @@ export async function uploadMedia(formData: FormData): Promise { } export async function deleteMedia(name: string): Promise { - await requireStaff(); + await requirePermission(PERMS.PAGES_EDIT); const { unlink } = await import("node:fs/promises"); const baseDir = path.resolve(process.cwd(), MEDIA_DIR); const filePath = path.resolve(baseDir, name); @@ -50,7 +51,7 @@ export async function deleteMedia(name: string): Promise { export async function uploadMediaAndReturn( formData: FormData, ): Promise { - await requireStaff(); + await requirePermission(PERMS.PAGES_EDIT); const file = formData.get("file") as File | null; if (!file || file.size === 0) return ""; if (file.size > MAX_SIZE) return ""; diff --git a/src/actions/admin-permissions.ts b/src/actions/admin-permissions.ts index 1a716c72..8b5d1521 100644 --- a/src/actions/admin-permissions.ts +++ b/src/actions/admin-permissions.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"; @@ -23,7 +24,7 @@ function parseMinRank(formData: FormData): number { } export async function createPermission(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE); const permission = String(formData.get("permission") ?? "") .normalize("NFC") .trim() @@ -67,7 +68,7 @@ export async function createPermission(formData: FormData): Promise { } export async function updatePermission(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE); const raw = String(formData.get("id") ?? "").normalize("NFC"); if (!raw) return; const id = BigInt(raw); @@ -106,7 +107,7 @@ export async function updatePermission(formData: FormData): Promise { } export async function deletePermission(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE); const id = formPositiveBigInt(formData, "id"); if (!id) return; diff --git a/src/actions/admin-photos.ts b/src/actions/admin-photos.ts index ea6ba0e7..1b7e5773 100644 --- a/src/actions/admin-photos.ts +++ b/src/actions/admin-photos.ts @@ -1,11 +1,12 @@ "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"; export async function deletePhoto(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.PAGES_EDIT); const id = Number(formData.get("id")); if (!(id > 0)) return; diff --git a/src/actions/admin-rare-values.ts b/src/actions/admin-rare-values.ts index b244db58..6c9e35b4 100644 --- a/src/actions/admin-rare-values.ts +++ b/src/actions/admin-rare-values.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 { formPositiveBigInt } from "@/lib/form-data"; import { prisma } from "@/lib/prisma"; export async function createCategory(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SHOP_EDIT); const name = String(formData.get("name") ?? "") .normalize("NFC") .trim() @@ -33,7 +34,7 @@ export async function createCategory(formData: FormData): Promise { } export async function deleteCategory(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SHOP_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) return; @@ -48,7 +49,7 @@ export async function deleteCategory(formData: FormData): Promise { } export async function createValue(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SHOP_EDIT); const categoryId = formPositiveBigInt(formData, "categoryId"); if (!categoryId) return; @@ -98,7 +99,7 @@ export async function createValue(formData: FormData): Promise { } export async function deleteValue(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SHOP_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) return; diff --git a/src/actions/admin-tags.ts b/src/actions/admin-tags.ts index 619e0b0a..7d08f355 100644 --- a/src/actions/admin-tags.ts +++ b/src/actions/admin-tags.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"; @@ -31,7 +32,7 @@ function normaliseColor(raw: string): string { // ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ────────── export async function createTag(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const name = str(formData.get("name")).trim().slice(0, 255); if (!name) return; @@ -56,7 +57,7 @@ export async function createTag(formData: FormData): Promise { } export async function updateTag(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const id = parseId(formData.get("id")); if (id === null) return; @@ -83,7 +84,7 @@ export async function updateTag(formData: FormData): Promise { } export async function deleteTag(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const id = parseId(formData.get("id")); if (id === null) return; diff --git a/src/actions/admin-teams.ts b/src/actions/admin-teams.ts index 022e03f4..3e47c340 100644 --- a/src/actions/admin-teams.ts +++ b/src/actions/admin-teams.ts @@ -1,11 +1,12 @@ "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"; export async function createTeam(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.USERS_EDIT); const rankName = String(formData.get("rankName") ?? "") .normalize("NFC") @@ -41,7 +42,7 @@ export async function createTeam(formData: FormData): Promise { } export async function deleteTeam(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.USERS_EDIT); const id = BigInt(String(formData.get("id"))); await prisma.websiteTeams.delete({ where: { id } }); diff --git a/src/actions/admin-users.ts b/src/actions/admin-users.ts index bf3baf60..1b81c803 100644 --- a/src/actions/admin-users.ts +++ b/src/actions/admin-users.ts @@ -1,7 +1,8 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireStaff, requireStaffRateLimited } from "@/lib/admin/guard"; +import { requirePermission, requirePermissionRateLimited } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency"; @@ -15,7 +16,7 @@ const CURRENCIES: ReadonlySet = new Set([ ]); export async function giveCurrency(formData: FormData): Promise { - const staff = await requireStaffRateLimited(); + const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT); const userId = Number(formData.get("userId")); const type = String(formData.get("type")); const amount = Number(formData.get("amount")); @@ -38,7 +39,7 @@ export async function giveCurrency(formData: FormData): Promise { } export async function setMotto(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.USERS_EDIT); const userId = Number(formData.get("userId")); const motto = String(formData.get("motto") ?? "") .normalize("NFC") @@ -51,7 +52,7 @@ export async function setMotto(formData: FormData): Promise { } export async function setRank(formData: FormData): Promise { - const staff = await requireStaffRateLimited(); + const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT); const userId = Number(formData.get("userId")); const rank = Number(formData.get("rank")); if (userId > 0 && rank > 0) { @@ -69,7 +70,7 @@ export async function setRank(formData: FormData): Promise { } export async function alertUser(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.USERS_EDIT); const userId = Number(formData.get("userId")); const message = String(formData.get("message") ?? "") .normalize("NFC") @@ -78,7 +79,7 @@ export async function alertUser(formData: FormData): Promise { } export async function disconnectUser(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.USERS_EDIT); const userId = Number(formData.get("userId")); const username = String(formData.get("username") ?? "").normalize("NFC"); if (userId > 0) await rcon.disconnectUser(userId, username); diff --git a/src/actions/admin-vouchers.ts b/src/actions/admin-vouchers.ts index aec1794a..f6c5ddb3 100644 --- a/src/actions/admin-vouchers.ts +++ b/src/actions/admin-vouchers.ts @@ -1,13 +1,14 @@ "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 { formPositiveBigInt } from "@/lib/form-data"; import { prisma } from "@/lib/prisma"; import { logServerError } from "@/lib/server-log"; export async function createVoucher(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SHOP_EDIT); const code = String(formData.get("code") ?? "") .normalize("NFC") @@ -53,7 +54,7 @@ export async function createVoucher(formData: FormData): Promise { } export async function deleteVoucher(formData: FormData): Promise { - await requireStaff(); + await requirePermission(PERMS.SHOP_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) return; diff --git a/src/actions/admin-vpn.ts b/src/actions/admin-vpn.ts index 93e4caae..2385c0e5 100644 --- a/src/actions/admin-vpn.ts +++ b/src/actions/admin-vpn.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"; @@ -28,7 +29,7 @@ async function writeSetting( } export async function saveVpn(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); // Toggle: an unchecked checkbox submits nothing, so absence === disabled. const enabled = diff --git a/src/actions/admin-writeable-boxes.ts b/src/actions/admin-writeable-boxes.ts index 178fc77b..95b14eda 100644 --- a/src/actions/admin-writeable-boxes.ts +++ b/src/actions/admin-writeable-boxes.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"; @@ -40,7 +41,7 @@ function revalidate(): void { } export async function createBox(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const title = String(formData.get("title") ?? "") .normalize("NFC") @@ -82,7 +83,7 @@ export async function createBox(formData: FormData): Promise { } export async function updateBox(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const id = parseId(formData); if (id == null) return; @@ -125,7 +126,7 @@ export async function updateBox(formData: FormData): Promise { } export async function deleteBox(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const id = parseId(formData); if (id == null) return; @@ -147,7 +148,7 @@ export async function deleteBox(formData: FormData): Promise { } export async function toggleBox(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.PAGES_EDIT); const id = parseId(formData); if (id == null) return; diff --git a/src/actions/badges.ts b/src/actions/badges.ts index 9a6c90dc..d1dfdc68 100644 --- a/src/actions/badges.ts +++ b/src/actions/badges.ts @@ -1,11 +1,12 @@ "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"; export async function getBadgeData({ code }: { code: string }) { - await requireStaff(); + await requirePermission(PERMS.CATALOG_EDIT); const badge = await prisma.websiteBadges.findUnique({ where: { badgeKey: code }, select: { badgeName: true, badgeDescription: true }, @@ -26,7 +27,7 @@ export async function updateBadge({ name: string; desc: string; }) { - await requireStaff(); + await requirePermission(PERMS.CATALOG_EDIT); await prisma.websiteBadges.upsert({ where: { badgeKey: code }, update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() }, diff --git a/src/actions/bulk-users.ts b/src/actions/bulk-users.ts index 92739068..a6c99215 100644 --- a/src/actions/bulk-users.ts +++ b/src/actions/bulk-users.ts @@ -1,6 +1,7 @@ "use server"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import type { ActionResult } from "@/lib/safe-action-shared"; import { rcon } from "@/lib/services/rcon"; @@ -11,7 +12,7 @@ export async function bulkUnban({ }: { userIds: number[]; }): Promise> { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.USERS_EDIT); const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } }, }); @@ -36,7 +37,7 @@ export async function bulkBan({ reason: string; duration: number; }): Promise> { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.USERS_EDIT); const now = Math.floor(Date.now() / 1000); let banned = 0; @@ -84,7 +85,7 @@ export async function bulkGiveCurrency({ failedIds: Array<{ userId: number; reason: string }>; }> > { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.USERS_EDIT); let given = 0; const failedIds: Array<{ userId: number; reason: string }> = []; @@ -142,7 +143,7 @@ export async function bulkGiveBadge({ failedIds: Array<{ userId: number; reason: string }>; }> > { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.USERS_EDIT); let given = 0; const failedIds: Array<{ userId: number; reason: string }> = []; diff --git a/src/actions/catalog-bc.ts b/src/actions/catalog-bc.ts index 719280ac..6d714e5f 100644 --- a/src/actions/catalog-bc.ts +++ b/src/actions/catalog-bc.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 { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -10,7 +11,7 @@ export async function updateBcPage({ id, ...fields }: { id: number } & Record) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); await prisma.catalogPagesBc.update({ where: { id }, data: fields as any }); await rcon.updateCatalog(); await logStaffActivity({ @@ -25,7 +26,7 @@ export async function updateBcPage({ } export async function deleteBcItem({ id }: { id: number }) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); await prisma.catalogItemsBc.delete({ where: { id } }); await logStaffActivity({ staffId: staff.id, @@ -47,7 +48,7 @@ export async function updateBcItem({ orderNumber?: number; extradata?: string; }) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); await prisma.catalogItemsBc.update({ where: { id }, data: data as any }); await logStaffActivity({ staffId: staff.id, @@ -69,7 +70,7 @@ export async function createBcItem({ orderNumber: number; extradata: string; }) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); const created = await prisma.catalogItemsBc.create({ data: { pageId, ...data }, }); @@ -90,7 +91,7 @@ export async function toggleBcPage({ id: number; field: "enabled" | "visible"; }) { - await requireStaff(); + await requirePermission(PERMS.CATALOG_EDIT); const page = await prisma.catalogPagesBc.findUnique({ where: { id }, select: { enabled: true, visible: true }, @@ -108,7 +109,7 @@ export async function createBcPage(input: { parentId: number; pageLayout: string; }) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); const created = await prisma.catalogPagesBc.create({ data: { caption: input.caption, diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index b6d2bfef..0420d85d 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.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 { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -23,7 +24,7 @@ export async function createCatalogItem(data: { haveOffer: "0" | "1"; clubOnly: "0" | "1"; }) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); const created = await prisma.catalogItems.create({ data }); await rcon.updateCatalog(); await logStaffActivity({ @@ -38,7 +39,7 @@ export async function createCatalogItem(data: { } export async function deleteCatalogItems({ ids }: { ids: number[] }) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); await prisma.catalogItems.deleteMany({ where: { id: { in: ids } } }); await rcon.updateCatalog(); await logStaffActivity({ @@ -58,7 +59,7 @@ export async function moveCatalogItems({ ids: number[]; targetPageId: number; }) { - await requireStaff(); + await requirePermission(PERMS.CATALOG_EDIT); await prisma.catalogItems.updateMany({ where: { id: { in: ids } }, data: { pageId: targetPageId }, @@ -73,7 +74,7 @@ export async function reorderCatalogItems({ }: { orders: Array<{ id: number; orderNumber: number }>; }) { - await requireStaff(); + await requirePermission(PERMS.CATALOG_EDIT); for (const { id, orderNumber } of orders) { await prisma.catalogItems.update({ where: { id }, data: { orderNumber } }); } @@ -91,7 +92,7 @@ export async function updateCatalogItem({ catalogFields: Record; baseItem?: { id: number; fields: Record }; }) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); await prisma.catalogItems.update({ where: { id }, data: catalogFields as any, @@ -119,7 +120,7 @@ export async function translateCatalogItems({ }: { items: Array<{ id: number; publicName: string; description: string }>; }) { - await requireStaff(); + await requirePermission(PERMS.CATALOG_EDIT); let namesUpdated = 0; let descriptionsUpdated = 0; let furniDataUpdated = 0; diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index 26d62c23..e2a20f35 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.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 type { ActionResult } from "@/lib/safe-action-shared"; import { rcon } from "@/lib/services/rcon"; @@ -11,7 +12,7 @@ export async function updateCatalogPage({ id, ...fields }: { id: number } & Record): Promise { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); await prisma.catalogPages.update({ where: { id }, data: fields as any }); await rcon.updateCatalog(); await logStaffActivity({ @@ -26,7 +27,7 @@ export async function updateCatalogPage({ } export async function deleteCatalogPage({ id }: { id: number }) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); await prisma.catalogPages.delete({ where: { id } }); await rcon.updateCatalog(); await logStaffActivity({ @@ -47,7 +48,7 @@ export async function toggleCatalogPage({ id: number; action: "toggleEnabled" | "toggleVisible"; }) { - await requireStaff(); + await requirePermission(PERMS.CATALOG_EDIT); const page = await prisma.catalogPages.findUnique({ where: { id }, select: { enabled: true, visible: true }, @@ -74,7 +75,7 @@ export async function createCatalogPage(input: { minRank?: number; orderNum?: number; }): Promise> { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); const created = await prisma.catalogPages.create({ data: { caption: input.caption, @@ -110,7 +111,7 @@ export async function reorderTreePage(input: { newParentId?: number; newOrderNum: number; }) { - await requireStaff(); + await requirePermission(PERMS.CATALOG_EDIT); await prisma.catalogPages.update({ where: { id: input.pageId }, data: { @@ -129,7 +130,7 @@ export async function deleteTreePage(input: { pageId: number; mode: "reparent" | "cascade"; }) { - await requireStaff(); + await requirePermission(PERMS.CATALOG_EDIT); if (input.mode === "cascade") { const children = await prisma.catalogPages.findMany({ where: { parentId: input.pageId }, diff --git a/src/actions/import-badges.ts b/src/actions/import-badges.ts index 40e91de0..dac0ed66 100644 --- a/src/actions/import-badges.ts +++ b/src/actions/import-badges.ts @@ -1,6 +1,7 @@ "use server"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; export async function importBadgeFromRemote({ @@ -12,7 +13,7 @@ export async function importBadgeFromRemote({ name: string; description: string; }) { - await requireStaff(); + await requirePermission(PERMS.ASSETS_IMPORT); try { await prisma.websiteBadges.upsert({ where: { badgeKey: code }, diff --git a/src/actions/multi-account-detect.ts b/src/actions/multi-account-detect.ts index 6e676d81..5562ae40 100644 --- a/src/actions/multi-account-detect.ts +++ b/src/actions/multi-account-detect.ts @@ -1,6 +1,7 @@ "use server"; -import { requireStaff } from "@/lib/admin/guard"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; export interface MultiAccountCluster { @@ -22,7 +23,7 @@ export async function detectMultiAccounts({ minAccounts: number; limit: number; }) { - await requireStaff(); + await requirePermission(PERMS.USERS_VIEW); const clusters: MultiAccountCluster[] = []; const ipGroups = await prisma.user.groupBy({ diff --git a/src/actions/rooms.ts b/src/actions/rooms.ts index 298d6aa7..8e44d43a 100644 --- a/src/actions/rooms.ts +++ b/src/actions/rooms.ts @@ -1,13 +1,14 @@ "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"; import { logStaffActivity } from "@/lib/services/staff-activity"; export async function updateRoomItem(payload: Record) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.ROOMS_EDIT); const { roomId, itemId, ...data } = payload as { roomId: number; itemId: number; @@ -31,7 +32,7 @@ export async function bulkDeleteRoomItems({ roomId: number; itemIds: number[]; }) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.ROOMS_EDIT); await prisma.items.deleteMany({ where: { id: { in: itemIds }, roomId } }); await logStaffActivity({ staffId: staff.id, @@ -49,7 +50,7 @@ export async function deleteRoomItem({ roomId: number; itemId: number; }) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.ROOMS_EDIT); await prisma.items.delete({ where: { id: itemId } }); await logStaffActivity({ staffId: staff.id, @@ -68,7 +69,7 @@ export async function roomRconAction({ roomId: number; action: string; }) { - await requireStaff(); + await requirePermission(PERMS.ROOMS_EDIT); if (action === "reload") { await rcon.send("reloadroom", { room_id: roomId }); } else if (action === "kick") { @@ -81,7 +82,7 @@ export async function roomRconAction({ } export async function deleteRoom({ id }: { id: number }) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.ROOMS_DELETE); await prisma.rooms.delete({ where: { id } }); await logStaffActivity({ staffId: staff.id, @@ -103,7 +104,7 @@ export async function updateRoom({ state?: string; usersMax?: number; }) { - const staff = await requireStaff(); + const staff = await requirePermission(PERMS.ROOMS_EDIT); await prisma.rooms.update({ where: { id }, data: data as any }); await logStaffActivity({ staffId: staff.id, diff --git a/src/actions/soundtracks.ts b/src/actions/soundtracks.ts index 3d3a12d7..97a489de 100644 --- a/src/actions/soundtracks.ts +++ b/src/actions/soundtracks.ts @@ -1,11 +1,12 @@ "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"; export async function deleteSoundtrack({ id }: { id: number }) { - await requireStaff(); + await requirePermission(PERMS.CATALOG_EDIT); await prisma.soundtracks.delete({ where: { id } }); revalidatePath("/admin/sounds"); } @@ -23,7 +24,7 @@ export async function updateSoundtrack({ track: string; length: number; }) { - await requireStaff(); + await requirePermission(PERMS.CATALOG_EDIT); await prisma.soundtracks.update({ where: { id }, data: { name, author, track, length }, diff --git a/src/app/admin/achievements/page.tsx b/src/app/admin/achievements/page.tsx index b30279c3..f579d8e6 100644 --- a/src/app/admin/achievements/page.tsx +++ b/src/app/admin/achievements/page.tsx @@ -2,6 +2,8 @@ import { Award } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -16,6 +18,11 @@ type Achievement = { }; export default async function AdminAchievements() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.achievements"); let achievements: Achievement[]; diff --git a/src/app/admin/ads/[id]/page.tsx b/src/app/admin/ads/[id]/page.tsx index c79cd6be..1c1afc8f 100644 --- a/src/app/admin/ads/[id]/page.tsx +++ b/src/app/admin/ads/[id]/page.tsx @@ -1,8 +1,9 @@ import { Megaphone } from "lucide-react"; import Link from "next/link"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { deleteAd, updateAd } from "@/actions/admin-ads"; import { prisma } from "@/lib/prisma"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -11,6 +12,11 @@ export default async function EditAd({ }: { params: Promise<{ id: string }>; }) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { + redirect("/admin"); + } + const { id } = await params; let ad: Awaited> = null; try { diff --git a/src/app/admin/ads/new/page.tsx b/src/app/admin/ads/new/page.tsx index 3004d84c..1cacdd77 100644 --- a/src/app/admin/ads/new/page.tsx +++ b/src/app/admin/ads/new/page.tsx @@ -1,8 +1,15 @@ import { Megaphone } from "lucide-react"; import Link from "next/link"; +import { redirect } from "next/navigation"; import { createAd } from "@/actions/admin-ads"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; + +export default async function NewAd() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.PAGES_EDIT, session.user.rank)) { + redirect("/admin"); + } -export default function NewAd() { return (
diff --git a/src/app/admin/ads/page.tsx b/src/app/admin/ads/page.tsx index fc652ce4..73b57e62 100644 --- a/src/app/admin/ads/page.tsx +++ b/src/app/admin/ads/page.tsx @@ -3,10 +3,17 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; export default async function AdminAds() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.ads"); let ads: Awaited>; try { diff --git a/src/app/admin/alerts/page.tsx b/src/app/admin/alerts/page.tsx index 92a4701d..e27cd2ff 100644 --- a/src/app/admin/alerts/page.tsx +++ b/src/app/admin/alerts/page.tsx @@ -3,6 +3,8 @@ import { getTranslations } from "next-intl/server"; import { sendHotelAlert } from "@/actions/admin-alerts"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -54,6 +56,11 @@ function Badge({ label, color }: { label: string; color: string }) { } export default async function AdminAlerts() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.NOTIFICATIONS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.alerts"); let alerts: Awaited>; diff --git a/src/app/admin/applications/page.tsx b/src/app/admin/applications/page.tsx index a3229460..1040291a 100644 --- a/src/app/admin/applications/page.tsx +++ b/src/app/admin/applications/page.tsx @@ -4,6 +4,8 @@ import { getTranslations } from "next-intl/server"; import { dismissApplication } from "@/actions/admin-applications"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -20,6 +22,11 @@ function formatDate(d: Date | null): string { } export default async function AdminApplications() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.applications"); let applications: ApplicationRow[]; try { diff --git a/src/app/admin/badges/page.tsx b/src/app/admin/badges/page.tsx index 99881216..6e223921 100644 --- a/src/app/admin/badges/page.tsx +++ b/src/app/admin/badges/page.tsx @@ -3,6 +3,8 @@ import { getTranslations } from "next-intl/server"; import { uploadBadge } from "@/actions/admin-badge-upload"; import { giveBadge } from "@/actions/admin-badges"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -13,6 +15,11 @@ export default async function AdminBadges({ }: { searchParams: Promise<{ uploaded?: string; error?: string }>; }) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.badges"); const { uploaded, error } = await searchParams; diff --git a/src/app/admin/calendar/[id]/page.tsx b/src/app/admin/calendar/[id]/page.tsx index c9939ef4..f094eacf 100644 --- a/src/app/admin/calendar/[id]/page.tsx +++ b/src/app/admin/calendar/[id]/page.tsx @@ -1,7 +1,8 @@ import Link from "next/link"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { prisma } from "@/lib/prisma"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -40,6 +41,11 @@ export default async function AdminCalendarCampaign({ }: { 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.calendar"); const { id } = await params; diff --git a/src/app/admin/calendar/page.tsx b/src/app/admin/calendar/page.tsx index 52788548..351e8f07 100644 --- a/src/app/admin/calendar/page.tsx +++ b/src/app/admin/calendar/page.tsx @@ -3,6 +3,8 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -22,6 +24,11 @@ function fmtDate(ts: number): string { } export default async function AdminCalendar() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.calendar"); let campaigns: Campaign[] = []; diff --git a/src/app/admin/email-templates/page.tsx b/src/app/admin/email-templates/page.tsx index 57469886..2408058d 100644 --- a/src/app/admin/email-templates/page.tsx +++ b/src/app/admin/email-templates/page.tsx @@ -5,10 +5,17 @@ import { updateEmailTemplate, } from "@/actions/admin-email-templates"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; export default async function AdminEmailTemplates() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.emailTemplates"); let templates: Awaited> = diff --git a/src/app/admin/favicon/page.tsx b/src/app/admin/favicon/page.tsx index fed13996..8fe024d3 100644 --- a/src/app/admin/favicon/page.tsx +++ b/src/app/admin/favicon/page.tsx @@ -2,10 +2,17 @@ import { Image } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { prisma } from "@/lib/prisma"; import { FaviconForm } from "./favicon-form"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; export default async function AdminFaviconPage() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.favicon"); let faviconUrl: string | null = null; diff --git a/src/app/admin/help-questions/[id]/page.tsx b/src/app/admin/help-questions/[id]/page.tsx index fcca6d9c..f53a2c4c 100644 --- a/src/app/admin/help-questions/[id]/page.tsx +++ b/src/app/admin/help-questions/[id]/page.tsx @@ -1,8 +1,9 @@ import { HelpCircle } from "lucide-react"; import Link from "next/link"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { deleteHelpQuestion, updateHelpQuestion } from "@/actions/admin-help"; import { prisma } from "@/lib/prisma"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -13,6 +14,11 @@ export default async function EditHelpQuestion({ params: Promise<{ id: string }>; searchParams: Promise<{ error?: string }>; }) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { + redirect("/admin"); + } + const { id } = await params; const { error } = await searchParams; let entry: Awaited< diff --git a/src/app/admin/help-questions/new/page.tsx b/src/app/admin/help-questions/new/page.tsx index f97129b7..708bcdd0 100644 --- a/src/app/admin/help-questions/new/page.tsx +++ b/src/app/admin/help-questions/new/page.tsx @@ -1,12 +1,19 @@ import { HelpCircle } from "lucide-react"; import Link from "next/link"; import { createHelpQuestion } from "@/actions/admin-help"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function NewHelpQuestion({ searchParams, }: { searchParams: Promise<{ error?: string }>; }) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.PAGES_EDIT, session.user.rank)) { + redirect("/admin"); + } + const { error } = await searchParams; return ( diff --git a/src/app/admin/help-questions/page.tsx b/src/app/admin/help-questions/page.tsx index b9c92c57..f20a05a0 100644 --- a/src/app/admin/help-questions/page.tsx +++ b/src/app/admin/help-questions/page.tsx @@ -3,10 +3,17 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; export default async function AdminHelpQuestions() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.helpCenter"); let entries: Awaited< ReturnType diff --git a/src/app/admin/housekeeping/page.tsx b/src/app/admin/housekeeping/page.tsx index 2562acef..4d361756 100644 --- a/src/app/admin/housekeeping/page.tsx +++ b/src/app/admin/housekeeping/page.tsx @@ -5,6 +5,8 @@ import { upsertPermission, } from "@/actions/admin-housekeeping"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -13,6 +15,11 @@ export default async function AdminHousekeeping({ }: { searchParams: Promise<{ q?: string; mode?: "manage" | "import" }>; }) { + const { session, permissions: acl } = await getAdminContext(); + if (!canAccess(acl, PERMS.SETTINGS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.housekeeping"); const sp = await searchParams; diff --git a/src/app/admin/ip/page.tsx b/src/app/admin/ip/page.tsx index 7241c6aa..caa762ea 100644 --- a/src/app/admin/ip/page.tsx +++ b/src/app/admin/ip/page.tsx @@ -8,6 +8,8 @@ import { } from "@/actions/admin-ip"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -23,6 +25,11 @@ function formatDate(d: Date | null): string { } export default async function AdminIp() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.ip"); let whitelist: IpRow[] = []; let blacklist: IpRow[] = []; diff --git a/src/app/admin/logs/page.tsx b/src/app/admin/logs/page.tsx index a716fcb9..b0914827 100644 --- a/src/app/admin/logs/page.tsx +++ b/src/app/admin/logs/page.tsx @@ -3,6 +3,8 @@ import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { buildStaffActivityWhere } from "@/lib/admin/log-filters"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -23,6 +25,11 @@ export default async function AdminStaffActivities({ authorization?: string; }>; }) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.logs"); const sp = await searchParams; diff --git a/src/app/admin/maintenance/page.tsx b/src/app/admin/maintenance/page.tsx index d73f877b..5157ce14 100644 --- a/src/app/admin/maintenance/page.tsx +++ b/src/app/admin/maintenance/page.tsx @@ -3,6 +3,8 @@ import { getTranslations } from "next-intl/server"; import { saveMaintenance } from "@/actions/admin-maintenance"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -13,6 +15,11 @@ const KEYS = [ ] as const; export default async function AdminMaintenance() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.maintenance"); let rows: { key: string; value: string }[] = []; diff --git a/src/app/admin/media/page.tsx b/src/app/admin/media/page.tsx index d5ecef60..949a1ca8 100644 --- a/src/app/admin/media/page.tsx +++ b/src/app/admin/media/page.tsx @@ -2,8 +2,15 @@ import { Image } from "lucide-react"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { AdminMediaGrid } from "@/components/admin/media-grid"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function AdminMediaPage() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.media"); return ( diff --git a/src/app/admin/navigation/page.tsx b/src/app/admin/navigation/page.tsx index 28496415..caafa997 100644 --- a/src/app/admin/navigation/page.tsx +++ b/src/app/admin/navigation/page.tsx @@ -2,6 +2,8 @@ import { Navigation } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -40,6 +42,11 @@ function YesNo({ on }: { on: boolean }) { } export default async function AdminNavigation() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.navigation"); let flatcats: Flatcat[] = []; try { diff --git a/src/app/admin/photos/page.tsx b/src/app/admin/photos/page.tsx index 63c5e319..6da662a7 100644 --- a/src/app/admin/photos/page.tsx +++ b/src/app/admin/photos/page.tsx @@ -3,10 +3,17 @@ import { getTranslations } from "next-intl/server"; import { deletePhoto } from "@/actions/admin-photos"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; export default async function AdminPhotos() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.photos"); let photos: Awaited> = []; try { diff --git a/src/app/admin/rare-values/page.tsx b/src/app/admin/rare-values/page.tsx index f6070770..40011fe4 100644 --- a/src/app/admin/rare-values/page.tsx +++ b/src/app/admin/rare-values/page.tsx @@ -8,12 +8,19 @@ import { } from "@/actions/admin-rare-values"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; const CURRENCY_TYPES = ["diamonds", "credits", "duckets", "points"]; export default async function AdminRareValues() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.rareValues"); let categories: Awaited< diff --git a/src/app/admin/rooms/[id]/furni/page.tsx b/src/app/admin/rooms/[id]/furni/page.tsx index ee193a93..a9411e13 100644 --- a/src/app/admin/rooms/[id]/furni/page.tsx +++ b/src/app/admin/rooms/[id]/furni/page.tsx @@ -84,7 +84,7 @@ export default async function RoomFurniPage({ ownerUsername: room.owner?.username ?? room.ownerName, }} items={rows} - canEdit={session.user.rank >= 4} + canEdit={canAccess(permissions, PERMS.ROOMS_EDIT, session.user.rank)} />
); diff --git a/src/app/admin/rooms/edit/[id]/page.tsx b/src/app/admin/rooms/edit/[id]/page.tsx index 8cf1cb66..c02b7490 100644 --- a/src/app/admin/rooms/edit/[id]/page.tsx +++ b/src/app/admin/rooms/edit/[id]/page.tsx @@ -15,10 +15,7 @@ export default async function RoomEditPage({ }) { const { id } = await params; const { session, permissions } = await getAdminContext(); - if ( - !canAccess(permissions, PERMS.ROOMS_VIEW, session.user.rank) || - session.user.rank < 4 - ) { + if (!canAccess(permissions, PERMS.ROOMS_EDIT, session.user.rank)) { redirect("/admin"); } diff --git a/src/app/admin/rooms/page.tsx b/src/app/admin/rooms/page.tsx index 5839ba63..91a6ae00 100644 --- a/src/app/admin/rooms/page.tsx +++ b/src/app/admin/rooms/page.tsx @@ -30,9 +30,7 @@ export default async function RoomsPage({ searchParams, ); - const canEdit = - canAccess(result.permissions, PERMS.ROOMS_VIEW, result.rank) && - result.rank >= 4; + const canEdit = canAccess(result.permissions, PERMS.ROOMS_EDIT, result.rank); return (
diff --git a/src/app/admin/rooms/show/[id]/page.tsx b/src/app/admin/rooms/show/[id]/page.tsx index 62345bb6..e6eab070 100644 --- a/src/app/admin/rooms/show/[id]/page.tsx +++ b/src/app/admin/rooms/show/[id]/page.tsx @@ -29,7 +29,7 @@ export default async function RoomShowPage({ redirect("/admin"); const room = await loadRoom(id); - const canEdit = session.user.rank >= 4; + const canEdit = canAccess(permissions, PERMS.ROOMS_EDIT, session.user.rank); return (
diff --git a/src/app/admin/subscriptions/page.tsx b/src/app/admin/subscriptions/page.tsx index c49b14e1..0006d83d 100644 --- a/src/app/admin/subscriptions/page.tsx +++ b/src/app/admin/subscriptions/page.tsx @@ -2,6 +2,8 @@ import { CreditCard } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -17,6 +19,11 @@ function fmt(ts: number): string { } export default async function AdminSubscriptions() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.subscriptions"); const now = Math.floor(Date.now() / 1000); diff --git a/src/app/admin/tags/page.tsx b/src/app/admin/tags/page.tsx index 35eac6ae..be92cad9 100644 --- a/src/app/admin/tags/page.tsx +++ b/src/app/admin/tags/page.tsx @@ -3,6 +3,8 @@ import { getTranslations } from "next-intl/server"; import { createTag, deleteTag, updateTag } from "@/actions/admin-tags"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -18,6 +20,11 @@ function formatDate(d: Date | null): string { } export default async function AdminTagsPage() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.tags"); let tags: Tag[] = []; let usage = new Map(); diff --git a/src/app/admin/teams/page.tsx b/src/app/admin/teams/page.tsx index 1eac967b..fb44ee51 100644 --- a/src/app/admin/teams/page.tsx +++ b/src/app/admin/teams/page.tsx @@ -3,6 +3,8 @@ import { getTranslations } from "next-intl/server"; import { createTeam, deleteTeam } from "@/actions/admin-teams"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -16,6 +18,11 @@ type TeamRow = { }; export default async function AdminTeams() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.teams"); let teams: TeamRow[] = []; try { diff --git a/src/app/admin/transactions/page.tsx b/src/app/admin/transactions/page.tsx index 93bc4a03..9efb84d9 100644 --- a/src/app/admin/transactions/page.tsx +++ b/src/app/admin/transactions/page.tsx @@ -2,8 +2,9 @@ import { ArrowLeftRight } 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 { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -47,7 +48,10 @@ export default async function AdminTransactions({ }: { searchParams: Promise<{ page?: string }>; }) { - await requireStaff(); + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { + redirect("/admin"); + } const t = await getTranslations("pages.admin.transactions"); diff --git a/src/app/admin/vouchers/page.tsx b/src/app/admin/vouchers/page.tsx index a9d29c17..32e2022d 100644 --- a/src/app/admin/vouchers/page.tsx +++ b/src/app/admin/vouchers/page.tsx @@ -3,6 +3,8 @@ import { getTranslations } from "next-intl/server"; import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; @@ -22,6 +24,11 @@ function fmtDate(d: Date | null): string { } export default async function AdminVouchers() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.vouchers"); const now = Date.now(); diff --git a/src/app/admin/vpn/page.tsx b/src/app/admin/vpn/page.tsx index 95af80be..596566a5 100644 --- a/src/app/admin/vpn/page.tsx +++ b/src/app/admin/vpn/page.tsx @@ -4,6 +4,8 @@ import { getTranslations } from "next-intl/server"; import { saveVpn } from "@/actions/admin-vpn"; import { StatusCard } from "@/components/admin/dashboard"; import { siteSettings } from "@/lib/services/site-settings"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; export const metadata = { title: "VPN" }; @@ -19,6 +21,11 @@ export default async function AdminVpn({ }: { searchParams: Promise<{ saved?: string }>; }) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) { + redirect("/admin"); + } + const sp = await searchParams; const t = await getTranslations("pages.admin.vpn"); diff --git a/src/app/admin/writeable-boxes/page.tsx b/src/app/admin/writeable-boxes/page.tsx index 7f3a4d61..6d9e331e 100644 --- a/src/app/admin/writeable-boxes/page.tsx +++ b/src/app/admin/writeable-boxes/page.tsx @@ -8,12 +8,19 @@ import { } from "@/actions/admin-writeable-boxes"; import { StatusCard } from "@/components/admin/dashboard"; import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export const dynamic = "force-dynamic"; export const metadata = { title: "Writeable boxes" }; export default async function AdminWriteableBoxes() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { + redirect("/admin"); + } + const t = await getTranslations("pages.admin.writeableBoxes"); const boxes = await prisma.websiteWriteableBoxes .findMany({ diff --git a/src/lib/admin-operations-contract.test.ts b/src/lib/admin-operations-contract.test.ts index 0dd0a3b3..f954b190 100644 --- a/src/lib/admin-operations-contract.test.ts +++ b/src/lib/admin-operations-contract.test.ts @@ -1,21 +1,14 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; const ROUTES: Array<[string, string]> = [ ["moderation", "PERMS.MODERATION_VIEW"], ["moderation/actions", "PERMS.MODERATION_EDIT"], ["moderation/cfh", "PERMS.MODERATION_VIEW"], - ["moderation/cfh/[id]", "PERMS.MODERATION_VIEW"], - ["moderation/team", "PERMS.MODERATION_VIEW"], ["logs/audit", "PERMS.LOGS_VIEW"], - ["logs/chat", "PERMS.LOGS_VIEW"], - ["logs/commands", "PERMS.LOGS_VIEW"], - ["logs/trades", "PERMS.LOGS_VIEW"], ["analytics", "PERMS.ANALYTICS_VIEW"], - ["analytics/activity", "PERMS.ANALYTICS_VIEW"], - ["analytics/economy", "PERMS.ANALYTICS_VIEW"], ["devops", "PERMS.DEVOPS_VIEW"], - ["devops/errors", "PERMS.DEVOPS_VIEW"], ["online", "PERMS.USERS_VIEW"], ["commandocentrum", "PERMS.RCON_EXECUTE"], ["users/edit/[id]", "PERMS.USERS_EDIT"], @@ -26,6 +19,43 @@ const ROUTES: Array<[string, string]> = [ ["wordfilter", "PERMS.WORDFILTER_VIEW"], ["articles", "PERMS.NEWS_VIEW"], ["shop", "PERMS.SHOP_VIEW"], + ["transactions", "PERMS.SHOP_VIEW"], + ["vouchers", "PERMS.SHOP_VIEW"], + ["vpn", "PERMS.SETTINGS_VIEW"], + ["ip", "PERMS.SETTINGS_VIEW"], + ["maintenance", "PERMS.SETTINGS_VIEW"], + ["alerts", "PERMS.NOTIFICATIONS_VIEW"], + ["tags", "PERMS.PAGES_VIEW"], + ["ads", "PERMS.PAGES_VIEW"], + ["media", "PERMS.PAGES_VIEW"], + ["photos", "PERMS.PAGES_VIEW"], + ["badges", "PERMS.CATALOG_VIEW"], + ["teams", "PERMS.USERS_VIEW"], + ["applications", "PERMS.USERS_VIEW"], + ["logs", "PERMS.LOGS_VIEW"], + ["catalog", "PERMS.CATALOG_VIEW"], + ["rooms/edit/[id]", "PERMS.ROOMS_EDIT"], +]; + +const ACTION_GATES: Array<[string, string]> = [ + ["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"], + ["catalog.ts", "PERMS.CATALOG_EDIT"], + ["rooms.ts", "PERMS.ROOMS_EDIT"], + ["admin-users.ts", "PERMS.USERS_EDIT"], + ["admin-vpn.ts", "PERMS.SETTINGS_EDIT"], + ["admin-ads.ts", "PERMS.PAGES_EDIT"], + ["admin-vouchers.ts", "PERMS.SHOP_EDIT"], + ["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"], + ["admin-permissions.ts", "PERMS.PERMISSIONS_MANAGE"], + ["commandocentrum.ts", "PERMS.RCON_EXECUTE"], + ["translations.ts", "PERMS.SETTINGS_EDIT"], ]; describe("admin operations route contract", () => { @@ -51,36 +81,42 @@ describe("admin operations route contract", () => { expect(readFileSync(path, "utf8"), path).toContain(permission); }); - it("guards moderation mutations separately from page navigation", () => { - const source = readFileSync("src/actions/moderation.ts", "utf8"); - expect(source).toContain("PERMS.MODERATION_EDIT"); - expect(source).toContain("adminAction"); - }); - - it("guards translation writes with SETTINGS_EDIT", () => { - const source = readFileSync("src/actions/translations.ts", "utf8"); - expect(source).toContain("PERMS.SETTINGS_EDIT"); - expect(source).not.toContain("rank < 7"); - }); - - it("guards commandocentrum mutations with RCON_EXECUTE", () => { - const source = readFileSync("src/actions/commandocentrum.ts", "utf8"); - 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) => { + it.each(ACTION_GATES)("guards %s with %s", (file, permission) => { const source = readFileSync(`src/actions/${file}`, "utf8"); expect(source).toContain(permission); expect(source).not.toMatch(/await requireStaff\(\)/); + expect(source).not.toMatch(/await requireStaffRateLimited\(\)/); + }); + + it("has no requireStaff left in admin action modules", () => { + const dir = "src/actions"; + const offenders: string[] = []; + for (const name of readdirSync(dir)) { + if (!name.endsWith(".ts") || name.endsWith(".test.ts")) continue; + const source = readFileSync(join(dir, name), "utf8"); + if (/await requireStaff(RateLimited)?\(\)/.test(source)) { + offenders.push(name); + } + } + expect(offenders).toEqual([]); + }); + + it("has no requireStaff left in admin pages", () => { + const root = "src/app/admin"; + const offenders: string[] = []; + function walk(dir: string) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name === "page.tsx") { + const source = readFileSync(full, "utf8"); + if (/await requireStaff\(/.test(source)) { + offenders.push(relative(process.cwd(), full).replaceAll("\\", "/")); + } + } + } + } + walk(root); + expect(offenders).toEqual([]); }); });