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
+458 -68

No files matched your search

+2 -4
View File
@@ -15,8 +15,6 @@ const BAN_TYPES: ReadonlySet<string> = new Set([
"machine",
"super",
]);
const PERMANENT_SECONDS = 100 * 365 * 24 * 3600;
export async function createBan(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
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;
const now = Math.floor(Date.now() / 1000);
const banExpire =
hours > 0 ? now + Math.floor(hours) * 3600 : now + PERMANENT_SECONDS;
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
const user = await prisma.user.findUnique({
where: { id: userId },
+32 -7
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
@@ -70,9 +71,7 @@ export const disconnectUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
async (ctx) => {
const username = ctx.data.username.normalize("NFC");
await requireRconOk(
await rcon.disconnectUser(ctx.data.userId, username),
);
await requireRconOk(await rcon.disconnectUser(ctx.data.userId, username));
revalidatePath(PATH);
return actionOk();
},
@@ -191,14 +190,42 @@ export const setMotto = adminAction(
const setRankSchema = z.object({
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). */
export const setRank = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
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 prisma.user.update({
where: { id: ctx.data.userId },
data: { rank: ctx.data.rank },
});
revalidatePath(PATH);
return actionOk();
},
@@ -214,9 +241,7 @@ export const executeCommand = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
async (ctx) => {
const command = ctx.data.command.normalize("NFC");
await requireRconOk(
await rcon.executeCommand(ctx.data.userId, command),
);
await requireRconOk(await rcon.executeCommand(ctx.data.userId, command));
revalidatePath(PATH);
return actionOk();
},
+18 -14
View File
@@ -1,6 +1,7 @@
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { activeBanWhere, unixNow } from "@/lib/bans";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -12,15 +13,19 @@ export default async function BannedPage() {
const session = await auth();
let reason = "";
let expire = 0;
let hasBan = false;
if (session?.user?.id) {
try {
const now = Math.floor(Date.now() / 1000);
const ban = await prisma.ban.findFirst({
where: { userId: Number(session.user.id), banExpire: { gt: now } },
orderBy: { banExpire: "desc" },
where: {
userId: Number(session.user.id),
...activeBanWhere(unixNow()),
},
orderBy: { timestamp: "desc" },
select: { banReason: true, banExpire: true },
});
if (ban) {
hasBan = true;
reason = ban.banReason;
expire = ban.banExpire;
}
@@ -29,17 +34,16 @@ export default async function BannedPage() {
}
}
const expiryText =
expire === 0
? ""
: expire > FAR_FUTURE
? t("permanent")
: t("expires", {
date: new Date(expire * 1000)
.toISOString()
.slice(0, 16)
.replace("T", " "),
});
const expiryText = !hasBan
? ""
: expire === 0 || expire > FAR_FUTURE
? t("permanent")
: t("expires", {
date: new Date(expire * 1000)
.toISOString()
.slice(0, 16)
.replace("T", " "),
});
return (
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
+3 -3
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { createBan, liftBan } from "@/actions/admin-bans";
import { StatusCard } from "@/components/admin/dashboard";
import { activeBanWhere, unixNow } from "@/lib/bans";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
@@ -18,11 +19,11 @@ export default async function AdminBans() {
}
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>> = [];
try {
bans = await prisma.ban.findMany({
where: { banExpire: { gt: now } },
where: activeBanWhere(now),
orderBy: { timestamp: "desc" },
take: 100,
});
@@ -30,7 +31,6 @@ export default async function AdminBans() {
bans = [];
}
// banExpire of 0 means permanent in the emulator schema.
const permanent = bans.filter((b) => b.banExpire === 0).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 { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard";
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
import { setCsrfCookie } from "@/lib/foundation/security";
import { canAccess, getAdminContext } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
@@ -66,6 +68,13 @@ async function Sidebar({
}) {
const t = await getTranslations("pages.admin.nav");
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 (
<aside
@@ -93,7 +102,10 @@ async function Sidebar({
</div>
</div>
<AdminSidebarNav />
<AdminSidebarNav
allowedPermissions={allowedPermissions}
isSuperAdmin={isSuperAdmin}
/>
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
<Link
+2 -1
View File
@@ -9,6 +9,7 @@ import {
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { activeBanWhere } from "@/lib/bans";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -31,7 +32,7 @@ export default async function AdminDashboard() {
prisma.user.count({ where: { online: "1" } }),
prisma.websiteArticles.count(),
prisma.ban.count({
where: { banExpire: { gt: Math.floor(Date.now() / 1000) } },
where: activeBanWhere(),
}),
]);
} catch {}
@@ -5,7 +5,7 @@ import { logAudit } from "@/lib/services/audit";
import { uploadSingleFurni } from "@/lib/services/upload-import";
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
{ permission: PERMS.ASSETS_IMPORT, maxBodyBytes: 52 * 1024 * 1024 },
async (request, ctx) => {
const formData = await request.formData();
+45 -1
View File
@@ -9,6 +9,7 @@ export const POST = withAdmin(
{ permission: PERMS.USERS_EDIT },
async (request, context) => {
const staffId = context.session.user.id;
const staffRank = context.session.user.rank;
const formData = await request.formData();
const userId = Number(formData.get("userId"));
const username = String(formData.get("username") || "");
@@ -23,13 +24,56 @@ export const POST = withAdmin(
if (action === "set_rank") {
const rank = Number(formData.get("rank") || "0");
if (!rank || rank < 0 || rank > 10) {
if (!Number.isInteger(rank) || rank < 1) {
return NextResponse.json(
{ success: false, message: "Invalid rank value" },
{ 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 rcon.setRank(userId, rank);
await logStaffActivity({
+29 -3
View File
@@ -5,7 +5,11 @@ import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
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";
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 pathname = usePathname() ?? "";
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
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]);
useEffect(() => {
+31 -3
View File
@@ -2,6 +2,7 @@
import Image from "next/image";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface OnlineUser {
id: number;
@@ -14,12 +15,29 @@ export interface OnlineUser {
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, …). */
export function StatusCard({
label,
value,
hint,
state: _state = "neutral",
state = "neutral",
icon,
}: {
label: string;
@@ -29,13 +47,23 @@ export function StatusCard({
icon?: ReactNode;
}) {
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">
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]">
{label}
</span>
{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}
</span>
) : null}
+5 -2
View File
@@ -1,6 +1,7 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { activeBanWhere } from "@/lib/bans";
import { safeRedirect } from "@/lib/foundation/security";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
@@ -62,9 +63,11 @@ export async function enforceSiteAccess(): Promise<void> {
try {
if (!target && session?.user?.id) {
const now = Math.floor(Date.now() / 1000);
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 },
});
if (ban) target = "/banned";
+231 -28
View File
@@ -39,6 +39,7 @@ import {
Wifi,
Wrench,
} from "lucide-react";
import { PERMS } from "@/lib/permission-slugs";
export interface AdminHubTab {
href: string;
@@ -64,6 +65,11 @@ export interface AdminNavItem {
href: string;
labelKey: string;
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. */
matchPrefixes?: string[];
/** 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",
labelKey: "dashboard",
icon: LayoutDashboard,
permission: PERMS.ADMIN_DASHBOARD,
},
],
},
@@ -243,31 +250,65 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/articles",
labelKey: "articles",
icon: Newspaper,
permission: PERMS.NEWS_VIEW,
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",
labelKey: "advertisements",
icon: FileText,
permission: PERMS.PAGES_VIEW,
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",
labelKey: "helpCenter",
icon: HelpCircle,
permission: PERMS.PAGES_VIEW,
matchPrefixes: ["/admin/help-questions"],
},
{
href: "/admin/writeable-boxes",
labelKey: "writeableBoxes",
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",
labelKey: "events",
icon: Calendar,
permission: PERMS.EVENTS_VIEW,
matchPrefixes: ["/admin/events"],
},
{
href: "/admin/polls",
labelKey: "polls",
icon: Vote,
permission: PERMS.POLLS_VIEW,
matchPrefixes: ["/admin/polls"],
},
{
href: "/admin/tickets",
labelKey: "tickets",
icon: Ticket,
permission: PERMS.TICKETS_VIEW,
matchPrefixes: ["/admin/tickets", "/admin/help-tickets"],
},
],
@@ -302,6 +346,7 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/users",
labelKey: "users",
icon: Users,
permission: PERMS.USERS_VIEW,
matchPrefixes: ["/admin/users"],
matchExcludePrefixes: ["/admin/users/multi-accounts"],
},
@@ -309,31 +354,64 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/users/multi-accounts",
labelKey: "multiAccounts",
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",
labelKey: "applications",
icon: ClipboardList,
permission: PERMS.USERS_VIEW,
},
{
href: "/admin/teams",
labelKey: "staffAccess",
icon: KeyRound,
permission: [
PERMS.USERS_VIEW,
PERMS.PERMISSIONS_MANAGE,
PERMS.SETTINGS_VIEW,
],
matchPrefixes: [
"/admin/teams",
"/admin/permissions",
"/admin/housekeeping",
],
},
{ href: "/admin/bans", labelKey: "bans", icon: Ban },
{ href: "/admin/ip", labelKey: "ipManagement", icon: Shield },
{ href: "/admin/vpn", labelKey: "vpn", icon: Shield },
{ href: "/admin/wordfilter", labelKey: "wordFilter", icon: Filter },
{
href: "/admin/bans",
labelKey: "bans",
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",
labelKey: "moderation",
icon: Gavel,
permission: PERMS.MODERATION_VIEW,
matchPrefixes: ["/admin/moderation"],
},
],
@@ -346,24 +424,62 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/catalog",
labelKey: "catalog",
icon: Store,
permission: PERMS.CATALOG_VIEW,
matchPrefixes: ["/admin/catalog"],
},
{ href: "/admin/rare-values", labelKey: "rareValues", icon: Sparkles },
{ href: "/admin/badges", labelKey: "badges", icon: BadgeCheck },
{ href: "/admin/achievements", labelKey: "achievements", icon: Trophy },
{ href: "/admin/sounds", labelKey: "sounds", icon: Volume2 },
{ href: "/admin/shop", labelKey: "shop", icon: ShoppingCart },
{ href: "/admin/transactions", labelKey: "transactions", icon: Activity },
{ href: "/admin/vouchers", labelKey: "vouchers", icon: Ticket },
{
href: "/admin/rare-values",
labelKey: "rareValues",
icon: Sparkles,
permission: PERMS.SHOP_VIEW,
},
{
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",
labelKey: "subscriptions",
icon: ClipboardList,
permission: PERMS.SHOP_VIEW,
},
{
href: "/admin/calendar",
labelKey: "calendar",
icon: CalendarDays,
permission: PERMS.SHOP_VIEW,
matchPrefixes: ["/admin/calendar"],
},
],
@@ -376,6 +492,7 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/radio",
labelKey: "radio",
icon: Radio,
permission: PERMS.RADIO_VIEW,
matchPrefixes: ["/admin/radio"],
},
],
@@ -384,15 +501,41 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
labelKey: "system",
icon: Settings,
items: [
{ href: "/admin/settings", labelKey: "settings", icon: Cog },
{ href: "/admin/theme", labelKey: "theme", icon: Sparkles },
{ href: "/admin/maintenance", labelKey: "maintenance", icon: Wrench },
{ href: "/admin/favicon", labelKey: "favicon", icon: Image },
{ href: "/admin/emulator", labelKey: "emulator", icon: Server },
{
href: "/admin/settings",
labelKey: "settings",
icon: Cog,
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",
labelKey: "emailTemplates",
icon: FileText,
permission: PERMS.PAGES_VIEW,
},
],
},
@@ -404,23 +547,27 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/commandocentrum",
labelKey: "commandocentrum",
icon: Terminal,
permission: PERMS.RCON_EXECUTE,
},
{
href: "/admin/rooms",
labelKey: "rooms",
icon: Store,
permission: PERMS.ROOMS_VIEW,
matchPrefixes: ["/admin/rooms"],
},
{
href: "/admin/import",
labelKey: "import",
icon: Import,
permission: PERMS.ASSETS_IMPORT,
matchPrefixes: ["/admin/import"],
},
{
href: "/admin/translations",
labelKey: "translations",
icon: Languages,
permission: PERMS.SETTINGS_VIEW,
matchPrefixes: ["/admin/translations"],
},
],
@@ -433,6 +580,7 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/logs",
labelKey: "logs",
icon: FileText,
permission: PERMS.LOGS_VIEW,
matchPrefixes: ["/admin/logs"],
matchExcludePrefixes: [
"/admin/logs/audit",
@@ -441,23 +589,78 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
"/admin/logs/trades",
],
},
{ href: "/admin/logs/audit", labelKey: "auditLog", icon: ClipboardList },
{ href: "/admin/logs/chat", labelKey: "chatLog", icon: FileText },
{ href: "/admin/logs/commands", labelKey: "commandLog", icon: Terminal },
{ href: "/admin/logs/trades", labelKey: "tradeLog", icon: Activity },
{
href: "/admin/logs/audit",
labelKey: "auditLog",
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",
labelKey: "analytics",
icon: Activity,
permission: PERMS.ANALYTICS_VIEW,
matchPrefixes: ["/admin/analytics"],
},
{
href: "/admin/devops",
labelKey: "devops",
icon: Server,
permission: PERMS.DEVOPS_VIEW,
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 } }],
};
}