feat(mod): lite /mod panel + clarify ACL vs housekeeping legacy
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m31s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-22 21:29:32 +02:00
1 parent a384c9a8bb
commit 084cca6ea4
18 files changed
+931 -77

No files matched your search

+12 -9
View File
@@ -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);
+16
View File
@@ -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" />
+21 -4
View File
@@ -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">
+13
View File
@@ -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>
);
}
+100
View File
@@ -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>
);
}
+91
View File
@@ -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
+56 -5
View File
@@ -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"
File diff suppressed because it is too large. Load diff
+35 -1
View File
@@ -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;
}
+10 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
}
}