fix(admin): P0 integrity — permanent bans, ACL sidebar, rank guards
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m36s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-22 19:01:59 +02:00
1 parent 11004626c7
commit 75cdfdebe4
14 files changed
+450 -60

No files matched your search

+2 -4
View File
@@ -15,8 +15,6 @@ const BAN_TYPES: ReadonlySet<string> = new Set([
"machine", "machine",
"super", "super",
]); ]);
const PERMANENT_SECONDS = 100 * 365 * 24 * 3600;
export async function createBan(formData: FormData): Promise<void> { export async function createBan(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN); const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
@@ -30,8 +28,8 @@ export async function createBan(formData: FormData): Promise<void> {
if (!(userId > 0) || !BAN_TYPES.has(type)) return; if (!(userId > 0) || !BAN_TYPES.has(type)) return;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const banExpire = // Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
hours > 0 ? now + Math.floor(hours) * 3600 : now + PERMANENT_SECONDS; const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
+32 -7
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
@@ -70,9 +71,7 @@ export const disconnectUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema }, { permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
async (ctx) => { async (ctx) => {
const username = ctx.data.username.normalize("NFC"); const username = ctx.data.username.normalize("NFC");
await requireRconOk( await requireRconOk(await rcon.disconnectUser(ctx.data.userId, username));
await rcon.disconnectUser(ctx.data.userId, username),
);
revalidatePath(PATH); revalidatePath(PATH);
return actionOk(); return actionOk();
}, },
@@ -191,14 +190,42 @@ export const setMotto = adminAction(
const setRankSchema = z.object({ const setRankSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
rank: z.coerce.number().int().min(0).max(10), rank: z.coerce.number().int().min(1).max(9999),
}); });
/** Set a user's rank (rcon: setrank). */ /** Set a user's rank (rcon: setrank). */
export const setRank = adminAction( export const setRank = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema }, { permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
async (ctx) => { async (ctx) => {
const staffRank = Number(ctx.session.user.rank);
const isSuper = ctx.permissions.isSuperAdmin;
const target = await prisma.user.findUnique({
where: { id: ctx.data.userId },
select: { rank: true },
});
if (!target) throw new ActionError("User not found");
const rankExists = await prisma.$queryRaw<{ id: number }[]>`
SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1
`.catch(() => [] as { id: number }[]);
if (rankExists.length === 0) throw new ActionError("Rank does not exist");
if (!isSuper) {
if (target.rank >= staffRank) {
throw new ActionError(
"Cannot change rank of a user at or above your rank",
);
}
if (ctx.data.rank >= staffRank) {
throw new ActionError("Cannot set a rank equal to or above your own");
}
}
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank)); await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
await prisma.user.update({
where: { id: ctx.data.userId },
data: { rank: ctx.data.rank },
});
revalidatePath(PATH); revalidatePath(PATH);
return actionOk(); return actionOk();
}, },
@@ -214,9 +241,7 @@ export const executeCommand = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema }, { permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
async (ctx) => { async (ctx) => {
const command = ctx.data.command.normalize("NFC"); const command = ctx.data.command.normalize("NFC");
await requireRconOk( await requireRconOk(await rcon.executeCommand(ctx.data.userId, command));
await rcon.executeCommand(ctx.data.userId, command),
);
revalidatePath(PATH); revalidatePath(PATH);
return actionOk(); return actionOk();
}, },
+10 -6
View File
@@ -1,6 +1,7 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { activeBanWhere, unixNow } from "@/lib/bans";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,15 +13,19 @@ export default async function BannedPage() {
const session = await auth(); const session = await auth();
let reason = ""; let reason = "";
let expire = 0; let expire = 0;
let hasBan = false;
if (session?.user?.id) { if (session?.user?.id) {
try { try {
const now = Math.floor(Date.now() / 1000);
const ban = await prisma.ban.findFirst({ const ban = await prisma.ban.findFirst({
where: { userId: Number(session.user.id), banExpire: { gt: now } }, where: {
orderBy: { banExpire: "desc" }, userId: Number(session.user.id),
...activeBanWhere(unixNow()),
},
orderBy: { timestamp: "desc" },
select: { banReason: true, banExpire: true }, select: { banReason: true, banExpire: true },
}); });
if (ban) { if (ban) {
hasBan = true;
reason = ban.banReason; reason = ban.banReason;
expire = ban.banExpire; expire = ban.banExpire;
} }
@@ -29,10 +34,9 @@ export default async function BannedPage() {
} }
} }
const expiryText = const expiryText = !hasBan
expire === 0
? "" ? ""
: expire > FAR_FUTURE : expire === 0 || expire > FAR_FUTURE
? t("permanent") ? t("permanent")
: t("expires", { : t("expires", {
date: new Date(expire * 1000) date: new Date(expire * 1000)
+3 -3
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { createBan, liftBan } from "@/actions/admin-bans"; import { createBan, liftBan } from "@/actions/admin-bans";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { activeBanWhere, unixNow } from "@/lib/bans";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -18,11 +19,11 @@ export default async function AdminBans() {
} }
const t = await getTranslations("pages.admin.bans"); const t = await getTranslations("pages.admin.bans");
const now = Math.floor(Date.now() / 1000); const now = unixNow();
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = []; let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
try { try {
bans = await prisma.ban.findMany({ bans = await prisma.ban.findMany({
where: { banExpire: { gt: now } }, where: activeBanWhere(now),
orderBy: { timestamp: "desc" }, orderBy: { timestamp: "desc" },
take: 100, take: 100,
}); });
@@ -30,7 +31,6 @@ export default async function AdminBans() {
bans = []; bans = [];
} }
// banExpire of 0 means permanent in the emulator schema.
const permanent = bans.filter((b) => b.banExpire === 0).length; const permanent = bans.filter((b) => b.banExpire === 0).length;
const accountBans = bans.filter((b) => b.type === "account").length; const accountBans = bans.filter((b) => b.type === "account").length;
+13 -1
View File
@@ -10,7 +10,9 @@ import { AdminTopbar } from "@/components/admin/admin-topbar";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
import { setCsrfCookie } from "@/lib/foundation/security"; import { setCsrfCookie } from "@/lib/foundation/security";
import { canAccess, getAdminContext } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -66,6 +68,13 @@ async function Sidebar({
}) { }) {
const t = await getTranslations("pages.admin.nav"); const t = await getTranslations("pages.admin.nav");
const initial = staff.username.charAt(0).toUpperCase(); const initial = staff.username.charAt(0).toUpperCase();
const { permissions } = await getAdminContext();
const isSuperAdmin = permissions.isSuperAdmin;
const allowedPermissions = isSuperAdmin
? []
: collectNavPermissionSlugs().filter((slug) =>
canAccess(permissions, slug, staff.rank),
);
return ( return (
<aside <aside
@@ -93,7 +102,10 @@ async function Sidebar({
</div> </div>
</div> </div>
<AdminSidebarNav /> <AdminSidebarNav
allowedPermissions={allowedPermissions}
isSuperAdmin={isSuperAdmin}
/>
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3"> <div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
<Link <Link
+2 -1
View File
@@ -9,6 +9,7 @@ import {
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { activeBanWhere } from "@/lib/bans";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -31,7 +32,7 @@ export default async function AdminDashboard() {
prisma.user.count({ where: { online: "1" } }), prisma.user.count({ where: { online: "1" } }),
prisma.websiteArticles.count(), prisma.websiteArticles.count(),
prisma.ban.count({ prisma.ban.count({
where: { banExpire: { gt: Math.floor(Date.now() / 1000) } }, where: activeBanWhere(),
}), }),
]); ]);
} catch {} } catch {}
@@ -5,7 +5,7 @@ import { logAudit } from "@/lib/services/audit";
import { uploadSingleFurni } from "@/lib/services/upload-import"; import { uploadSingleFurni } from "@/lib/services/upload-import";
export const POST = withAdmin( export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT }, { permission: PERMS.ASSETS_IMPORT, maxBodyBytes: 52 * 1024 * 1024 },
async (request, ctx) => { async (request, ctx) => {
const formData = await request.formData(); const formData = await request.formData();
+45 -1
View File
@@ -9,6 +9,7 @@ export const POST = withAdmin(
{ permission: PERMS.USERS_EDIT }, { permission: PERMS.USERS_EDIT },
async (request, context) => { async (request, context) => {
const staffId = context.session.user.id; const staffId = context.session.user.id;
const staffRank = context.session.user.rank;
const formData = await request.formData(); const formData = await request.formData();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const username = String(formData.get("username") || ""); const username = String(formData.get("username") || "");
@@ -23,13 +24,56 @@ export const POST = withAdmin(
if (action === "set_rank") { if (action === "set_rank") {
const rank = Number(formData.get("rank") || "0"); const rank = Number(formData.get("rank") || "0");
if (!rank || rank < 0 || rank > 10) { if (!Number.isInteger(rank) || rank < 1) {
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Invalid rank value" }, { success: false, message: "Invalid rank value" },
{ status: 400 }, { status: 400 },
); );
} }
const rankExists = await prisma.$queryRaw<{ id: number }[]>`
SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1
`.catch(() => [] as { id: number }[]);
if (rankExists.length === 0) {
return NextResponse.json(
{ success: false, message: "Rank does not exist" },
{ status: 400 },
);
}
const target = await prisma.user.findUnique({
where: { id: userId },
select: { rank: true },
});
if (!target) {
return NextResponse.json(
{ success: false, message: "User not found" },
{ status: 404 },
);
}
const isSuper = context.permissions.isSuperAdmin;
if (!isSuper) {
if (target.rank >= staffRank) {
return NextResponse.json(
{
success: false,
message: "Cannot change rank of a user at or above your rank",
},
{ status: 403 },
);
}
if (rank >= staffRank) {
return NextResponse.json(
{
success: false,
message: "Cannot set a rank equal to or above your own",
},
{ status: 403 },
);
}
}
await prisma.user.update({ where: { id: userId }, data: { rank } }); await prisma.user.update({ where: { id: userId }, data: { rank } });
await rcon.setRank(userId, rank); await rcon.setRank(userId, rank);
await logStaffActivity({ await logStaffActivity({
+29 -3
View File
@@ -5,7 +5,11 @@ import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { AdminNavLink } from "@/components/admin/admin-nav-link"; import { AdminNavLink } from "@/components/admin/admin-nav-link";
import { ADMIN_NAV_GROUPS, type AdminNavItem } from "@/lib/admin-nav"; import {
ADMIN_NAV_GROUPS,
type AdminNavItem,
navItemIsAllowed,
} from "@/lib/admin-nav";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const STORAGE_KEY = "admin-nav-collapsed"; const STORAGE_KEY = "admin-nav-collapsed";
@@ -52,13 +56,35 @@ function persistCollapsed(next: Record<string, boolean>) {
} }
} }
export function AdminSidebarNav() { export function AdminSidebarNav({
allowedPermissions = [],
isSuperAdmin = false,
}: {
/** ACL slugs the staff member holds (ignored when isSuperAdmin). */
allowedPermissions?: string[];
isSuperAdmin?: boolean;
}) {
const t = useTranslations("pages.admin.nav"); const t = useTranslations("pages.admin.nav");
const pathname = usePathname() ?? ""; const pathname = usePathname() ?? "";
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [hydrated, setHydrated] = useState(false); const [hydrated, setHydrated] = useState(false);
const groups = useMemo(() => ADMIN_NAV_GROUPS, []); const allowed = useMemo(
() => new Set(allowedPermissions),
[allowedPermissions],
);
const groups = useMemo(() => {
const access = {
isSuperAdmin,
has: (slug: string) => allowed.has(slug),
};
return ADMIN_NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => navItemIsAllowed(item, access)),
})).filter((group) => group.items.length > 0);
}, [allowed, isSuperAdmin]);
const allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]); const allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]);
useEffect(() => { useEffect(() => {
+31 -3
View File
@@ -2,6 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface OnlineUser { export interface OnlineUser {
id: number; id: number;
@@ -14,12 +15,29 @@ export interface OnlineUser {
type State = "ok" | "warn" | "danger" | "neutral"; type State = "ok" | "warn" | "danger" | "neutral";
const STATE_RING: Record<State, string> = {
ok: "ring-1 ring-[var(--admin-success-subtle)]/50 border-[var(--admin-success-subtle)]/40",
warn: "ring-1 ring-[var(--admin-accent)]/40 border-[var(--admin-accent)]/35",
danger:
"ring-1 ring-[var(--admin-error-subtle)]/50 border-[var(--admin-error-subtle)]/45",
neutral: "border-[var(--admin-border)]",
};
const STATE_ICON_BG: Record<State, string> = {
ok: "from-[var(--admin-success-subtle)]/25 to-[var(--admin-success-subtle)]/5 text-[var(--admin-success)]",
warn: "from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 text-[var(--admin-accent)]",
danger:
"from-[var(--admin-error-subtle)]/25 to-[var(--admin-error-subtle)]/5 text-[var(--admin-error)]",
neutral:
"from-[var(--admin-accent)]/15 to-[var(--admin-accent)]/5 text-[var(--admin-accent)]",
};
/** A single live-status tile (online users, emulator, DB, …). */ /** A single live-status tile (online users, emulator, DB, …). */
export function StatusCard({ export function StatusCard({
label, label,
value, value,
hint, hint,
state: _state = "neutral", state = "neutral",
icon, icon,
}: { }: {
label: string; label: string;
@@ -29,13 +47,23 @@ export function StatusCard({
icon?: ReactNode; icon?: ReactNode;
}) { }) {
return ( return (
<div className="bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl p-5 shadow-card hover:shadow-card-hover transition-shadow duration-200"> <div
className={cn(
"bg-[var(--admin-surface)] border rounded-2xl p-5 shadow-card hover:shadow-card-hover transition-shadow duration-200",
STATE_RING[state],
)}
>
<div className="flex items-center justify-between gap-3 mb-3"> <div className="flex items-center justify-between gap-3 mb-3">
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]"> <span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]">
{label} {label}
</span> </span>
{icon ? ( {icon ? (
<span className="w-8 h-8 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/15 to-[var(--admin-accent)]/5 grid place-items-center text-[var(--admin-accent)] flex-none"> <span
className={cn(
"w-8 h-8 rounded-xl bg-gradient-to-br grid place-items-center flex-none",
STATE_ICON_BG[state],
)}
>
{icon} {icon}
</span> </span>
) : null} ) : null}
+5 -2
View File
@@ -1,6 +1,7 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { activeBanWhere } from "@/lib/bans";
import { safeRedirect } from "@/lib/foundation/security"; import { safeRedirect } from "@/lib/foundation/security";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -62,9 +63,11 @@ export async function enforceSiteAccess(): Promise<void> {
try { try {
if (!target && session?.user?.id) { if (!target && session?.user?.id) {
const now = Math.floor(Date.now() / 1000);
const ban = await prisma.ban.findFirst({ const ban = await prisma.ban.findFirst({
where: { userId: Number(session.user.id), banExpire: { gt: now } }, where: {
userId: Number(session.user.id),
...activeBanWhere(),
},
select: { id: true }, select: { id: true },
}); });
if (ban) target = "/banned"; if (ban) target = "/banned";
+231 -28
View File
@@ -39,6 +39,7 @@ import {
Wifi, Wifi,
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
import { PERMS } from "@/lib/permission-slugs";
export interface AdminHubTab { export interface AdminHubTab {
href: string; href: string;
@@ -64,6 +65,11 @@ export interface AdminNavItem {
href: string; href: string;
labelKey: string; labelKey: string;
icon: LucideIcon; icon: LucideIcon;
/**
* ACL slug(s) required to show this item. Any match is enough.
* Omit only for the dashboard (already gated by admin.dashboard).
*/
permission?: string | readonly string[];
/** When set, sidebar item is active if pathname matches any prefix. */ /** When set, sidebar item is active if pathname matches any prefix. */
matchPrefixes?: string[]; matchPrefixes?: string[];
/** Prefixes that must NOT count as active (e.g. /admin/users vs multi-accounts). */ /** Prefixes that must NOT count as active (e.g. /admin/users vs multi-accounts). */
@@ -232,6 +238,7 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin", href: "/admin",
labelKey: "dashboard", labelKey: "dashboard",
icon: LayoutDashboard, icon: LayoutDashboard,
permission: PERMS.ADMIN_DASHBOARD,
}, },
], ],
}, },
@@ -243,31 +250,65 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/articles", href: "/admin/articles",
labelKey: "articles", labelKey: "articles",
icon: Newspaper, icon: Newspaper,
permission: PERMS.NEWS_VIEW,
matchPrefixes: ["/admin/articles"], matchPrefixes: ["/admin/articles"],
}, },
{ href: "/admin/photos", labelKey: "photos", icon: Image }, {
{ href: "/admin/banners", labelKey: "banners", icon: Megaphone }, href: "/admin/photos",
labelKey: "photos",
icon: Image,
permission: PERMS.PAGES_VIEW,
},
{
href: "/admin/banners",
labelKey: "banners",
icon: Megaphone,
permission: PERMS.BANNERS_VIEW,
},
{ {
href: "/admin/ads", href: "/admin/ads",
labelKey: "advertisements", labelKey: "advertisements",
icon: FileText, icon: FileText,
permission: PERMS.PAGES_VIEW,
matchPrefixes: ["/admin/ads"], matchPrefixes: ["/admin/ads"],
}, },
{ href: "/admin/media", labelKey: "media", icon: Image }, {
{ href: "/admin/navigation", labelKey: "navigator", icon: Compass }, href: "/admin/media",
labelKey: "media",
icon: Image,
permission: PERMS.PAGES_VIEW,
},
{
href: "/admin/navigation",
labelKey: "navigator",
icon: Compass,
permission: PERMS.PAGES_VIEW,
},
{ {
href: "/admin/help-questions", href: "/admin/help-questions",
labelKey: "helpCenter", labelKey: "helpCenter",
icon: HelpCircle, icon: HelpCircle,
permission: PERMS.PAGES_VIEW,
matchPrefixes: ["/admin/help-questions"], matchPrefixes: ["/admin/help-questions"],
}, },
{ {
href: "/admin/writeable-boxes", href: "/admin/writeable-boxes",
labelKey: "writeableBoxes", labelKey: "writeableBoxes",
icon: Package, icon: Package,
permission: PERMS.PAGES_VIEW,
},
{
href: "/admin/tags",
labelKey: "tags",
icon: Tags,
permission: PERMS.PAGES_VIEW,
},
{
href: "/admin/prefixes",
labelKey: "prefixes",
icon: Sparkles,
permission: PERMS.PREFIXES_VIEW,
}, },
{ href: "/admin/tags", labelKey: "tags", icon: Tags },
{ href: "/admin/prefixes", labelKey: "prefixes", icon: Sparkles },
], ],
}, },
{ {
@@ -278,18 +319,21 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/events", href: "/admin/events",
labelKey: "events", labelKey: "events",
icon: Calendar, icon: Calendar,
permission: PERMS.EVENTS_VIEW,
matchPrefixes: ["/admin/events"], matchPrefixes: ["/admin/events"],
}, },
{ {
href: "/admin/polls", href: "/admin/polls",
labelKey: "polls", labelKey: "polls",
icon: Vote, icon: Vote,
permission: PERMS.POLLS_VIEW,
matchPrefixes: ["/admin/polls"], matchPrefixes: ["/admin/polls"],
}, },
{ {
href: "/admin/tickets", href: "/admin/tickets",
labelKey: "tickets", labelKey: "tickets",
icon: Ticket, icon: Ticket,
permission: PERMS.TICKETS_VIEW,
matchPrefixes: ["/admin/tickets", "/admin/help-tickets"], matchPrefixes: ["/admin/tickets", "/admin/help-tickets"],
}, },
], ],
@@ -302,6 +346,7 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/users", href: "/admin/users",
labelKey: "users", labelKey: "users",
icon: Users, icon: Users,
permission: PERMS.USERS_VIEW,
matchPrefixes: ["/admin/users"], matchPrefixes: ["/admin/users"],
matchExcludePrefixes: ["/admin/users/multi-accounts"], matchExcludePrefixes: ["/admin/users/multi-accounts"],
}, },
@@ -309,31 +354,64 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/users/multi-accounts", href: "/admin/users/multi-accounts",
labelKey: "multiAccounts", labelKey: "multiAccounts",
icon: Users, icon: Users,
permission: PERMS.USERS_VIEW,
},
{
href: "/admin/online",
labelKey: "onlineUsers",
icon: Wifi,
permission: PERMS.USERS_VIEW,
}, },
{ href: "/admin/online", labelKey: "onlineUsers", icon: Wifi },
{ {
href: "/admin/applications", href: "/admin/applications",
labelKey: "applications", labelKey: "applications",
icon: ClipboardList, icon: ClipboardList,
permission: PERMS.USERS_VIEW,
}, },
{ {
href: "/admin/teams", href: "/admin/teams",
labelKey: "staffAccess", labelKey: "staffAccess",
icon: KeyRound, icon: KeyRound,
permission: [
PERMS.USERS_VIEW,
PERMS.PERMISSIONS_MANAGE,
PERMS.SETTINGS_VIEW,
],
matchPrefixes: [ matchPrefixes: [
"/admin/teams", "/admin/teams",
"/admin/permissions", "/admin/permissions",
"/admin/housekeeping", "/admin/housekeeping",
], ],
}, },
{ href: "/admin/bans", labelKey: "bans", icon: Ban }, {
{ href: "/admin/ip", labelKey: "ipManagement", icon: Shield }, href: "/admin/bans",
{ href: "/admin/vpn", labelKey: "vpn", icon: Shield }, labelKey: "bans",
{ href: "/admin/wordfilter", labelKey: "wordFilter", icon: Filter }, icon: Ban,
permission: PERMS.BANS_VIEW,
},
{
href: "/admin/ip",
labelKey: "ipManagement",
icon: Shield,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/vpn",
labelKey: "vpn",
icon: Shield,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/wordfilter",
labelKey: "wordFilter",
icon: Filter,
permission: PERMS.WORDFILTER_VIEW,
},
{ {
href: "/admin/moderation", href: "/admin/moderation",
labelKey: "moderation", labelKey: "moderation",
icon: Gavel, icon: Gavel,
permission: PERMS.MODERATION_VIEW,
matchPrefixes: ["/admin/moderation"], matchPrefixes: ["/admin/moderation"],
}, },
], ],
@@ -346,24 +424,62 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/catalog", href: "/admin/catalog",
labelKey: "catalog", labelKey: "catalog",
icon: Store, icon: Store,
permission: PERMS.CATALOG_VIEW,
matchPrefixes: ["/admin/catalog"], matchPrefixes: ["/admin/catalog"],
}, },
{ href: "/admin/rare-values", labelKey: "rareValues", icon: Sparkles }, {
{ href: "/admin/badges", labelKey: "badges", icon: BadgeCheck }, href: "/admin/rare-values",
{ href: "/admin/achievements", labelKey: "achievements", icon: Trophy }, labelKey: "rareValues",
{ href: "/admin/sounds", labelKey: "sounds", icon: Volume2 }, icon: Sparkles,
{ href: "/admin/shop", labelKey: "shop", icon: ShoppingCart }, permission: PERMS.SHOP_VIEW,
{ href: "/admin/transactions", labelKey: "transactions", icon: Activity }, },
{ href: "/admin/vouchers", labelKey: "vouchers", icon: Ticket }, {
href: "/admin/badges",
labelKey: "badges",
icon: BadgeCheck,
permission: PERMS.CATALOG_VIEW,
},
{
href: "/admin/achievements",
labelKey: "achievements",
icon: Trophy,
permission: PERMS.CATALOG_VIEW,
},
{
href: "/admin/sounds",
labelKey: "sounds",
icon: Volume2,
permission: PERMS.CATALOG_VIEW,
},
{
href: "/admin/shop",
labelKey: "shop",
icon: ShoppingCart,
permission: PERMS.SHOP_VIEW,
},
{
href: "/admin/transactions",
labelKey: "transactions",
icon: Activity,
permission: PERMS.SHOP_VIEW,
},
{
href: "/admin/vouchers",
labelKey: "vouchers",
icon: Ticket,
permission: PERMS.SHOP_VIEW,
},
{ {
href: "/admin/subscriptions", href: "/admin/subscriptions",
labelKey: "subscriptions", labelKey: "subscriptions",
icon: ClipboardList, icon: ClipboardList,
permission: PERMS.SHOP_VIEW,
}, },
{ {
href: "/admin/calendar", href: "/admin/calendar",
labelKey: "calendar", labelKey: "calendar",
icon: CalendarDays, icon: CalendarDays,
permission: PERMS.SHOP_VIEW,
matchPrefixes: ["/admin/calendar"], matchPrefixes: ["/admin/calendar"],
}, },
], ],
@@ -376,6 +492,7 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/radio", href: "/admin/radio",
labelKey: "radio", labelKey: "radio",
icon: Radio, icon: Radio,
permission: PERMS.RADIO_VIEW,
matchPrefixes: ["/admin/radio"], matchPrefixes: ["/admin/radio"],
}, },
], ],
@@ -384,15 +501,41 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
labelKey: "system", labelKey: "system",
icon: Settings, icon: Settings,
items: [ items: [
{ href: "/admin/settings", labelKey: "settings", icon: Cog }, {
{ href: "/admin/theme", labelKey: "theme", icon: Sparkles }, href: "/admin/settings",
{ href: "/admin/maintenance", labelKey: "maintenance", icon: Wrench }, labelKey: "settings",
{ href: "/admin/favicon", labelKey: "favicon", icon: Image }, icon: Cog,
{ href: "/admin/emulator", labelKey: "emulator", icon: Server }, permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/theme",
labelKey: "theme",
icon: Sparkles,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/maintenance",
labelKey: "maintenance",
icon: Wrench,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/favicon",
labelKey: "favicon",
icon: Image,
permission: PERMS.SETTINGS_VIEW,
},
{
href: "/admin/emulator",
labelKey: "emulator",
icon: Server,
permission: PERMS.SETTINGS_VIEW,
},
{ {
href: "/admin/email-templates", href: "/admin/email-templates",
labelKey: "emailTemplates", labelKey: "emailTemplates",
icon: FileText, icon: FileText,
permission: PERMS.PAGES_VIEW,
}, },
], ],
}, },
@@ -404,23 +547,27 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/commandocentrum", href: "/admin/commandocentrum",
labelKey: "commandocentrum", labelKey: "commandocentrum",
icon: Terminal, icon: Terminal,
permission: PERMS.RCON_EXECUTE,
}, },
{ {
href: "/admin/rooms", href: "/admin/rooms",
labelKey: "rooms", labelKey: "rooms",
icon: Store, icon: Store,
permission: PERMS.ROOMS_VIEW,
matchPrefixes: ["/admin/rooms"], matchPrefixes: ["/admin/rooms"],
}, },
{ {
href: "/admin/import", href: "/admin/import",
labelKey: "import", labelKey: "import",
icon: Import, icon: Import,
permission: PERMS.ASSETS_IMPORT,
matchPrefixes: ["/admin/import"], matchPrefixes: ["/admin/import"],
}, },
{ {
href: "/admin/translations", href: "/admin/translations",
labelKey: "translations", labelKey: "translations",
icon: Languages, icon: Languages,
permission: PERMS.SETTINGS_VIEW,
matchPrefixes: ["/admin/translations"], matchPrefixes: ["/admin/translations"],
}, },
], ],
@@ -433,6 +580,7 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/logs", href: "/admin/logs",
labelKey: "logs", labelKey: "logs",
icon: FileText, icon: FileText,
permission: PERMS.LOGS_VIEW,
matchPrefixes: ["/admin/logs"], matchPrefixes: ["/admin/logs"],
matchExcludePrefixes: [ matchExcludePrefixes: [
"/admin/logs/audit", "/admin/logs/audit",
@@ -441,23 +589,78 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
"/admin/logs/trades", "/admin/logs/trades",
], ],
}, },
{ href: "/admin/logs/audit", labelKey: "auditLog", icon: ClipboardList }, {
{ href: "/admin/logs/chat", labelKey: "chatLog", icon: FileText }, href: "/admin/logs/audit",
{ href: "/admin/logs/commands", labelKey: "commandLog", icon: Terminal }, labelKey: "auditLog",
{ href: "/admin/logs/trades", labelKey: "tradeLog", icon: Activity }, icon: ClipboardList,
permission: PERMS.LOGS_VIEW,
},
{
href: "/admin/logs/chat",
labelKey: "chatLog",
icon: FileText,
permission: PERMS.LOGS_VIEW,
},
{
href: "/admin/logs/commands",
labelKey: "commandLog",
icon: Terminal,
permission: PERMS.LOGS_VIEW,
},
{
href: "/admin/logs/trades",
labelKey: "tradeLog",
icon: Activity,
permission: PERMS.LOGS_VIEW,
},
{ {
href: "/admin/analytics", href: "/admin/analytics",
labelKey: "analytics", labelKey: "analytics",
icon: Activity, icon: Activity,
permission: PERMS.ANALYTICS_VIEW,
matchPrefixes: ["/admin/analytics"], matchPrefixes: ["/admin/analytics"],
}, },
{ {
href: "/admin/devops", href: "/admin/devops",
labelKey: "devops", labelKey: "devops",
icon: Server, icon: Server,
permission: PERMS.DEVOPS_VIEW,
matchPrefixes: ["/admin/devops"], matchPrefixes: ["/admin/devops"],
}, },
{ href: "/admin/alerts", labelKey: "alerts", icon: AlertTriangle }, {
href: "/admin/alerts",
labelKey: "alerts",
icon: AlertTriangle,
permission: PERMS.NOTIFICATIONS_VIEW,
},
], ],
}, },
]; ];
/** Whether a nav item should be visible for the given permission set. */
export function navItemIsAllowed(
item: AdminNavItem,
opts: { isSuperAdmin: boolean; has: (slug: string) => boolean },
): boolean {
if (opts.isSuperAdmin) return true;
if (!item.permission) return true;
const needed = Array.isArray(item.permission)
? item.permission
: [item.permission];
return needed.some((slug) => opts.has(slug));
}
/** Collect every ACL slug referenced by the sidebar (for layout gating). */
export function collectNavPermissionSlugs(): string[] {
const slugs = new Set<string>();
for (const group of ADMIN_NAV_GROUPS) {
for (const item of group.items) {
if (!item.permission) continue;
const needed = Array.isArray(item.permission)
? item.permission
: [item.permission];
for (const slug of needed) slugs.add(slug);
}
}
return [...slugs];
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { activeBanWhere, isBanActive } from "@/lib/bans";
describe("isBanActive", () => {
it("treats banExpire 0 as permanent", () => {
expect(isBanActive(0, 1_700_000_000)).toBe(true);
});
it("treats future expiry as active", () => {
expect(isBanActive(1_800_000_000, 1_700_000_000)).toBe(true);
});
it("treats past expiry as inactive", () => {
expect(isBanActive(1_600_000_000, 1_700_000_000)).toBe(false);
});
});
describe("activeBanWhere", () => {
it("includes permanent and future expires", () => {
expect(activeBanWhere(100)).toEqual({
OR: [{ banExpire: 0 }, { banExpire: { gt: 100 } }],
});
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* Emulator-compatible ban expiry helpers.
* `banExpire === 0` means permanent (Arcturus / Plus-style).
*/
export function unixNow(): number {
return Math.floor(Date.now() / 1000);
}
export function isBanActive(
banExpire: number,
now: number = unixNow(),
): boolean {
return banExpire === 0 || banExpire > now;
}
/** Prisma `where` fragment for currently active bans (incl. permanent). */
export function activeBanWhere(now: number = unixNow()) {
return {
OR: [{ banExpire: 0 }, { banExpire: { gt: now } }],
};
}