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 0e380313fc..b369e93b03 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 787e3e40bd..f2eaa14e69 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 ad56089021..1088fe3572 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 f8d48d0226..e93f335fd9 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 608d1b0e5a..1835b6c2e9 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 d73521f754..f1e98d4649 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 1cf277a2c4..f703f79a19 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 3b3068cc1b..277451c052 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 8900f54ad7..797b101dab 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 2c7d6d4bf8..5636c55b55 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 6487af5688..1d7982b9fe 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 1a716c72c3..8b5d152195 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 ea6ba0e74c..1b7e5773c5 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 b244db5826..6c9e35b464 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 619e0b0ab3..7d08f355fe 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 022e03f410..3e47c340d8 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 bf3baf60a0..1b81c80363 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 aec1794a08..f6c5ddb322 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 93e4caaeb8..2385c0e595 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 178fc77b70..95b14eda7b 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 9a6c90dc56..d1dfdc68d7 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 92739068f1..a6c9921553 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 719280acba..6d714e5fb9 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 b6d2bfef22..0420d85de6 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 26d62c2340..e2a20f3517 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 40e91de0f9..dac0ed6611 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 6e676d8151..5562ae4082 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 298d6aa72d..8e44d43a89 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 3d3a12d70f..97a489de4c 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 b30279c302..f579d8e652 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 c79cd6be84..1c1afc8f29 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 3004d84c60..1cacdd77e9 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 fc652ce489..73b57e6261 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 92a4701d2c..e27cd2ffb5 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 a322946051..1040291a77 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 9988121664..6e22392146 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 c9939ef451..f094eacf79 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 527885488d..351e8f07b3 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 574698865f..2408058deb 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 fed1399655..8fe024d364 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 fcca6d9cbc..f53a2c4c5e 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 f97129b70d..708bcdd0b8 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 b9c92c57db..f20a05a011 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 2562acef59..4d361756db 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 7241c6aa9e..caa762eaa4 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 a716fcb98d..b0914827ac 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 d73f877bbb..5157ce1416 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 d5ecef6068..949a1ca87d 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 2849641507..caafa997cb 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 63c5e31945..6da662a75a 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 f607077000..40011fe449 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 ee193a93ef..a9411e133d 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 8cf1cb66c7..c02b74901f 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 5839ba6316..91a6ae0074 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 62345bb611..e6eab070b3 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 c49b14e1af..0006d83d66 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 35eac6ae54..be92cad92e 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 1eac967bf9..fb44ee5196 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 93bc4a0350..9efb84d905 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 a9d29c17b8..32e2022d74 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 95af80bea2..596566a575 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 7f3a4d61e9..6d9e331e50 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 0dd0a3b3ca..f954b190b1 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([]); }); });