fix(admin): P0 integrity — permanent bans, ACL sidebar, rank guards
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
11004626c7
commit
75cdfdebe4
14 files changed
+458
-68
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
@@ -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 } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } }],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user