From 084cca6ea40343c27d9130fb9a836a7792e4ddfa Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 22 Jul 2026 21:29:32 +0200 Subject: [PATCH] feat(mod): lite /mod panel + clarify ACL vs housekeeping legacy Co-authored-by: Cursor --- src/actions/moderation.ts | 21 ++- src/app/admin/housekeeping/page.tsx | 16 ++ .../admin/moderation/cfh/[id]/cfh-detail.tsx | 19 +- src/app/admin/moderation/cfh/cfh-table.tsx | 25 ++- .../admin/permissions/permissions-list.tsx | 11 ++ src/app/mod/actions/page.tsx | 13 ++ src/app/mod/bans/page.tsx | 100 +++++++++++ src/app/mod/cfh/[id]/page.tsx | 91 ++++++++++ src/app/mod/cfh/page.tsx | 162 ++++++++++++++++++ src/app/mod/layout.tsx | 120 +++++++++++++ src/app/mod/page.tsx | 114 ++++++++++++ src/components/navigation.tsx | 61 ++++++- src/components/top-header.tsx | 74 +++++--- src/lib/admin/guard.ts | 36 +++- src/lib/foundation/action.ts | 19 +- src/messages/en.json | 42 ++++- src/messages/it.json | 42 ++++- src/messages/nl.json | 42 ++++- 18 files changed, 931 insertions(+), 77 deletions(-) create mode 100644 src/app/mod/actions/page.tsx create mode 100644 src/app/mod/bans/page.tsx create mode 100644 src/app/mod/cfh/[id]/page.tsx create mode 100644 src/app/mod/cfh/page.tsx create mode 100644 src/app/mod/layout.tsx create mode 100644 src/app/mod/page.tsx diff --git a/src/actions/moderation.ts b/src/actions/moderation.ts index ef2c0b8b..7e223748 100644 --- a/src/actions/moderation.ts +++ b/src/actions/moderation.ts @@ -12,8 +12,11 @@ import { rcon } from "@/lib/services/rcon"; const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() }); +const CFH_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT] as const; +const MOD_ACTION_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] as const; + export const assignCfhTicket = adminAction( - { permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema }, + { permission: CFH_PERM, schema: cfhIdSchema }, async (ctx) => { const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId }, @@ -42,7 +45,7 @@ const cfhStateSchema = z.object({ }); export const updateCfhState = adminAction( - { permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema }, + { permission: CFH_PERM, schema: cfhStateSchema }, async (ctx) => { const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId }, @@ -68,7 +71,7 @@ export const updateCfhState = adminAction( ); export const closeCfhTicket = adminAction( - { permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema }, + { permission: CFH_PERM, schema: cfhIdSchema }, async (ctx) => { await prisma.supportTickets.update({ where: { id: ctx.data.ticketId }, @@ -91,7 +94,7 @@ export const closeCfhTicket = adminAction( const userIdSchema = z.object({ userId: z.coerce.number().int().positive() }); export const quickKick = adminAction( - { permission: PERMS.MODERATION_EDIT, schema: userIdSchema }, + { permission: MOD_ACTION_PERM, schema: userIdSchema }, async (ctx) => { await rcon.disconnectUser(ctx.data.userId); @@ -112,7 +115,7 @@ const muteSchema = z.object({ }); export const quickMute = adminAction( - { permission: PERMS.MODERATION_EDIT, schema: muteSchema }, + { permission: MOD_ACTION_PERM, schema: muteSchema }, async (ctx) => { await rcon.muteUser(ctx.data.userId, ctx.data.duration); @@ -129,7 +132,7 @@ export const quickMute = adminAction( ); export const quickUnmute = adminAction( - { permission: PERMS.MODERATION_EDIT, schema: userIdSchema }, + { permission: MOD_ACTION_PERM, schema: userIdSchema }, async (ctx) => { await rcon.unmuteUser(ctx.data.userId); @@ -150,7 +153,7 @@ const alertSchema = z.object({ }); export const quickAlert = adminAction( - { permission: PERMS.MODERATION_EDIT, schema: alertSchema }, + { permission: MOD_ACTION_PERM, schema: alertSchema }, async (ctx) => { await rcon.alertUser(ctx.data.userId, ctx.data.message); @@ -169,7 +172,7 @@ export const quickAlert = adminAction( const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() }); export const quickRoomKick = adminAction( - { permission: PERMS.MODERATION_EDIT, schema: roomIdSchema }, + { permission: MOD_ACTION_PERM, schema: roomIdSchema }, async (ctx) => { await rcon.kickAll(ctx.data.roomId); @@ -190,7 +193,7 @@ const broadcastSchema = z.object({ }); export const broadcastAlert = adminAction( - { permission: PERMS.MODERATION_EDIT, schema: broadcastSchema }, + { permission: MOD_ACTION_PERM, schema: broadcastSchema }, async (ctx) => { if (ctx.data.type === "hotel") { await rcon.hotelAlert(ctx.data.message); diff --git a/src/app/admin/housekeeping/page.tsx b/src/app/admin/housekeeping/page.tsx index b6ed0642..6646bfb6 100644 --- a/src/app/admin/housekeeping/page.tsx +++ b/src/app/admin/housekeeping/page.tsx @@ -106,6 +106,22 @@ export default async function AdminHousekeeping({ return (
+
+

{t("legacyBannerTitle")}

+

+ {t("legacyBannerBody")}{" "} + + {t("legacyBannerCta")} + +

+
+
{/* Header */}
- + @@ -155,7 +166,7 @@ export function CfhDetail({ {sender ? ( @@ -184,7 +195,7 @@ export function CfhDetail({ {reported ? (
@@ -400,7 +411,7 @@ export function CfhDetail({ {moderator ? ( diff --git a/src/app/admin/moderation/cfh/cfh-table.tsx b/src/app/admin/moderation/cfh/cfh-table.tsx index f5790cfa..5c2ec5c7 100644 --- a/src/app/admin/moderation/cfh/cfh-table.tsx +++ b/src/app/admin/moderation/cfh/cfh-table.tsx @@ -20,9 +20,26 @@ export interface CfhRow { stateLabel: string; } -export function CfhTable({ data }: { data: PaginatedResult }) { +export function CfhTable({ + data, + basePath = "/admin/moderation/cfh", + userLinkMode = "admin", +}: { + data: PaginatedResult; + /** Detail URL prefix (no trailing slash). */ + basePath?: string; + /** Admin user show vs public profile. */ + userLinkMode?: "admin" | "public"; +}) { const t = useTranslations("pages.admin.cfh"); + function userHref(id: number, username: string) { + if (userLinkMode === "public" && username && username !== "—") { + return `/u/${encodeURIComponent(username)}`; + } + return `/admin/users/${id}`; + } + const columns: DataTableColumn[] = [ { key: "id", label: t("colId"), sortable: true }, { @@ -32,7 +49,7 @@ export function CfhTable({ data }: { data: PaginatedResult }) { render: (_value, row) => (
{row.issue || t("noDescription")} @@ -50,7 +67,7 @@ export function CfhTable({ data }: { data: PaginatedResult }) { render: (_value, row) => row.senderId ? ( {row.sender} @@ -66,7 +83,7 @@ export function CfhTable({ data }: { data: PaginatedResult }) { render: (_value, row) => row.reportedId ? ( {row.reported} diff --git a/src/app/admin/permissions/permissions-list.tsx b/src/app/admin/permissions/permissions-list.tsx index 9bcaa3bd..5230ae23 100644 --- a/src/app/admin/permissions/permissions-list.tsx +++ b/src/app/admin/permissions/permissions-list.tsx @@ -98,6 +98,17 @@ export function PermissionsList({ ranks: initialRanks }: Props) { return (
+
+

Live ACL

+

+ Rank ACL here controls real admin access. The Housekeeping table is + legacy reference only and does not gate routes. +

+
+ {/* Header */}

diff --git a/src/app/mod/actions/page.tsx b/src/app/mod/actions/page.tsx new file mode 100644 index 00000000..7aad1475 --- /dev/null +++ b/src/app/mod/actions/page.tsx @@ -0,0 +1,13 @@ +import { ModActionsClient } from "@/app/admin/moderation/actions/mod-actions-client"; +import { requireModPermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; + +export default async function ModQuickActionsPage() { + await requireModPermission([PERMS.MOD_ACTIONS, PERMS.MODERATION_EDIT]); + + return ( +

+ +
+ ); +} diff --git a/src/app/mod/bans/page.tsx b/src/app/mod/bans/page.tsx new file mode 100644 index 00000000..49d230f5 --- /dev/null +++ b/src/app/mod/bans/page.tsx @@ -0,0 +1,100 @@ +import { getTranslations } from "next-intl/server"; +import { StatusCard } from "@/components/admin/dashboard"; +import { requireModPermission } from "@/lib/admin/guard"; +import { activeBanWhere, unixNow } from "@/lib/bans"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +function fromUnix(ts: number): string { + return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " "); +} + +export default async function ModBansPage() { + await requireModPermission([PERMS.MOD_BANS_VIEW, PERMS.BANS_VIEW]); + + const t = await getTranslations("pages.admin.bans"); + const now = unixNow(); + let bans: Awaited> = []; + try { + bans = await prisma.ban.findMany({ + where: activeBanWhere(now), + orderBy: { timestamp: "desc" }, + take: 100, + }); + } catch { + bans = []; + } + + const permanent = bans.filter((b) => b.banExpire === 0).length; + const accountBans = bans.filter((b) => b.type === "account").length; + + return ( +
+

+ Read-only ban list for moderators. Create/lift bans requires the full + admin panel. +

+
+ 0 ? "warn" : "ok"} + icon="🔨" + /> + 0 ? "danger" : "neutral"} + icon="⛔" + /> + +
+ +
+

+ {t("activeBans")} ({bans.length}) +

+ {bans.length === 0 ? ( +
{t("noBans")}
+ ) : ( +
+ + + + + + + + + + + {bans.map((b) => ( + + + + + + + ))} + +
{t("colUserId")}{t("colType")}{t("colReason")}{t("colExpires")}
{b.userId} + + {b.type} + + {b.banReason || "—"} + {b.banExpire === 0 ? ( + + {t("permanent")} + + ) : ( + fromUnix(b.banExpire) + )} +
+
+ )} +
+
+ ); +} diff --git a/src/app/mod/cfh/[id]/page.tsx b/src/app/mod/cfh/[id]/page.tsx new file mode 100644 index 00000000..6556e5de --- /dev/null +++ b/src/app/mod/cfh/[id]/page.tsx @@ -0,0 +1,91 @@ +import { notFound } from "next/navigation"; +import { CfhDetail } from "@/app/admin/moderation/cfh/[id]/cfh-detail"; +import { requireModPermission } from "@/lib/admin/guard"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; + +export default async function ModCfhDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + await requireModPermission([PERMS.MOD_CFH_VIEW, PERMS.MODERATION_VIEW]); + + const { session, permissions } = await getAdminContext(); + const { id } = await params; + const ticketId = Number(id); + if (Number.isNaN(ticketId)) notFound(); + + const ticket = await prisma.supportTickets.findUnique({ + where: { id: ticketId }, + }); + if (!ticket) notFound(); + + const canEdit = + canAccess(permissions, PERMS.MOD_CFH_EDIT, session.user.rank) || + canAccess(permissions, PERMS.MODERATION_EDIT, session.user.rank); + + const userIds = [ticket.senderId, ticket.reportedId, ticket.modId].filter( + Boolean, + ); + const users = + userIds.length > 0 + ? await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { + id: true, + username: true, + look: true, + rank: true, + online: true, + ipRegister: true, + mail: true, + }, + }) + : []; + const userMap = Object.fromEntries(users.map((u) => [u.id, u])); + + const reportedId = ticket.reportedId; + const [banCount, recentBans] = reportedId + ? await Promise.all([ + prisma.ban.count({ where: { userId: reportedId } }), + prisma.ban.findMany({ + where: { userId: reportedId }, + orderBy: { id: "desc" }, + take: 5, + }), + ]) + : [0, []]; + + return ( + ({ + id: b.id, + reason: b.banReason, + type: b.type, + staff: `Staff #${b.userStaffId}`, + timestamp: b.timestamp, + })), + }} + canEdit={canEdit} + currentUserId={session.user.id} + listHref="/mod/cfh" + userLinkMode="public" + /> + ); +} diff --git a/src/app/mod/cfh/page.tsx b/src/app/mod/cfh/page.tsx new file mode 100644 index 00000000..73a54217 --- /dev/null +++ b/src/app/mod/cfh/page.tsx @@ -0,0 +1,162 @@ +import { getTranslations } from "next-intl/server"; +import { type CfhRow, CfhTable } from "@/app/admin/moderation/cfh/cfh-table"; +import type { Prisma } from "@/generated/prisma/client"; +import { requireModPermission } from "@/lib/admin/guard"; +import { calcPagination, parseListParams } from "@/lib/admin-helpers"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export default async function ModCfhListPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + await requireModPermission([PERMS.MOD_CFH_VIEW, PERMS.MODERATION_VIEW]); + + const t = await getTranslations("pages.admin.cfh"); + const raw = await searchParams; + const parsed = parseListParams(new URLSearchParams(raw)); + const stateRaw = raw.filter_state ?? raw.state; + const stateFilter = + stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1; + + const conditions: Prisma.SupportTicketsWhereInput[] = []; + if (stateFilter >= 0) { + conditions.push({ state: stateFilter }); + } + + if (parsed.search.trim()) { + const q = parsed.search.trim(); + const asId = Number(q); + let userIds: number[] = []; + try { + const users = await prisma.user.findMany({ + where: { username: { contains: q } }, + select: { id: true }, + take: 50, + }); + userIds = users.map((u) => u.id); + } catch { + userIds = []; + } + + conditions.push({ + OR: [ + { issue: { contains: q } }, + ...(Number.isFinite(asId) && asId > 0 ? [{ id: asId }] : []), + ...(userIds.length + ? [ + { senderId: { in: userIds } }, + { reportedId: { in: userIds } }, + { modId: { in: userIds } }, + ] + : []), + ], + }); + } + + const where: Prisma.SupportTicketsWhereInput = + conditions.length === 0 + ? {} + : conditions.length === 1 + ? conditions[0] + : { AND: conditions }; + + const SORT_MAP: Record< + string, + Prisma.SupportTicketsOrderByWithRelationInput + > = { + id: { id: "desc" }, + issue: { issue: "asc" }, + sender: { senderId: "asc" }, + reported: { reportedId: "asc" }, + mod: { modId: "asc" }, + state: { state: "asc" }, + }; + + const baseOrder = SORT_MAP[parsed.sort ?? "id"] ?? { id: "desc" }; + const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder; + const orderBy = { + [orderField]: parsed.order, + } as Prisma.SupportTicketsOrderByWithRelationInput; + + const total = await prisma.supportTickets.count({ where }).catch(() => 0); + const pagination = calcPagination(total, parsed.page, parsed.perPage); + + const tickets = await prisma.supportTickets + .findMany({ + where, + orderBy, + skip: pagination.offset, + take: pagination.perPage, + }) + .catch(() => []); + + const userIds = [ + ...new Set([ + ...tickets.map((x) => x.senderId), + ...tickets.map((x) => x.reportedId), + ...tickets.map((x) => x.modId), + ]), + ].filter(Boolean); + + let userMap = new Map(); + if (userIds.length > 0) { + try { + const users = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, username: true }, + }); + userMap = new Map(users.map((u) => [u.id, u.username])); + } catch { + userMap = new Map(); + } + } + + const stateLabels: Record = { + 0: t("stateOpen"), + 1: t("statePicked"), + 2: t("stateClosed"), + }; + + const rows: CfhRow[] = tickets.map((ticket) => ({ + id: ticket.id, + issue: ticket.issue, + roomId: ticket.roomId, + when: ticket.timestamp + ? new Date(ticket.timestamp * 1000) + .toISOString() + .slice(0, 16) + .replace("T", " ") + : "—", + sender: userMap.get(ticket.senderId) ?? "—", + senderId: ticket.senderId, + reported: userMap.get(ticket.reportedId) ?? "—", + reportedId: ticket.reportedId, + mod: userMap.get(ticket.modId) ?? "—", + state: ticket.state, + stateLabel: stateLabels[ticket.state] ?? String(ticket.state), + })); + + return ( +
+ {total === 0 && !parsed.search && stateFilter < 0 ? ( +
{t("noTickets")}
+ ) : ( + + )} +
+ ); +} diff --git a/src/app/mod/layout.tsx b/src/app/mod/layout.tsx new file mode 100644 index 00000000..88efe2bc --- /dev/null +++ b/src/app/mod/layout.tsx @@ -0,0 +1,120 @@ +import { Ban, Gavel, LogOut, Radio, Shield } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import type { ReactNode } from "react"; +import { LanguageSwitcher } from "@/components/language-switcher"; +import { ThemeSwitcher } from "@/components/theme-switcher"; +import { requireMod } from "@/lib/admin/guard"; +import { setCsrfCookie } from "@/lib/foundation/security"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { siteSettings } from "@/lib/services/site-settings"; + +export const dynamic = "force-dynamic"; + +export default async function ModLayout({ children }: { children: ReactNode }) { + const staff = await requireMod(); + let csrfToken = ""; + try { + csrfToken = await setCsrfCookie(); + } catch { + csrfToken = ""; + } + if (await siteSettings.getBool("force_staff_2fa", false)) { + const u = await prisma.user + .findUnique({ + where: { id: staff.id }, + select: { twoFactorConfirmedAt: true }, + }) + .catch(() => null); + if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired"); + } + + const t = await getTranslations("pages.mod"); + const { permissions } = await getAdminContext(); + const showCfh = + canAccess(permissions, PERMS.MOD_CFH_VIEW, staff.rank) || + canAccess(permissions, PERMS.MODERATION_VIEW, staff.rank); + const showActions = + canAccess(permissions, PERMS.MOD_ACTIONS, staff.rank) || + canAccess(permissions, PERMS.MODERATION_EDIT, staff.rank); + const showBans = + canAccess(permissions, PERMS.MOD_BANS_VIEW, staff.rank) || + canAccess(permissions, PERMS.BANS_VIEW, staff.rank); + const showAdmin = canAccess(permissions, PERMS.ADMIN_DASHBOARD, staff.rank); + + const nav = [ + { href: "/mod", label: t("nav.overview"), icon: Shield, show: true }, + { href: "/mod/cfh", label: t("nav.cfh"), icon: Radio, show: showCfh }, + { + href: "/mod/actions", + label: t("nav.actions"), + icon: Gavel, + show: showActions, + }, + { href: "/mod/bans", label: t("nav.bans"), icon: Ban, show: showBans }, + ].filter((item) => item.show); + + return ( + <> + {csrfToken ? : null} +
+
+
+
+

+ {t("badge")} +

+

+ {t("title")} +

+

+ {staff.username} · rank {staff.rank} +

+
+
+ + + {showAdmin ? ( + + {t("nav.fullAdmin")} + + ) : null} + + + {t("nav.backToSite")} + +
+
+ +
+
{children}
+
+ + ); +} diff --git a/src/app/mod/page.tsx b/src/app/mod/page.tsx new file mode 100644 index 00000000..9f25f4f4 --- /dev/null +++ b/src/app/mod/page.tsx @@ -0,0 +1,114 @@ +import { Ban, Gavel, Radio, Shield } from "lucide-react"; +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { StatsCard } from "@/components/admin/stats-card"; +import { getMinStaffRank } from "@/lib/admin/min-staff-rank"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; + +export default async function ModDashboardPage() { + const t = await getTranslations("pages.mod"); + const { session, permissions } = await getAdminContext(); + const rank = session.user.rank; + const now = Math.floor(Date.now() / 1000); + const todayStart = now - (now % 86400); + const minStaffRank = await getMinStaffRank(); + + const showCfh = + canAccess(permissions, PERMS.MOD_CFH_VIEW, rank) || + canAccess(permissions, PERMS.MODERATION_VIEW, rank); + const showActions = + canAccess(permissions, PERMS.MOD_ACTIONS, rank) || + canAccess(permissions, PERMS.MODERATION_EDIT, rank); + const showBans = + canAccess(permissions, PERMS.MOD_BANS_VIEW, rank) || + canAccess(permissions, PERMS.BANS_VIEW, rank); + + const [openCfh, todayBans, modsOnline] = await Promise.all([ + showCfh + ? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0) + : Promise.resolve(0), + showBans + ? prisma.ban + .count({ where: { timestamp: { gte: todayStart } } }) + .catch(() => 0) + : Promise.resolve(0), + prisma.user + .count({ where: { online: "1", rank: { gte: minStaffRank } } }) + .catch(() => 0), + ]); + + return ( +
+
+

{t("overviewTitle")}

+

+ {t("overviewSubtitle")} +

+
+ +
+ {showCfh ? ( + } + href="/mod/cfh" + /> + ) : null} + {showBans ? ( + } + href="/mod/bans" + /> + ) : null} + } + /> + {showActions ? ( + } + href="/mod/actions" + /> + ) : null} +
+ +
+

{t("shortcutsTitle")}

+
    + {showCfh ? ( +
  • + + {t("nav.cfh")} + +
  • + ) : null} + {showActions ? ( +
  • + + {t("nav.actions")} + +
  • + ) : null} + {showBans ? ( +
  • + + {t("nav.bans")} + +
  • + ) : null} +
+
+
+ ); +} diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 57c3ba4b..9860f124 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -1,18 +1,43 @@ import Image from "next/image"; import Link from "next/link"; -import { getTranslations } from "next-intl/server"; import type { Session } from "next-auth"; +import { getTranslations } from "next-intl/server"; import { LanguageSwitcher } from "@/components/language-switcher"; -import { resolveHotelName } from "@/lib/hotel-name"; import { MobileNav } from "@/components/mobile-nav"; import { NavDropdown } from "@/components/nav-dropdown"; import { NavbarColorPicker } from "@/components/navbar-color-picker"; import { ThemeSwitcher } from "@/components/theme-switcher"; +import { resolveHotelName } from "@/lib/hotel-name"; +import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions"; export async function Navigation({ session }: { session: Session | null }) { const t = await getTranslations("nav"); const brandLabel = await resolveHotelName(); - const isStaff = (session?.user?.rank ?? 0) >= 7; + let showAdmin = false; + let showMod = false; + if (session?.user) { + const ctx = await getApiAdminContext(); + if (ctx) { + showAdmin = canAccess( + ctx.permissions, + PERMS.ADMIN_DASHBOARD, + ctx.session.user.rank, + ); + showMod = + canAccess( + ctx.permissions, + PERMS.MOD_DASHBOARD, + ctx.session.user.rank, + ) || + canAccess( + ctx.permissions, + PERMS.MODERATION_VIEW, + ctx.session.user.rank, + ) || + canAccess(ctx.permissions, PERMS.MOD_CFH_VIEW, ctx.session.user.rank) || + canAccess(ctx.permissions, PERMS.MOD_ACTIONS, ctx.session.user.rank); + } + } return (
- {isStaff ? ( + {showAdmin || showMod ? (
- Administration - Admin + Staff + Staff
- - Admin panel - - - Commandocentrum - - - Logs - + {showMod ? ( + + Mod panel + + ) : null} + {showAdmin ? ( + <> + + Admin panel + + + Commandocentrum + + + Logs + + + ) : null} Logo generator diff --git a/src/lib/admin/guard.ts b/src/lib/admin/guard.ts index eb977571..196a6add 100644 --- a/src/lib/admin/guard.ts +++ b/src/lib/admin/guard.ts @@ -26,7 +26,9 @@ export async function requireStaff(): Promise { * Staff gate plus a specific ACL slug. Use for FormData server actions that * must not stop at admin.dashboard alone (RCON, user edits, settings writes). */ -export async function requirePermission(permission: string): Promise { +export async function requirePermission( + permission: string, +): Promise { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank)) redirectSafe("/", "/"); @@ -56,3 +58,35 @@ export async function requireStaffRateLimited(): Promise { redirectSafe("/admin?error=ratelimit", "/admin"); return staff; } + +/** + * Mod-lite gate: needs any mod.* / moderation ACL, not admin.dashboard. + * Use for `/mod` layout so mid-ranks can access without full housekeeping. + */ +export async function requireMod(): Promise { + const { session, permissions } = await getAdminContext(); + const ok = + canAccess(permissions, PERMS.MOD_DASHBOARD, session.user.rank) || + canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank) || + canAccess(permissions, PERMS.MOD_CFH_VIEW, session.user.rank) || + canAccess(permissions, PERMS.MOD_ACTIONS, session.user.rank) || + canAccess(permissions, PERMS.MOD_BANS_VIEW, session.user.rank); + if (!ok) redirectSafe("/", "/"); + return { + id: session.user.id, + rank: session.user.rank, + username: session.user.username, + }; +} + +/** Mod-lite page gate: any-of permission list (no admin.dashboard required). */ +export async function requireModPermission( + permission: string | readonly string[], +): Promise { + const staff = await requireMod(); + const { permissions } = await getAdminContext(); + const needed = Array.isArray(permission) ? permission : [permission]; + const ok = needed.some((slug) => canAccess(permissions, slug, staff.rank)); + if (!ok) redirectSafe("/mod", "/mod"); + return staff; +} diff --git a/src/lib/foundation/action.ts b/src/lib/foundation/action.ts index 86508f56..6c7792b4 100644 --- a/src/lib/foundation/action.ts +++ b/src/lib/foundation/action.ts @@ -43,7 +43,8 @@ function fail( export { fail as actionError, ok as actionOk }; interface AdminOpts { - permission?: string; + /** Single slug, or any-of list (e.g. admin.moderation.edit OR mod.actions). */ + permission?: string | readonly string[]; schema?: TSchema; rateLimitKey?: string; rateLimitMax?: number; @@ -75,19 +76,19 @@ export function adminAction( setContextUserId(Number(apiCtx.session.user.id) as never); if (opts.permission) { - if ( - !canAccess( - apiCtx.permissions, - opts.permission, - apiCtx.session.user.rank, - ) - ) { + const needed = Array.isArray(opts.permission) + ? opts.permission + : [opts.permission]; + const allowed = needed.some((slug) => + canAccess(apiCtx.permissions, slug, apiCtx.session.user.rank), + ); + if (!allowed) { await logAuthorizationEvent({ kind: "permission.denied", userId: Number(apiCtx.session.user.id), username: apiCtx.session.user.name ?? undefined, rank: apiCtx.session.user.rank, - permission: opts.permission, + permission: needed.join("|"), source: "adminAction", reason: "Permission check denied", }); diff --git a/src/messages/en.json b/src/messages/en.json index 976fe4f9..9f50e560 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -23,7 +23,8 @@ "admin": "Admin", "openMenu": "Open menu", "closeMenu": "Close menu", - "drawBadge": "Draw badge" + "drawBadge": "Draw badge", + "mod": "Mod" }, "header": { "online": "{count} {hotel} online", @@ -1085,7 +1086,7 @@ "backToSite": "Back to site", "expandAll": "Expand all", "collapseAll": "Collapse all", - "housekeeping": "Housekeeping", + "housekeeping": "Housekeeping (legacy)", "hotelManagementPanel": "Hotel management panel", "rankLabel": "Rank {rank}", "community": "Community", @@ -1656,7 +1657,7 @@ }, "permissions": { "title": "Permissions", - "subtitle": "Manage rank-based permissions for the admin panel", + "subtitle": "Manage live ACL ranks and role permissions for admin and mod panels", "addPermission": "Add permission", "editPermission": "Edit permission", "deletePermission": "Delete permission", @@ -2537,7 +2538,7 @@ "favicon": "Favicon", "emulator": "Emulator", "email": "Email", - "housekeeping": "Housekeeping", + "housekeeping": "Housekeeping (legacy)", "commando": "Commando", "rooms": "Rooms", "import": "Import", @@ -2560,7 +2561,7 @@ "tickets": "Support desk", "ticketsSubtitle": "CMS tickets, help-center tickets, and reply templates", "staffAccess": "Ranks & permissions", - "staffAccessSubtitle": "Staff ranks, ACL permissions and housekeeping access", + "staffAccessSubtitle": "Staff ranks and live ACL. Housekeeping is legacy reference only.", "analytics": "Analytics", "analyticsSubtitle": "Hotel activity, retention and economy", "devops": "DevOps", @@ -2611,8 +2612,8 @@ "confirmDisable": "Disable maintenance mode and restore access for all users?" }, "housekeeping": { - "title": "Housekeeping Permissions", - "subtitle": "Configure which ranks can access which admin sections", + "title": "Housekeeping (legacy)", + "subtitle": "Legacy min-rank table — not used by live ACL checks. Manage real access under Permissions.", "addPermission": "Add permission", "editPermission": "Edit permission", "deletePermission": "Delete permission", @@ -2722,7 +2723,10 @@ "auditAction": "Action", "auditTarget": "Target", "save": "Save", - "importTitle": "Import permissions" + "importTitle": "Import permissions", + "legacyBannerTitle": "This table is not live ACL", + "legacyBannerBody": "Runtime access uses the ACL permissions system. Use Housekeeping only as a legacy reference.", + "legacyBannerCta": "Open live Permissions" }, "rooms": { "title": "Rooms", @@ -3011,6 +3015,28 @@ "more": "More" } } + }, + "mod": { + "title": "Moderation panel", + "badge": "Mod", + "overviewTitle": "Moderator overview", + "overviewSubtitle": "CFH, quick actions and bans without full admin access.", + "shortcutsTitle": "Shortcuts", + "nav": { + "overview": "Overview", + "cfh": "Call for Help", + "actions": "Quick actions", + "bans": "Bans", + "fullAdmin": "Full admin", + "backToSite": "Back to site" + }, + "stats": { + "openCfh": "Open CFH", + "bansToday": "Bans today", + "modsOnline": "Mods online", + "quickActions": "Quick actions", + "quickActionsHint": "Kick, mute, alert" + } } } } diff --git a/src/messages/it.json b/src/messages/it.json index 9319d5cc..66a3dfa6 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -23,7 +23,8 @@ "admin": "Admin", "openMenu": "Open menu", "closeMenu": "Close menu", - "drawBadge": "Crea distintivo" + "drawBadge": "Crea distintivo", + "mod": "Mod" }, "header": { "online": "{count} online su {hotel}", @@ -1018,7 +1019,7 @@ "devops": "DevOps", "wordFilter": "Filtro parole", "maintenance": "Manutenzione", - "housekeeping": "Housekeeping", + "housekeeping": "Housekeeping (legacy)", "rooms": "Stanze", "emailTemplates": "Template email", "theme": "Tema", @@ -1612,7 +1613,7 @@ }, "permissions": { "title": "Permessi", - "subtitle": "Gestisci permessi basati sul grado per il pannello admin", + "subtitle": "Gestisci gradi e permessi ACL live per pannelli admin e mod", "addPermission": "Aggiungi permesso", "editPermission": "Modifica permesso", "deletePermission": "Elimina permesso", @@ -2458,8 +2459,8 @@ "confirmDisable": "Disattivare la modalità manutenzione e ripristinare l'accesso per tutti gli utenti?" }, "housekeeping": { - "title": "Permessi Housekeeping", - "subtitle": "Configura quali gradi possono accedere a quali sezioni admin", + "title": "Housekeeping (legacy)", + "subtitle": "Tabella min-rank legacy — non usata dai controlli ACL attivi. Gestisci l'accesso reale in Permessi.", "addPermission": "Aggiungi permesso", "editPermission": "Modifica permesso", "deletePermission": "Elimina permesso", @@ -2569,7 +2570,10 @@ "auditTarget": "Destinatario", "selectAllMatching": "Select all {count} matching", "save": "Save", - "importTitle": "Import permissions" + "importTitle": "Import permissions", + "legacyBannerTitle": "Questa tabella non e ACL live", + "legacyBannerBody": "L'accesso runtime usa il sistema ACL. Usa Housekeeping solo come riferimento legacy.", + "legacyBannerCta": "Apri Permessi live" }, "rooms": { "title": "Stanze", @@ -2772,7 +2776,7 @@ "favicon": "Favicon", "emulator": "Emulator", "email": "Email", - "housekeeping": "Housekeeping", + "housekeeping": "Housekeeping (legacy)", "commando": "Commando", "rooms": "Stanze", "import": "Import", @@ -2795,7 +2799,7 @@ "tickets": "Assistenza", "ticketsSubtitle": "Ticket CMS, ticket centro assistenza e template risposte", "staffAccess": "Gradi e permessi", - "staffAccessSubtitle": "Gradi staff, permessi ACL e accesso housekeeping", + "staffAccessSubtitle": "Gradi staff e ACL live. Housekeeping e solo riferimento legacy.", "analytics": "Analitiche", "analyticsSubtitle": "Attività hotel, retention ed economia", "devops": "DevOps", @@ -3014,6 +3018,28 @@ "ratelimit": "Stai inviando troppo in fretta. Attendi un momento e riprova.", "error": "Qualcosa è andato storto. Riprova." } + }, + "mod": { + "title": "Pannello moderazione", + "badge": "Mod", + "overviewTitle": "Panoramica moderatore", + "overviewSubtitle": "CFH, azioni rapide e ban senza accesso admin completo.", + "shortcutsTitle": "Collegamenti", + "nav": { + "overview": "Panoramica", + "cfh": "Call for Help", + "actions": "Azioni rapide", + "bans": "Ban", + "fullAdmin": "Admin completo", + "backToSite": "Torna al sito" + }, + "stats": { + "openCfh": "CFH aperti", + "bansToday": "Ban oggi", + "modsOnline": "Mod online", + "quickActions": "Azioni rapide", + "quickActionsHint": "Kick, mute, alert" + } } } } diff --git a/src/messages/nl.json b/src/messages/nl.json index 4163d61e..de5c92e1 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -23,7 +23,8 @@ "admin": "Admin", "openMenu": "Menu openen", "closeMenu": "Menu sluiten", - "drawBadge": "Badge tekenen" + "drawBadge": "Badge tekenen", + "mod": "Mod" }, "header": { "online": "{count} {hotel} online", @@ -1076,7 +1077,7 @@ "backToSite": "Terug naar site", "expandAll": "Alles openen", "collapseAll": "Alles sluiten", - "housekeeping": "Housekeeping", + "housekeeping": "Housekeeping (legacy)", "hotelManagementPanel": "Hotelbeheerpaneel", "rankLabel": "Rang {rank}", "hubNewsMedia": "News & media", @@ -1588,7 +1589,7 @@ }, "permissions": { "title": "Permissies", - "subtitle": "Beheer ranggebaseerde permissies voor het beheerderspaneel", + "subtitle": "Beheer live ACL-rangen en rolrechten voor admin- en mod-panelen", "addPermission": "Permissie toevoegen", "editPermission": "Permissie bewerken", "deletePermission": "Permissie verwijderen", @@ -2433,8 +2434,8 @@ "confirmDisable": "Onderhoudsmodus uitschakelen en toegang herstellen voor alle gebruikers?" }, "housekeeping": { - "title": "Housekeeping Permissies", - "subtitle": "Configureer welke rangen toegang hebben tot welke beheerderssecties", + "title": "Housekeeping (legacy)", + "subtitle": "Legacy min-rank tabel — niet gebruikt door live ACL. Beheer echte toegang via Permissions.", "addPermission": "Permissie toevoegen", "editPermission": "Permissie bewerken", "deletePermission": "Permissie verwijderen", @@ -2544,7 +2545,10 @@ "auditAction": "Actie", "auditTarget": "Doel", "save": "Opslaan", - "importTitle": "Permissies importeren" + "importTitle": "Permissies importeren", + "legacyBannerTitle": "Deze tabel is geen live ACL", + "legacyBannerBody": "Runtime-toegang gebruikt het ACL-systeem. Gebruik Housekeeping alleen als legacy-referentie.", + "legacyBannerCta": "Open live Permissions" }, "rooms": { "title": "Kamers", @@ -2838,7 +2842,7 @@ "favicon": "Favicon", "emulator": "Emulator", "email": "E-mail", - "housekeeping": "Housekeeping", + "housekeeping": "Housekeeping (legacy)", "commando": "Commando", "rooms": "Kamers", "import": "Import", @@ -2861,7 +2865,7 @@ "tickets": "Supportbalie", "ticketsSubtitle": "CMS-tickets, helpcentrum-tickets en antwoordtemplates", "staffAccess": "Rangen & rechten", - "staffAccessSubtitle": "Staffrangen, ACL-rechten en housekeeping-toegang", + "staffAccessSubtitle": "Staffrangen en live ACL. Housekeeping is alleen legacy-referentie.", "analytics": "Analytics", "analyticsSubtitle": "Hotelactiviteit, retentie en economie", "devops": "DevOps", @@ -3012,6 +3016,28 @@ "more": "Meer" } } + }, + "mod": { + "title": "Moderatiepaneel", + "badge": "Mod", + "overviewTitle": "Moderator overzicht", + "overviewSubtitle": "CFH, snelle acties en bans zonder volledige admin-toegang.", + "shortcutsTitle": "Snelkoppelingen", + "nav": { + "overview": "Overzicht", + "cfh": "Call for Help", + "actions": "Snelle acties", + "bans": "Bans", + "fullAdmin": "Volledige admin", + "backToSite": "Terug naar site" + }, + "stats": { + "openCfh": "Open CFH", + "bansToday": "Bans vandaag", + "modsOnline": "Mods online", + "quickActions": "Snelle acties", + "quickActionsHint": "Kick, mute, alert" + } } } }