diff --git a/src/actions/admin-bans.ts b/src/actions/admin-bans.ts index 933964a7..cffdf017 100644 --- a/src/actions/admin-bans.ts +++ b/src/actions/admin-bans.ts @@ -15,8 +15,6 @@ const BAN_TYPES: ReadonlySet = new Set([ "machine", "super", ]); -const PERMANENT_SECONDS = 100 * 365 * 24 * 3600; - export async function createBan(formData: FormData): Promise { const staff = await requirePermissionRateLimited(PERMS.USERS_BAN); const userId = Number(formData.get("userId")); @@ -30,8 +28,8 @@ export async function createBan(formData: FormData): Promise { 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 }, diff --git a/src/actions/commandocentrum.ts b/src/actions/commandocentrum.ts index acfee124..235af851 100644 --- a/src/actions/commandocentrum.ts +++ b/src/actions/commandocentrum.ts @@ -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(); }, diff --git a/src/app/(site)/banned/page.tsx b/src/app/(site)/banned/page.tsx index dcd07fbf..c7594df8 100644 --- a/src/app/(site)/banned/page.tsx +++ b/src/app/(site)/banned/page.tsx @@ -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 (
diff --git a/src/app/admin/bans/page.tsx b/src/app/admin/bans/page.tsx index 426fd06d..99702f5a 100644 --- a/src/app/admin/bans/page.tsx +++ b/src/app/admin/bans/page.tsx @@ -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> = []; 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; diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 78b3fb4c..c4bd79de 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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 (