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 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(
|
export const assignCfhTicket = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
{ permission: CFH_PERM, schema: cfhIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.supportTickets.findUnique({
|
const ticket = await prisma.supportTickets.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -42,7 +45,7 @@ const cfhStateSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const updateCfhState = adminAction(
|
export const updateCfhState = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
|
{ permission: CFH_PERM, schema: cfhStateSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.supportTickets.findUnique({
|
const ticket = await prisma.supportTickets.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -68,7 +71,7 @@ export const updateCfhState = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const closeCfhTicket = adminAction(
|
export const closeCfhTicket = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
{ permission: CFH_PERM, schema: cfhIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await prisma.supportTickets.update({
|
await prisma.supportTickets.update({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -91,7 +94,7 @@ export const closeCfhTicket = adminAction(
|
|||||||
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const quickKick = adminAction(
|
export const quickKick = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.disconnectUser(ctx.data.userId);
|
await rcon.disconnectUser(ctx.data.userId);
|
||||||
|
|
||||||
@@ -112,7 +115,7 @@ const muteSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const quickMute = adminAction(
|
export const quickMute = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: muteSchema },
|
{ permission: MOD_ACTION_PERM, schema: muteSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||||
|
|
||||||
@@ -129,7 +132,7 @@ export const quickMute = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const quickUnmute = adminAction(
|
export const quickUnmute = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.unmuteUser(ctx.data.userId);
|
await rcon.unmuteUser(ctx.data.userId);
|
||||||
|
|
||||||
@@ -150,7 +153,7 @@ const alertSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const quickAlert = adminAction(
|
export const quickAlert = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: alertSchema },
|
{ permission: MOD_ACTION_PERM, schema: alertSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
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() });
|
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const quickRoomKick = adminAction(
|
export const quickRoomKick = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.kickAll(ctx.data.roomId);
|
await rcon.kickAll(ctx.data.roomId);
|
||||||
|
|
||||||
@@ -190,7 +193,7 @@ const broadcastSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const broadcastAlert = adminAction(
|
export const broadcastAlert = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
|
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
if (ctx.data.type === "hotel") {
|
if (ctx.data.type === "hotel") {
|
||||||
await rcon.hotelAlert(ctx.data.message);
|
await rcon.hotelAlert(ctx.data.message);
|
||||||
|
|||||||
@@ -106,6 +106,22 @@ export default async function AdminHousekeeping({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<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">
|
<div className="admin-tabs mb-4">
|
||||||
<Link
|
<Link
|
||||||
href="/admin/housekeeping?tab=overview"
|
href="/admin/housekeeping?tab=overview"
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export function CfhDetail({
|
|||||||
canEdit,
|
canEdit,
|
||||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the public component API
|
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the public component API
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
listHref = "/admin/moderation/cfh",
|
||||||
|
userLinkMode = "admin",
|
||||||
}: {
|
}: {
|
||||||
ticket: {
|
ticket: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -91,6 +93,8 @@ export function CfhDetail({
|
|||||||
banHistory: { count: number; recent: BanRecord[] };
|
banHistory: { count: number; recent: BanRecord[] };
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
currentUserId: number;
|
currentUserId: number;
|
||||||
|
listHref?: string;
|
||||||
|
userLinkMode?: "admin" | "public";
|
||||||
}) {
|
}) {
|
||||||
const { run, isPending } = useServerAction();
|
const { run, isPending } = useServerAction();
|
||||||
const [alertMsg, setAlertMsg] = useState("");
|
const [alertMsg, setAlertMsg] = useState("");
|
||||||
@@ -98,11 +102,18 @@ export function CfhDetail({
|
|||||||
|
|
||||||
const state = CFH_STATES[ticket.state] ?? CFH_STATES[0];
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<Link href="/admin/moderation/cfh">
|
<Link href={listHref}>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -155,7 +166,7 @@ export function CfhDetail({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{sender ? (
|
{sender ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/show/${sender.id}`}
|
href={userHref(sender)}
|
||||||
className="flex items-center gap-3 hover:text-primary no-underline"
|
className="flex items-center gap-3 hover:text-primary no-underline"
|
||||||
>
|
>
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
@@ -184,7 +195,7 @@ export function CfhDetail({
|
|||||||
{reported ? (
|
{reported ? (
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/show/${reported.id}`}
|
href={userHref(reported)}
|
||||||
className="flex items-center gap-3 hover:text-primary no-underline"
|
className="flex items-center gap-3 hover:text-primary no-underline"
|
||||||
>
|
>
|
||||||
<AlertTriangle className="h-4 w-4 text-[var(--admin-error)]" />
|
<AlertTriangle className="h-4 w-4 text-[var(--admin-error)]" />
|
||||||
@@ -400,7 +411,7 @@ export function CfhDetail({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{moderator ? (
|
{moderator ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/show/${moderator.id}`}
|
href={userHref(moderator)}
|
||||||
className="flex items-center gap-2 text-sm hover:text-primary no-underline"
|
className="flex items-center gap-2 text-sm hover:text-primary no-underline"
|
||||||
>
|
>
|
||||||
<Shield className="h-3.5 w-3.5 text-primary" />
|
<Shield className="h-3.5 w-3.5 text-primary" />
|
||||||
|
|||||||
@@ -20,9 +20,26 @@ export interface CfhRow {
|
|||||||
stateLabel: string;
|
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");
|
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>[] = [
|
const columns: DataTableColumn<CfhRow>[] = [
|
||||||
{ key: "id", label: t("colId"), sortable: true },
|
{ key: "id", label: t("colId"), sortable: true },
|
||||||
{
|
{
|
||||||
@@ -32,7 +49,7 @@ export function CfhTable({ data }: { data: PaginatedResult<CfhRow> }) {
|
|||||||
render: (_value, row) => (
|
render: (_value, row) => (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/moderation/cfh/${row.id}`}
|
href={`${basePath}/${row.id}`}
|
||||||
className="text-sm font-medium truncate block hover:underline"
|
className="text-sm font-medium truncate block hover:underline"
|
||||||
>
|
>
|
||||||
{row.issue || t("noDescription")}
|
{row.issue || t("noDescription")}
|
||||||
@@ -50,7 +67,7 @@ export function CfhTable({ data }: { data: PaginatedResult<CfhRow> }) {
|
|||||||
render: (_value, row) =>
|
render: (_value, row) =>
|
||||||
row.senderId ? (
|
row.senderId ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/${row.senderId}`}
|
href={userHref(row.senderId, row.sender)}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{row.sender}
|
{row.sender}
|
||||||
@@ -66,7 +83,7 @@ export function CfhTable({ data }: { data: PaginatedResult<CfhRow> }) {
|
|||||||
render: (_value, row) =>
|
render: (_value, row) =>
|
||||||
row.reportedId ? (
|
row.reportedId ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/${row.reportedId}`}
|
href={userHref(row.reportedId, row.reported)}
|
||||||
className="font-medium hover:underline"
|
className="font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{row.reported}
|
{row.reported}
|
||||||
|
|||||||
@@ -98,6 +98,17 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">
|
<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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,18 +1,43 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
|
||||||
import { MobileNav } from "@/components/mobile-nav";
|
import { MobileNav } from "@/components/mobile-nav";
|
||||||
import { NavDropdown } from "@/components/nav-dropdown";
|
import { NavDropdown } from "@/components/nav-dropdown";
|
||||||
import { NavbarColorPicker } from "@/components/navbar-color-picker";
|
import { NavbarColorPicker } from "@/components/navbar-color-picker";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
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 }) {
|
export async function Navigation({ session }: { session: Session | null }) {
|
||||||
const t = await getTranslations("nav");
|
const t = await getTranslations("nav");
|
||||||
const brandLabel = await resolveHotelName();
|
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 (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@@ -154,7 +179,20 @@ export async function Navigation({ session }: { session: Session | null }) {
|
|||||||
</>
|
</>
|
||||||
) : 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">
|
<Link href="/admin" className="nav-item">
|
||||||
<Image
|
<Image
|
||||||
src="/assets/images/icons/navigation/home.png"
|
src="/assets/images/icons/navigation/home.png"
|
||||||
@@ -298,7 +336,20 @@ export async function Navigation({ session }: { session: Session | null }) {
|
|||||||
</>
|
</>
|
||||||
) : 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">
|
<Link href="/admin" className="nav-item">
|
||||||
<Image
|
<Image
|
||||||
src="/assets/images/icons/navigation/home.png"
|
src="/assets/images/icons/navigation/home.png"
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+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
|
* 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).
|
* 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();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
|
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
|
||||||
redirectSafe("/", "/");
|
redirectSafe("/", "/");
|
||||||
@@ -56,3 +58,35 @@ export async function requireStaffRateLimited(): Promise<StaffUser> {
|
|||||||
redirectSafe("/admin?error=ratelimit", "/admin");
|
redirectSafe("/admin?error=ratelimit", "/admin");
|
||||||
return staff;
|
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 };
|
export { fail as actionError, ok as actionOk };
|
||||||
|
|
||||||
interface AdminOpts<TSchema extends z.ZodType | undefined> {
|
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;
|
schema?: TSchema;
|
||||||
rateLimitKey?: string;
|
rateLimitKey?: string;
|
||||||
rateLimitMax?: number;
|
rateLimitMax?: number;
|
||||||
@@ -75,19 +76,19 @@ export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
|
|||||||
setContextUserId(Number(apiCtx.session.user.id) as never);
|
setContextUserId(Number(apiCtx.session.user.id) as never);
|
||||||
|
|
||||||
if (opts.permission) {
|
if (opts.permission) {
|
||||||
if (
|
const needed = Array.isArray(opts.permission)
|
||||||
!canAccess(
|
? opts.permission
|
||||||
apiCtx.permissions,
|
: [opts.permission];
|
||||||
opts.permission,
|
const allowed = needed.some((slug) =>
|
||||||
apiCtx.session.user.rank,
|
canAccess(apiCtx.permissions, slug, apiCtx.session.user.rank),
|
||||||
)
|
);
|
||||||
) {
|
if (!allowed) {
|
||||||
await logAuthorizationEvent({
|
await logAuthorizationEvent({
|
||||||
kind: "permission.denied",
|
kind: "permission.denied",
|
||||||
userId: Number(apiCtx.session.user.id),
|
userId: Number(apiCtx.session.user.id),
|
||||||
username: apiCtx.session.user.name ?? undefined,
|
username: apiCtx.session.user.name ?? undefined,
|
||||||
rank: apiCtx.session.user.rank,
|
rank: apiCtx.session.user.rank,
|
||||||
permission: opts.permission,
|
permission: needed.join("|"),
|
||||||
source: "adminAction",
|
source: "adminAction",
|
||||||
reason: "Permission check denied",
|
reason: "Permission check denied",
|
||||||
});
|
});
|
||||||
|
|||||||
+34
-8
@@ -23,7 +23,8 @@
|
|||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"openMenu": "Open menu",
|
"openMenu": "Open menu",
|
||||||
"closeMenu": "Close menu",
|
"closeMenu": "Close menu",
|
||||||
"drawBadge": "Draw badge"
|
"drawBadge": "Draw badge",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
@@ -1085,7 +1086,7 @@
|
|||||||
"backToSite": "Back to site",
|
"backToSite": "Back to site",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
"housekeeping": "Housekeeping",
|
"housekeeping": "Housekeeping (legacy)",
|
||||||
"hotelManagementPanel": "Hotel management panel",
|
"hotelManagementPanel": "Hotel management panel",
|
||||||
"rankLabel": "Rank {rank}",
|
"rankLabel": "Rank {rank}",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
@@ -1656,7 +1657,7 @@
|
|||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"title": "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",
|
"addPermission": "Add permission",
|
||||||
"editPermission": "Edit permission",
|
"editPermission": "Edit permission",
|
||||||
"deletePermission": "Delete permission",
|
"deletePermission": "Delete permission",
|
||||||
@@ -2537,7 +2538,7 @@
|
|||||||
"favicon": "Favicon",
|
"favicon": "Favicon",
|
||||||
"emulator": "Emulator",
|
"emulator": "Emulator",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"housekeeping": "Housekeeping",
|
"housekeeping": "Housekeeping (legacy)",
|
||||||
"commando": "Commando",
|
"commando": "Commando",
|
||||||
"rooms": "Rooms",
|
"rooms": "Rooms",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
@@ -2560,7 +2561,7 @@
|
|||||||
"tickets": "Support desk",
|
"tickets": "Support desk",
|
||||||
"ticketsSubtitle": "CMS tickets, help-center tickets, and reply templates",
|
"ticketsSubtitle": "CMS tickets, help-center tickets, and reply templates",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
"staffAccessSubtitle": "Staff ranks, ACL permissions and housekeeping access",
|
"staffAccessSubtitle": "Staff ranks and live ACL. Housekeeping is legacy reference only.",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"analyticsSubtitle": "Hotel activity, retention and economy",
|
"analyticsSubtitle": "Hotel activity, retention and economy",
|
||||||
"devops": "DevOps",
|
"devops": "DevOps",
|
||||||
@@ -2611,8 +2612,8 @@
|
|||||||
"confirmDisable": "Disable maintenance mode and restore access for all users?"
|
"confirmDisable": "Disable maintenance mode and restore access for all users?"
|
||||||
},
|
},
|
||||||
"housekeeping": {
|
"housekeeping": {
|
||||||
"title": "Housekeeping Permissions",
|
"title": "Housekeeping (legacy)",
|
||||||
"subtitle": "Configure which ranks can access which admin sections",
|
"subtitle": "Legacy min-rank table — not used by live ACL checks. Manage real access under Permissions.",
|
||||||
"addPermission": "Add permission",
|
"addPermission": "Add permission",
|
||||||
"editPermission": "Edit permission",
|
"editPermission": "Edit permission",
|
||||||
"deletePermission": "Delete permission",
|
"deletePermission": "Delete permission",
|
||||||
@@ -2722,7 +2723,10 @@
|
|||||||
"auditAction": "Action",
|
"auditAction": "Action",
|
||||||
"auditTarget": "Target",
|
"auditTarget": "Target",
|
||||||
"save": "Save",
|
"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": {
|
"rooms": {
|
||||||
"title": "Rooms",
|
"title": "Rooms",
|
||||||
@@ -3011,6 +3015,28 @@
|
|||||||
"more": "More"
|
"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",
|
"admin": "Admin",
|
||||||
"openMenu": "Open menu",
|
"openMenu": "Open menu",
|
||||||
"closeMenu": "Close menu",
|
"closeMenu": "Close menu",
|
||||||
"drawBadge": "Crea distintivo"
|
"drawBadge": "Crea distintivo",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} online su {hotel}",
|
"online": "{count} online su {hotel}",
|
||||||
@@ -1018,7 +1019,7 @@
|
|||||||
"devops": "DevOps",
|
"devops": "DevOps",
|
||||||
"wordFilter": "Filtro parole",
|
"wordFilter": "Filtro parole",
|
||||||
"maintenance": "Manutenzione",
|
"maintenance": "Manutenzione",
|
||||||
"housekeeping": "Housekeeping",
|
"housekeeping": "Housekeeping (legacy)",
|
||||||
"rooms": "Stanze",
|
"rooms": "Stanze",
|
||||||
"emailTemplates": "Template email",
|
"emailTemplates": "Template email",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
@@ -1612,7 +1613,7 @@
|
|||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"title": "Permessi",
|
"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",
|
"addPermission": "Aggiungi permesso",
|
||||||
"editPermission": "Modifica permesso",
|
"editPermission": "Modifica permesso",
|
||||||
"deletePermission": "Elimina permesso",
|
"deletePermission": "Elimina permesso",
|
||||||
@@ -2458,8 +2459,8 @@
|
|||||||
"confirmDisable": "Disattivare la modalità manutenzione e ripristinare l'accesso per tutti gli utenti?"
|
"confirmDisable": "Disattivare la modalità manutenzione e ripristinare l'accesso per tutti gli utenti?"
|
||||||
},
|
},
|
||||||
"housekeeping": {
|
"housekeeping": {
|
||||||
"title": "Permessi Housekeeping",
|
"title": "Housekeeping (legacy)",
|
||||||
"subtitle": "Configura quali gradi possono accedere a quali sezioni admin",
|
"subtitle": "Tabella min-rank legacy — non usata dai controlli ACL attivi. Gestisci l'accesso reale in Permessi.",
|
||||||
"addPermission": "Aggiungi permesso",
|
"addPermission": "Aggiungi permesso",
|
||||||
"editPermission": "Modifica permesso",
|
"editPermission": "Modifica permesso",
|
||||||
"deletePermission": "Elimina permesso",
|
"deletePermission": "Elimina permesso",
|
||||||
@@ -2569,7 +2570,10 @@
|
|||||||
"auditTarget": "Destinatario",
|
"auditTarget": "Destinatario",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"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": {
|
"rooms": {
|
||||||
"title": "Stanze",
|
"title": "Stanze",
|
||||||
@@ -2772,7 +2776,7 @@
|
|||||||
"favicon": "Favicon",
|
"favicon": "Favicon",
|
||||||
"emulator": "Emulator",
|
"emulator": "Emulator",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"housekeeping": "Housekeeping",
|
"housekeeping": "Housekeeping (legacy)",
|
||||||
"commando": "Commando",
|
"commando": "Commando",
|
||||||
"rooms": "Stanze",
|
"rooms": "Stanze",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
@@ -2795,7 +2799,7 @@
|
|||||||
"tickets": "Assistenza",
|
"tickets": "Assistenza",
|
||||||
"ticketsSubtitle": "Ticket CMS, ticket centro assistenza e template risposte",
|
"ticketsSubtitle": "Ticket CMS, ticket centro assistenza e template risposte",
|
||||||
"staffAccess": "Gradi e permessi",
|
"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",
|
"analytics": "Analitiche",
|
||||||
"analyticsSubtitle": "Attività hotel, retention ed economia",
|
"analyticsSubtitle": "Attività hotel, retention ed economia",
|
||||||
"devops": "DevOps",
|
"devops": "DevOps",
|
||||||
@@ -3014,6 +3018,28 @@
|
|||||||
"ratelimit": "Stai inviando troppo in fretta. Attendi un momento e riprova.",
|
"ratelimit": "Stai inviando troppo in fretta. Attendi un momento e riprova.",
|
||||||
"error": "Qualcosa è andato storto. 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",
|
"admin": "Admin",
|
||||||
"openMenu": "Menu openen",
|
"openMenu": "Menu openen",
|
||||||
"closeMenu": "Menu sluiten",
|
"closeMenu": "Menu sluiten",
|
||||||
"drawBadge": "Badge tekenen"
|
"drawBadge": "Badge tekenen",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
@@ -1076,7 +1077,7 @@
|
|||||||
"backToSite": "Terug naar site",
|
"backToSite": "Terug naar site",
|
||||||
"expandAll": "Alles openen",
|
"expandAll": "Alles openen",
|
||||||
"collapseAll": "Alles sluiten",
|
"collapseAll": "Alles sluiten",
|
||||||
"housekeeping": "Housekeeping",
|
"housekeeping": "Housekeeping (legacy)",
|
||||||
"hotelManagementPanel": "Hotelbeheerpaneel",
|
"hotelManagementPanel": "Hotelbeheerpaneel",
|
||||||
"rankLabel": "Rang {rank}",
|
"rankLabel": "Rang {rank}",
|
||||||
"hubNewsMedia": "News & media",
|
"hubNewsMedia": "News & media",
|
||||||
@@ -1588,7 +1589,7 @@
|
|||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"title": "Permissies",
|
"title": "Permissies",
|
||||||
"subtitle": "Beheer ranggebaseerde permissies voor het beheerderspaneel",
|
"subtitle": "Beheer live ACL-rangen en rolrechten voor admin- en mod-panelen",
|
||||||
"addPermission": "Permissie toevoegen",
|
"addPermission": "Permissie toevoegen",
|
||||||
"editPermission": "Permissie bewerken",
|
"editPermission": "Permissie bewerken",
|
||||||
"deletePermission": "Permissie verwijderen",
|
"deletePermission": "Permissie verwijderen",
|
||||||
@@ -2433,8 +2434,8 @@
|
|||||||
"confirmDisable": "Onderhoudsmodus uitschakelen en toegang herstellen voor alle gebruikers?"
|
"confirmDisable": "Onderhoudsmodus uitschakelen en toegang herstellen voor alle gebruikers?"
|
||||||
},
|
},
|
||||||
"housekeeping": {
|
"housekeeping": {
|
||||||
"title": "Housekeeping Permissies",
|
"title": "Housekeeping (legacy)",
|
||||||
"subtitle": "Configureer welke rangen toegang hebben tot welke beheerderssecties",
|
"subtitle": "Legacy min-rank tabel — niet gebruikt door live ACL. Beheer echte toegang via Permissions.",
|
||||||
"addPermission": "Permissie toevoegen",
|
"addPermission": "Permissie toevoegen",
|
||||||
"editPermission": "Permissie bewerken",
|
"editPermission": "Permissie bewerken",
|
||||||
"deletePermission": "Permissie verwijderen",
|
"deletePermission": "Permissie verwijderen",
|
||||||
@@ -2544,7 +2545,10 @@
|
|||||||
"auditAction": "Actie",
|
"auditAction": "Actie",
|
||||||
"auditTarget": "Doel",
|
"auditTarget": "Doel",
|
||||||
"save": "Opslaan",
|
"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": {
|
"rooms": {
|
||||||
"title": "Kamers",
|
"title": "Kamers",
|
||||||
@@ -2838,7 +2842,7 @@
|
|||||||
"favicon": "Favicon",
|
"favicon": "Favicon",
|
||||||
"emulator": "Emulator",
|
"emulator": "Emulator",
|
||||||
"email": "E-mail",
|
"email": "E-mail",
|
||||||
"housekeeping": "Housekeeping",
|
"housekeeping": "Housekeeping (legacy)",
|
||||||
"commando": "Commando",
|
"commando": "Commando",
|
||||||
"rooms": "Kamers",
|
"rooms": "Kamers",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
@@ -2861,7 +2865,7 @@
|
|||||||
"tickets": "Supportbalie",
|
"tickets": "Supportbalie",
|
||||||
"ticketsSubtitle": "CMS-tickets, helpcentrum-tickets en antwoordtemplates",
|
"ticketsSubtitle": "CMS-tickets, helpcentrum-tickets en antwoordtemplates",
|
||||||
"staffAccess": "Rangen & rechten",
|
"staffAccess": "Rangen & rechten",
|
||||||
"staffAccessSubtitle": "Staffrangen, ACL-rechten en housekeeping-toegang",
|
"staffAccessSubtitle": "Staffrangen en live ACL. Housekeeping is alleen legacy-referentie.",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"analyticsSubtitle": "Hotelactiviteit, retentie en economie",
|
"analyticsSubtitle": "Hotelactiviteit, retentie en economie",
|
||||||
"devops": "DevOps",
|
"devops": "DevOps",
|
||||||
@@ -3012,6 +3016,28 @@
|
|||||||
"more": "Meer"
|
"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