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

+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({