feat(mod): lite /mod panel + clarify ACL vs housekeeping legacy
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
a384c9a8bb
commit
084cca6ea4
18 files changed
+931
-77
No files matched your search
@@ -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);
|
||||
|
||||
@@ -106,6 +106,22 @@ export default async function AdminHousekeeping({
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div
|
||||
className="mb-4 rounded-lg border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-4 py-3 text-sm text-[var(--admin-text)]"
|
||||
role="status"
|
||||
>
|
||||
<p className="m-0 font-semibold">{t("legacyBannerTitle")}</p>
|
||||
<p className="m-0 mt-1 text-[var(--admin-text-muted)]">
|
||||
{t("legacyBannerBody")}{" "}
|
||||
<Link
|
||||
href="/admin/permissions"
|
||||
className="font-medium text-[var(--admin-accent)] underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("legacyBannerCta")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs mb-4">
|
||||
<Link
|
||||
href="/admin/housekeeping?tab=overview"
|
||||
|
||||
@@ -74,6 +74,8 @@ export function CfhDetail({
|
||||
canEdit,
|
||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the public component API
|
||||
currentUserId,
|
||||
listHref = "/admin/moderation/cfh",
|
||||
userLinkMode = "admin",
|
||||
}: {
|
||||
ticket: {
|
||||
id: number;
|
||||
@@ -91,6 +93,8 @@ export function CfhDetail({
|
||||
banHistory: { count: number; recent: BanRecord[] };
|
||||
canEdit: boolean;
|
||||
currentUserId: number;
|
||||
listHref?: string;
|
||||
userLinkMode?: "admin" | "public";
|
||||
}) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const [alertMsg, setAlertMsg] = useState("");
|
||||
@@ -98,11 +102,18 @@ export function CfhDetail({
|
||||
|
||||
const state = CFH_STATES[ticket.state] ?? CFH_STATES[0];
|
||||
|
||||
function userHref(user: UserInfo) {
|
||||
if (userLinkMode === "public") {
|
||||
return `/u/${encodeURIComponent(user.username)}`;
|
||||
}
|
||||
return `/admin/users/show/${user.id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<Link href="/admin/moderation/cfh">
|
||||
<Link href={listHref}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -155,7 +166,7 @@ export function CfhDetail({
|
||||
<CardContent>
|
||||
{sender ? (
|
||||
<Link
|
||||
href={`/admin/users/show/${sender.id}`}
|
||||
href={userHref(sender)}
|
||||
className="flex items-center gap-3 hover:text-primary no-underline"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
@@ -184,7 +195,7 @@ export function CfhDetail({
|
||||
{reported ? (
|
||||
<div>
|
||||
<Link
|
||||
href={`/admin/users/show/${reported.id}`}
|
||||
href={userHref(reported)}
|
||||
className="flex items-center gap-3 hover:text-primary no-underline"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-[var(--admin-error)]" />
|
||||
@@ -400,7 +411,7 @@ export function CfhDetail({
|
||||
<CardContent>
|
||||
{moderator ? (
|
||||
<Link
|
||||
href={`/admin/users/show/${moderator.id}`}
|
||||
href={userHref(moderator)}
|
||||
className="flex items-center gap-2 text-sm hover:text-primary no-underline"
|
||||
>
|
||||
<Shield className="h-3.5 w-3.5 text-primary" />
|
||||
|
||||
@@ -20,9 +20,26 @@ export interface CfhRow {
|
||||
stateLabel: string;
|
||||
}
|
||||
|
||||
export function CfhTable({ data }: { data: PaginatedResult<CfhRow> }) {
|
||||
export function CfhTable({
|
||||
data,
|
||||
basePath = "/admin/moderation/cfh",
|
||||
userLinkMode = "admin",
|
||||
}: {
|
||||
data: PaginatedResult<CfhRow>;
|
||||
/** 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<CfhRow>[] = [
|
||||
{ key: "id", label: t("colId"), sortable: true },
|
||||
{
|
||||
@@ -32,7 +49,7 @@ export function CfhTable({ data }: { data: PaginatedResult<CfhRow> }) {
|
||||
render: (_value, row) => (
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/admin/moderation/cfh/${row.id}`}
|
||||
href={`${basePath}/${row.id}`}
|
||||
className="text-sm font-medium truncate block hover:underline"
|
||||
>
|
||||
{row.issue || t("noDescription")}
|
||||
@@ -50,7 +67,7 @@ export function CfhTable({ data }: { data: PaginatedResult<CfhRow> }) {
|
||||
render: (_value, row) =>
|
||||
row.senderId ? (
|
||||
<Link
|
||||
href={`/admin/users/${row.senderId}`}
|
||||
href={userHref(row.senderId, row.sender)}
|
||||
className="hover:underline"
|
||||
>
|
||||
{row.sender}
|
||||
@@ -66,7 +83,7 @@ export function CfhTable({ data }: { data: PaginatedResult<CfhRow> }) {
|
||||
render: (_value, row) =>
|
||||
row.reportedId ? (
|
||||
<Link
|
||||
href={`/admin/users/${row.reportedId}`}
|
||||
href={userHref(row.reportedId, row.reported)}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{row.reported}
|
||||
|
||||
@@ -98,6 +98,17 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm text-[var(--admin-text)]"
|
||||
role="status"
|
||||
>
|
||||
<p className="m-0 font-semibold">Live ACL</p>
|
||||
<p className="m-0 mt-1 text-muted-foreground">
|
||||
Rank ACL here controls real admin access. The Housekeeping table is
|
||||
legacy reference only and does not gate routes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<ModActionsClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ReturnType<typeof prisma.ban.findMany>> = [];
|
||||
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 (
|
||||
<main>
|
||||
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
|
||||
Read-only ban list for moderators. Create/lift bans requires the full
|
||||
admin panel.
|
||||
</p>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("activeBans")}
|
||||
value={bans.length}
|
||||
state={bans.length > 0 ? "warn" : "ok"}
|
||||
icon="🔨"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("permanent")}
|
||||
value={permanent}
|
||||
state={permanent > 0 ? "danger" : "neutral"}
|
||||
icon="⛔"
|
||||
/>
|
||||
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("activeBans")} ({bans.length})
|
||||
</h2>
|
||||
{bans.length === 0 ? (
|
||||
<div className="admin-empty">{t("noBans")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colUserId")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colReason")}</th>
|
||||
<th>{t("colExpires")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bans.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>{b.userId}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{b.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>{b.banReason || "—"}</td>
|
||||
<td>
|
||||
{b.banExpire === 0 ? (
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
|
||||
{t("permanent")}
|
||||
</span>
|
||||
) : (
|
||||
fromUnix(b.banExpire)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<CfhDetail
|
||||
ticket={{
|
||||
id: ticket.id,
|
||||
state: ticket.state,
|
||||
type: ticket.type,
|
||||
timestamp: ticket.timestamp,
|
||||
issue: ticket.issue,
|
||||
roomId: ticket.roomId,
|
||||
category: ticket.category,
|
||||
score: ticket.score,
|
||||
}}
|
||||
sender={userMap[ticket.senderId] ?? null}
|
||||
reported={userMap[ticket.reportedId] ?? null}
|
||||
moderator={userMap[ticket.modId] ?? null}
|
||||
banHistory={{
|
||||
count: banCount,
|
||||
recent: recentBans.map((b) => ({
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, string>>;
|
||||
}) {
|
||||
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<number, string>();
|
||||
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<number, string> = {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{total === 0 && !parsed.search && stateFilter < 0 ? (
|
||||
<div className="admin-empty">{t("noTickets")}</div>
|
||||
) : (
|
||||
<CfhTable
|
||||
basePath="/mod/cfh"
|
||||
userLinkMode="public"
|
||||
data={{
|
||||
rows,
|
||||
total,
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
lastPage: pagination.lastPage,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 ? <meta name="csrf-token" content={csrfToken} /> : null}
|
||||
<div
|
||||
data-admin
|
||||
className="min-h-screen"
|
||||
style={{ backgroundColor: "var(--admin-canvas)" }}
|
||||
>
|
||||
<header className="border-b border-[var(--admin-border)] bg-[var(--admin-sidebar-background)]">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 text-xs font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
|
||||
{t("badge")}
|
||||
</p>
|
||||
<h1 className="m-0 truncate text-lg font-semibold text-[var(--admin-sidebar-text-readable)]">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-sidebar-muted-readable)]">
|
||||
{staff.username} · rank {staff.rank}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LanguageSwitcher variant="admin" />
|
||||
<ThemeSwitcher variant="admin" />
|
||||
{showAdmin ? (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--admin-sidebar-muted-readable)] no-underline hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]"
|
||||
>
|
||||
{t("nav.fullAdmin")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--admin-sidebar-muted-readable)] no-underline hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
{t("nav.backToSite")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4 pb-3">
|
||||
{nav.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--admin-sidebar-muted-readable)] no-underline hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]"
|
||||
>
|
||||
<Icon size={14} />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="admin-section-title m-0">{t("overviewTitle")}</h2>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
{t("overviewSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{showCfh ? (
|
||||
<StatsCard
|
||||
title={t("stats.openCfh")}
|
||||
value={openCfh}
|
||||
icon={<Radio className="h-4 w-4" />}
|
||||
href="/mod/cfh"
|
||||
/>
|
||||
) : null}
|
||||
{showBans ? (
|
||||
<StatsCard
|
||||
title={t("stats.bansToday")}
|
||||
value={todayBans}
|
||||
icon={<Ban className="h-4 w-4" />}
|
||||
href="/mod/bans"
|
||||
/>
|
||||
) : null}
|
||||
<StatsCard
|
||||
title={t("stats.modsOnline")}
|
||||
value={modsOnline}
|
||||
icon={<Shield className="h-4 w-4" />}
|
||||
/>
|
||||
{showActions ? (
|
||||
<StatsCard
|
||||
title={t("stats.quickActions")}
|
||||
value="→"
|
||||
description={t("stats.quickActionsHint")}
|
||||
icon={<Gavel className="h-4 w-4" />}
|
||||
href="/mod/actions"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="admin-card space-y-2 p-4">
|
||||
<h3 className="m-0 text-sm font-semibold">{t("shortcutsTitle")}</h3>
|
||||
<ul className="m-0 list-none space-y-1 p-0 text-sm">
|
||||
{showCfh ? (
|
||||
<li>
|
||||
<Link href="/mod/cfh" className="text-primary hover:underline">
|
||||
{t("nav.cfh")}
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
{showActions ? (
|
||||
<li>
|
||||
<Link
|
||||
href="/mod/actions"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t("nav.actions")}
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
{showBans ? (
|
||||
<li>
|
||||
<Link href="/mod/bans" className="text-primary hover:underline">
|
||||
{t("nav.bans")}
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<nav
|
||||
@@ -154,7 +179,20 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isStaff ? (
|
||||
{showMod ? (
|
||||
<Link href="/mod" className="nav-item">
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/home.png"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
{t("mod")}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{showAdmin ? (
|
||||
<Link href="/admin" className="nav-item">
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/home.png"
|
||||
@@ -298,7 +336,20 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isStaff ? (
|
||||
{showMod ? (
|
||||
<Link href="/mod" className="nav-item">
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/home.png"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
{t("mod")}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{showAdmin ? (
|
||||
<Link href="/admin" className="nav-item">
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/home.png"
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
import type { Session } from "next-auth";
|
||||
import { signOut } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -70,7 +71,25 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
"https://www.habbo.com/habbo-imaging/avatarimage",
|
||||
)) ?? "";
|
||||
const avatar = avatarImageUrl(imager, look, { headOnly: true, direction: 2 });
|
||||
const isStaff = (session.user.rank ?? 0) >= 7;
|
||||
let showAdmin = false;
|
||||
let showMod = false;
|
||||
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);
|
||||
}
|
||||
const textColor =
|
||||
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
|
||||
|
||||
@@ -88,36 +107,49 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-1 sm:gap-x-3">
|
||||
{isStaff ? (
|
||||
{showAdmin || showMod ? (
|
||||
<details className="relative">
|
||||
<summary
|
||||
className="list-none cursor-pointer font-semibold text-xs sm:text-sm [&::-webkit-details-marker]:hidden"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
<span className="hidden sm:inline">Administration</span>
|
||||
<span className="sm:hidden">Admin</span>
|
||||
<span className="hidden sm:inline">Staff</span>
|
||||
<span className="sm:hidden">Staff</span>
|
||||
</summary>
|
||||
<div
|
||||
role="menu"
|
||||
className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[180px] rounded-xl z-50 py-2"
|
||||
>
|
||||
<Link href="/admin" className="dropdown-item" role="menuitem">
|
||||
Admin panel
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/commandocentrum"
|
||||
className="dropdown-item"
|
||||
role="menuitem"
|
||||
>
|
||||
Commandocentrum
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/logs"
|
||||
className="dropdown-item"
|
||||
role="menuitem"
|
||||
>
|
||||
Logs
|
||||
</Link>
|
||||
{showMod ? (
|
||||
<Link href="/mod" className="dropdown-item" role="menuitem">
|
||||
Mod panel
|
||||
</Link>
|
||||
) : null}
|
||||
{showAdmin ? (
|
||||
<>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="dropdown-item"
|
||||
role="menuitem"
|
||||
>
|
||||
Admin panel
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/commandocentrum"
|
||||
className="dropdown-item"
|
||||
role="menuitem"
|
||||
>
|
||||
Commandocentrum
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/logs"
|
||||
className="dropdown-item"
|
||||
role="menuitem"
|
||||
>
|
||||
Logs
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
<Link href="/logo" className="dropdown-item" role="menuitem">
|
||||
Logo generator
|
||||
</Link>
|
||||
|
||||
+35
-1
@@ -26,7 +26,9 @@ export async function requireStaff(): Promise<StaffUser> {
|
||||
* 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<StaffUser> {
|
||||
export async function requirePermission(
|
||||
permission: string,
|
||||
): Promise<StaffUser> {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
|
||||
redirectSafe("/", "/");
|
||||
@@ -56,3 +58,35 @@ export async function requireStaffRateLimited(): Promise<StaffUser> {
|
||||
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<StaffUser> {
|
||||
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<StaffUser> {
|
||||
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;
|
||||
}
|
||||
@@ -43,7 +43,8 @@ function fail(
|
||||
export { fail as actionError, ok as actionOk };
|
||||
|
||||
interface AdminOpts<TSchema extends z.ZodType | undefined> {
|
||||
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<TSchema extends z.ZodType | undefined = undefined>(
|
||||
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",
|
||||
});
|
||||
|
||||
+34
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user