Finish fine-grained ACL across remaining admin pages and actions.
Local Build and Deploy / deploy (push) Successful in 56s

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 <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-15 20:15:22 +02:00
1 parent 3c8a8ff888
commit 0e89d03940
63 files changed
+420 -163

No files matched your search

+5 -4
View File
@@ -2,7 +2,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
import { 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<void> {
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<void> {
}
export async function updateAd(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteAd(formData: FormData): Promise<void> {
const staff = await requireStaff();
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
+3 -2
View File
@@ -1,7 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { 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<void> {
await requireStaff();
await requirePermission(PERMS.NOTIFICATIONS_EDIT);
const message = String(formData.get("message") ?? "")
.normalize("NFC")
+3 -2
View File
@@ -1,12 +1,13 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
export async function dismissApplication(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
+3 -2
View File
@@ -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<void> {
const staff = await requireStaff();
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const dir = process.env.BADGE_UPLOAD_DIR;
if (!dir) {
+3 -2
View File
@@ -1,12 +1,13 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
export async function giveBadge(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.CATALOG_EDIT);
const userId = Number(formData.get("userId"));
const code = String(formData.get("code") ?? "")
+5 -4
View File
@@ -1,12 +1,13 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
export async function createEmailTemplate(formData: FormData): Promise<void> {
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<void> {
}
export async function updateEmailTemplate(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteEmailTemplate(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
await prisma.emailTemplates.delete({ where: { id } });
+5 -4
View File
@@ -2,7 +2,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { 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<void> {
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<void> {
}
export async function updateHelpQuestion(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
const staff = await requireStaff();
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
+4 -3
View File
@@ -1,7 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
/**
@@ -9,7 +10,7 @@ import { prisma } from "@/lib/prisma";
* string). Mirrors AtomCMS' housekeeping permission management.
*/
export async function upsertPermission(formData: FormData): Promise<void> {
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<void> {
}
export async function deletePermission(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.SETTINGS_EDIT);
const raw = String(formData.get("id") ?? "").normalize("NFC");
if (!raw) return;
+6 -5
View File
@@ -1,7 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
function parseIp(formData: FormData): string {
@@ -20,7 +21,7 @@ function parseAsn(formData: FormData): string | null {
}
export async function addWhitelist(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteWhitelist(formData: FormData): Promise<void> {
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<void> {
}
export async function addBlacklist(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteBlacklist(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.SETTINGS_EDIT);
const raw = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
+3 -2
View File
@@ -1,7 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
@@ -35,7 +36,7 @@ async function upsertSetting(key: string, value: string): Promise<void> {
}
export async function saveMaintenance(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.SETTINGS_EDIT);
// Checkbox: present only when ticked. Normalise to the '1'/'0' string the
// emulator/Laravel side expects.
+5 -4
View File
@@ -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<void> {
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<void> {
}
export async function deleteMedia(name: string): Promise<void> {
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<void> {
export async function uploadMediaAndReturn(
formData: FormData,
): Promise<string> {
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 "";
+5 -4
View File
@@ -2,7 +2,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
import { logServerError } from "@/lib/server-log";
@@ -23,7 +24,7 @@ function parseMinRank(formData: FormData): number {
}
export async function createPermission(formData: FormData): Promise<void> {
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<void> {
}
export async function updatePermission(formData: FormData): Promise<void> {
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<void> {
}
export async function deletePermission(formData: FormData): Promise<void> {
const staff = await requireStaff();
const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
+3 -2
View File
@@ -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<void> {
await requireStaff();
await requirePermission(PERMS.PAGES_EDIT);
const id = Number(formData.get("id"));
if (!(id > 0)) return;
+6 -5
View File
@@ -1,12 +1,13 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
export async function createCategory(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteCategory(formData: FormData): Promise<void> {
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<void> {
}
export async function createValue(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteValue(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
+5 -4
View File
@@ -1,7 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -31,7 +32,7 @@ function normaliseColor(raw: string): string {
// ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ──────────
export async function createTag(formData: FormData): Promise<void> {
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<void> {
}
export async function updateTag(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteTag(formData: FormData): Promise<void> {
const staff = await requireStaff();
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = parseId(formData.get("id"));
if (id === null) return;
+4 -3
View File
@@ -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<void> {
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<void> {
}
export async function deleteTeam(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.USERS_EDIT);
const id = BigInt(String(formData.get("id")));
await prisma.websiteTeams.delete({ where: { id } });
+7 -6
View File
@@ -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<string> = new Set([
]);
export async function giveCurrency(formData: FormData): Promise<void> {
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<void> {
}
export async function setMotto(formData: FormData): Promise<void> {
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<void> {
}
export async function setRank(formData: FormData): Promise<void> {
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<void> {
}
export async function alertUser(formData: FormData): Promise<void> {
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<void> {
}
export async function disconnectUser(formData: FormData): Promise<void> {
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);
+4 -3
View File
@@ -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<void> {
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<void> {
}
export async function deleteVoucher(formData: FormData): Promise<void> {
await requireStaff();
await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
+3 -2
View File
@@ -2,7 +2,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -28,7 +29,7 @@ async function writeSetting(
}
export async function saveVpn(formData: FormData): Promise<void> {
const staff = await requireStaff();
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
const enabled =
+6 -5
View File
@@ -1,7 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -40,7 +41,7 @@ function revalidate(): void {
}
export async function createBox(formData: FormData): Promise<void> {
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<void> {
}
export async function updateBox(formData: FormData): Promise<void> {
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<void> {
}
export async function deleteBox(formData: FormData): Promise<void> {
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<void> {
}
export async function toggleBox(formData: FormData): Promise<void> {
const staff = await requireStaff();
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = parseId(formData);
if (id == null) return;
+4 -3
View File
@@ -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() },
+6 -5
View File
@@ -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<ActionResult<{ unbanned: number; total: number }>> {
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<ActionResult<{ banned: number }>> {
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 }> = [];
+8 -7
View File
@@ -1,7 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { 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<string, unknown>) {
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,
+8 -7
View File
@@ -1,7 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { 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<string, unknown>;
baseItem?: { id: number; fields: Record<string, unknown> };
}) {
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;
+8 -7
View File
@@ -1,7 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import 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<string, unknown>): Promise<ActionResult> {
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<ActionResult<{ id: number }>> {
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 },
+3 -2
View File
@@ -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 },
+3 -2
View File
@@ -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({
+8 -7
View File
@@ -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<string, unknown>) {
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,
+4 -3
View File
@@ -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 },
+7
View File
@@ -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[];
+7 -1
View File
@@ -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<ReturnType<typeof prisma.websiteAds.findUnique>> = null;
try {
+8 -1
View File
@@ -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 (
<main>
<div className="flex items-center gap-3 mb-6">
+7
View File
@@ -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<ReturnType<typeof prisma.websiteAds.findMany>>;
try {
+7
View File
@@ -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<ReturnType<typeof prisma.alertLogs.findMany>>;
+7
View File
@@ -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 {
+7
View File
@@ -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;
+7 -1
View File
@@ -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;
+7
View File
@@ -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[] = [];
+7
View File
@@ -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<ReturnType<typeof prisma.emailTemplates.findMany>> =
+7
View File
@@ -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;
+7 -1
View File
@@ -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<
@@ -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 (
+7
View File
@@ -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<typeof prisma.websiteHelpCenterCategories.findMany>
+7
View File
@@ -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;
+7
View File
@@ -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[] = [];
+7
View File
@@ -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;
+7
View File
@@ -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 }[] = [];
+7
View File
@@ -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 (
+7
View File
@@ -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 {
+7
View File
@@ -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<ReturnType<typeof prisma.cameraWeb.findMany>> = [];
try {
+7
View File
@@ -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<
+1 -1
View File
@@ -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)}
/>
</div>
);
+1 -4
View File
@@ -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");
}
+1 -3
View File
@@ -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 (
<div className="space-y-6">
+1 -1
View File
@@ -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 (
<div className="space-y-6">
+7
View File
@@ -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);
+7
View File
@@ -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<string, number>();
+7
View File
@@ -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 {
+6 -2
View File
@@ -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");
+7
View File
@@ -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();
+7
View File
@@ -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");
+7
View File
@@ -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({
+73 -37
View File
@@ -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([]);
});
});