feat(mod): tickets+team tabs; retire HK writes for live ACL
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
084cca6ea4
commit
75cace41ea
16 files changed
+735
-1002
No files matched your search
@@ -1,194 +1,31 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { auth } from "@/lib/auth";
|
import { redirectSafe } from "@/lib/foundation/security";
|
||||||
import { sessionUserId } from "@/lib/auth/session-user";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
|
||||||
|
|
||||||
function sanitize(str: string, max = 255): string {
|
/**
|
||||||
return String(str ?? "")
|
* Housekeeping table writes are retired. Runtime access uses ACL only.
|
||||||
.normalize("NFC")
|
* Redirect editors to live Permissions.
|
||||||
.trim()
|
*/
|
||||||
.slice(0, max);
|
async function rejectLegacyHousekeepingWrite(): Promise<never> {
|
||||||
}
|
|
||||||
|
|
||||||
async function currentUserId(): Promise<number | null> {
|
|
||||||
const session = await auth();
|
|
||||||
return session?.user ? sessionUserId(session.user.id) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertPermission(formData: FormData): Promise<void> {
|
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
redirectSafe("/admin/permissions?from=housekeeping", "/admin/permissions");
|
||||||
const permission = sanitize(String(formData.get("permission") ?? ""));
|
|
||||||
const minRank = Number(formData.get("minRank"));
|
|
||||||
const descriptionRaw = sanitize(String(formData.get("description") ?? ""));
|
|
||||||
const description = descriptionRaw.length > 0 ? descriptionRaw : null;
|
|
||||||
const groupName =
|
|
||||||
sanitize(String(formData.get("groupName") ?? ""), 100) || null;
|
|
||||||
const dependsOn = sanitize(String(formData.get("dependsOn") ?? "")) || null;
|
|
||||||
|
|
||||||
if (!permission || !Number.isFinite(minRank) || minRank < 0) return;
|
|
||||||
|
|
||||||
const userId = await currentUserId();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const existing = await prisma.websiteHousekeepingPermissions.findUnique({
|
|
||||||
where: { permission },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const before = {
|
|
||||||
minRank: existing.minRank,
|
|
||||||
description: existing.description,
|
|
||||||
groupName: existing.groupName,
|
|
||||||
dependsOn: existing.dependsOn,
|
|
||||||
};
|
|
||||||
await prisma.websiteHousekeepingPermissions.update({
|
|
||||||
where: { permission },
|
|
||||||
data: {
|
|
||||||
minRank,
|
|
||||||
description,
|
|
||||||
groupName,
|
|
||||||
dependsOn,
|
|
||||||
updatedBy: userId ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await logAudit({
|
|
||||||
userId: userId ?? 0,
|
|
||||||
action: "housekeeping.permission.update",
|
|
||||||
target: "WebsiteHousekeepingPermissions",
|
|
||||||
targetId: Number(existing.id),
|
|
||||||
before,
|
|
||||||
after: { minRank, description, groupName, dependsOn },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await prisma.websiteHousekeepingPermissions.create({
|
|
||||||
data: {
|
|
||||||
permission,
|
|
||||||
minRank,
|
|
||||||
description,
|
|
||||||
groupName,
|
|
||||||
dependsOn,
|
|
||||||
updatedBy: userId ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await logAudit({
|
|
||||||
userId: userId ?? 0,
|
|
||||||
action: "housekeeping.permission.create",
|
|
||||||
target: "WebsiteHousekeepingPermissions",
|
|
||||||
targetId: undefined,
|
|
||||||
after: { permission, minRank, description, groupName, dependsOn },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Swallow
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/admin/housekeeping");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePermission(formData: FormData): Promise<void> {
|
export async function upsertPermission(_formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
await rejectLegacyHousekeepingWrite();
|
||||||
|
}
|
||||||
|
|
||||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
export async function deletePermission(_formData: FormData): Promise<void> {
|
||||||
if (!raw) return;
|
await rejectLegacyHousekeepingWrite();
|
||||||
|
|
||||||
const userId = await currentUserId();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const existing = await prisma.websiteHousekeepingPermissions.findUnique({
|
|
||||||
where: { id: BigInt(raw) },
|
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
await prisma.websiteHousekeepingPermissions.delete({
|
|
||||||
where: { id: BigInt(raw) },
|
|
||||||
});
|
|
||||||
await logAudit({
|
|
||||||
userId: userId ?? 0,
|
|
||||||
action: "housekeeping.permission.delete",
|
|
||||||
target: "WebsiteHousekeepingPermissions",
|
|
||||||
targetId: Number(existing.id),
|
|
||||||
before: {
|
|
||||||
permission: existing.permission,
|
|
||||||
minRank: existing.minRank,
|
|
||||||
description: existing.description,
|
|
||||||
groupName: existing.groupName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Already gone / invalid id.
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/admin/housekeeping");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkImportPermissions(
|
export async function bulkImportPermissions(
|
||||||
formData: FormData,
|
_formData: FormData,
|
||||||
): Promise<{ count: number; errors: string[] }> {
|
): Promise<{ count: number; errors: string[] }> {
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
await rejectLegacyHousekeepingWrite();
|
||||||
|
|
||||||
const raw = sanitize(String(formData.get("json") ?? ""));
|
|
||||||
if (!raw) return { count: 0, errors: ["Empty input"] };
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return { count: 0, errors: ["Invalid JSON"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
return { count: 0, errors: ["Expected an array"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = await currentUserId();
|
|
||||||
const errors: string[] = [];
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
for (const item of parsed) {
|
|
||||||
const permission = sanitize(String(item.permission ?? ""));
|
|
||||||
const minRank = Number(item.minRank);
|
|
||||||
const description = sanitize(String(item.description ?? "")) || null;
|
|
||||||
const groupName = sanitize(String(item.groupName ?? ""), 100) || null;
|
|
||||||
const dependsOn = sanitize(String(item.dependsOn ?? "")) || null;
|
|
||||||
|
|
||||||
if (!permission || !Number.isFinite(minRank) || minRank < 0) {
|
|
||||||
errors.push(`Invalid entry: ${JSON.stringify(item)}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.websiteHousekeepingPermissions.upsert({
|
|
||||||
where: { permission },
|
|
||||||
update: {
|
|
||||||
minRank,
|
|
||||||
description,
|
|
||||||
groupName,
|
|
||||||
dependsOn,
|
|
||||||
updatedBy: userId ?? undefined,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
permission,
|
|
||||||
minRank,
|
|
||||||
description,
|
|
||||||
groupName,
|
|
||||||
dependsOn,
|
|
||||||
updatedBy: userId ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
count++;
|
|
||||||
} catch {
|
|
||||||
errors.push(`Failed to upsert: ${permission}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/admin/housekeeping");
|
|
||||||
return { count, errors };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportPermissions(): Promise<string> {
|
export async function exportPermissions(): Promise<string> {
|
||||||
@@ -208,688 +45,16 @@ export async function exportPermissions(): Promise<string> {
|
|||||||
return JSON.stringify(perms, null, 2);
|
return JSON.stringify(perms, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyPreset(formData: FormData): Promise<void> {
|
export async function applyPreset(_formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
await rejectLegacyHousekeepingWrite();
|
||||||
|
|
||||||
const preset = String(formData.get("preset") ?? "").trim();
|
|
||||||
const userId = await currentUserId();
|
|
||||||
|
|
||||||
const presets: Record<
|
|
||||||
string,
|
|
||||||
Array<{
|
|
||||||
permission: string;
|
|
||||||
minRank: number;
|
|
||||||
description?: string;
|
|
||||||
groupName?: string;
|
|
||||||
}>
|
|
||||||
> = {
|
|
||||||
moderator: [
|
|
||||||
{
|
|
||||||
permission: "admin.dashboard",
|
|
||||||
minRank: 5,
|
|
||||||
description: "Access admin dashboard",
|
|
||||||
groupName: "Core",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.users.view",
|
|
||||||
minRank: 5,
|
|
||||||
description: "View user profiles",
|
|
||||||
groupName: "Users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.users.ban",
|
|
||||||
minRank: 5,
|
|
||||||
description: "Ban users",
|
|
||||||
groupName: "Users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.room.view",
|
|
||||||
minRank: 5,
|
|
||||||
description: "View rooms",
|
|
||||||
groupName: "Rooms",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.bans.view",
|
|
||||||
minRank: 5,
|
|
||||||
description: "View bans",
|
|
||||||
groupName: "Bans",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.logs.view",
|
|
||||||
minRank: 5,
|
|
||||||
description: "View logs",
|
|
||||||
groupName: "Logs",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.wordfilter.view",
|
|
||||||
minRank: 5,
|
|
||||||
description: "View wordfilter",
|
|
||||||
groupName: "Wordfilter",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.dashboard",
|
|
||||||
minRank: 5,
|
|
||||||
description: "Mod dashboard",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.cfh.view",
|
|
||||||
minRank: 5,
|
|
||||||
description: "View CFH",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.cfh.edit",
|
|
||||||
minRank: 5,
|
|
||||||
description: "Handle CFH",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.actions",
|
|
||||||
minRank: 5,
|
|
||||||
description: "Mod actions",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.users.view",
|
|
||||||
minRank: 5,
|
|
||||||
description: "View users (mod)",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.bans.view",
|
|
||||||
minRank: 5,
|
|
||||||
description: "View bans (mod)",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
admin: [
|
|
||||||
{
|
|
||||||
permission: "admin.dashboard",
|
|
||||||
minRank: 7,
|
|
||||||
description: "Access admin dashboard",
|
|
||||||
groupName: "Core",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.users.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View user profiles",
|
|
||||||
groupName: "Users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.users.edit",
|
|
||||||
minRank: 7,
|
|
||||||
description: "Edit users",
|
|
||||||
groupName: "Users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.users.ban",
|
|
||||||
minRank: 7,
|
|
||||||
description: "Ban users",
|
|
||||||
groupName: "Users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.users.reset_password",
|
|
||||||
minRank: 7,
|
|
||||||
description: "Reset passwords",
|
|
||||||
groupName: "Users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.room.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View rooms",
|
|
||||||
groupName: "Rooms",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.room.edit",
|
|
||||||
minRank: 7,
|
|
||||||
description: "Edit rooms",
|
|
||||||
groupName: "Rooms",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.room.delete",
|
|
||||||
minRank: 7,
|
|
||||||
description: "Delete rooms",
|
|
||||||
groupName: "Rooms",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.bans.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View bans",
|
|
||||||
groupName: "Bans",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.news.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View news",
|
|
||||||
groupName: "News",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.news.edit",
|
|
||||||
minRank: 7,
|
|
||||||
description: "Edit news",
|
|
||||||
groupName: "News",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.logs.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View logs",
|
|
||||||
groupName: "Logs",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.settings.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View settings",
|
|
||||||
groupName: "Settings",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.wordfilter.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View wordfilter",
|
|
||||||
groupName: "Wordfilter",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.wordfilter.edit",
|
|
||||||
minRank: 7,
|
|
||||||
description: "Edit wordfilter",
|
|
||||||
groupName: "Wordfilter",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.catalog.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View catalog",
|
|
||||||
groupName: "Catalog",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.shop.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View shop",
|
|
||||||
groupName: "Shop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.events.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View events",
|
|
||||||
groupName: "Events",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.tickets.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View tickets",
|
|
||||||
groupName: "Tickets",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.moderation.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View moderation",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.polls.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View polls",
|
|
||||||
groupName: "Polls",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.analytics.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View analytics",
|
|
||||||
groupName: "Analytics",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.prefixes.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View prefixes",
|
|
||||||
groupName: "Prefixes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.banners.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View banners",
|
|
||||||
groupName: "Banners",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.radio.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View radio",
|
|
||||||
groupName: "Radio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.rcon.execute",
|
|
||||||
minRank: 7,
|
|
||||||
description: "Execute RCON",
|
|
||||||
groupName: "RCON",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.dashboard",
|
|
||||||
minRank: 7,
|
|
||||||
description: "Mod dashboard",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.cfh.view",
|
|
||||||
minRank: 7,
|
|
||||||
description: "View CFH",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.cfh.edit",
|
|
||||||
minRank: 7,
|
|
||||||
description: "Handle CFH",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.actions",
|
|
||||||
minRank: 7,
|
|
||||||
description: "Mod actions",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
superadmin: [
|
|
||||||
{
|
|
||||||
permission: "admin.dashboard",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Access admin dashboard",
|
|
||||||
groupName: "Core",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.users.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View user profiles",
|
|
||||||
groupName: "Users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.users.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit users",
|
|
||||||
groupName: "Users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.users.ban",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Ban users",
|
|
||||||
groupName: "Users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.users.reset_password",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Reset passwords",
|
|
||||||
groupName: "Users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.room.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View rooms",
|
|
||||||
groupName: "Rooms",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.room.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit rooms",
|
|
||||||
groupName: "Rooms",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.room.delete",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Delete rooms",
|
|
||||||
groupName: "Rooms",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.bans.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View bans",
|
|
||||||
groupName: "Bans",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.news.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View news",
|
|
||||||
groupName: "News",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.news.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit news",
|
|
||||||
groupName: "News",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.logs.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View logs",
|
|
||||||
groupName: "Logs",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.settings.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View settings",
|
|
||||||
groupName: "Settings",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.settings.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit settings",
|
|
||||||
groupName: "Settings",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.permissions.manage",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Manage permissions",
|
|
||||||
groupName: "Settings",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.wordfilter.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View wordfilter",
|
|
||||||
groupName: "Wordfilter",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.wordfilter.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit wordfilter",
|
|
||||||
groupName: "Wordfilter",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.catalog.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View catalog",
|
|
||||||
groupName: "Catalog",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.catalog.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit catalog",
|
|
||||||
groupName: "Catalog",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.shop.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View shop",
|
|
||||||
groupName: "Shop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.shop.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit shop",
|
|
||||||
groupName: "Shop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.events.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View events",
|
|
||||||
groupName: "Events",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.events.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit events",
|
|
||||||
groupName: "Events",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.tickets.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View tickets",
|
|
||||||
groupName: "Tickets",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.tickets.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit tickets",
|
|
||||||
groupName: "Tickets",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.moderation.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View moderation",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.moderation.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit moderation",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.polls.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View polls",
|
|
||||||
groupName: "Polls",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.polls.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit polls",
|
|
||||||
groupName: "Polls",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.analytics.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View analytics",
|
|
||||||
groupName: "Analytics",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.analytics.export",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Export analytics",
|
|
||||||
groupName: "Analytics",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.devops.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View devops",
|
|
||||||
groupName: "DevOps",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.devops.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit devops",
|
|
||||||
groupName: "DevOps",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.prefixes.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View prefixes",
|
|
||||||
groupName: "Prefixes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.prefixes.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit prefixes",
|
|
||||||
groupName: "Prefixes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.banners.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View banners",
|
|
||||||
groupName: "Banners",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.banners.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit banners",
|
|
||||||
groupName: "Banners",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.radio.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View radio",
|
|
||||||
groupName: "Radio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.radio.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit radio",
|
|
||||||
groupName: "Radio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.rcon.execute",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Execute RCON",
|
|
||||||
groupName: "RCON",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.assets.import",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Import assets",
|
|
||||||
groupName: "Assets",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.export",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Export data",
|
|
||||||
groupName: "Export",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.notifications.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View notifications",
|
|
||||||
groupName: "Notifications",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.notifications.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit notifications",
|
|
||||||
groupName: "Notifications",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.pages.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View CMS pages",
|
|
||||||
groupName: "CMS Pages",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "admin.pages.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit CMS pages",
|
|
||||||
groupName: "CMS Pages",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.dashboard",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Mod dashboard",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.cfh.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View CFH",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.cfh.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Handle CFH",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.actions",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Mod actions",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.team.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View mod team",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.tickets.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View mod tickets",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.tickets.edit",
|
|
||||||
minRank: 8,
|
|
||||||
description: "Edit mod tickets",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.users.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View users (mod)",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permission: "mod.bans.view",
|
|
||||||
minRank: 8,
|
|
||||||
description: "View bans (mod)",
|
|
||||||
groupName: "Moderation",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const selected = presets[preset];
|
|
||||||
if (!selected) return;
|
|
||||||
|
|
||||||
for (const item of selected) {
|
|
||||||
const { permission, minRank, description, groupName } = item;
|
|
||||||
await prisma.websiteHousekeepingPermissions.upsert({
|
|
||||||
where: { permission },
|
|
||||||
update: {
|
|
||||||
minRank,
|
|
||||||
description,
|
|
||||||
groupName,
|
|
||||||
updatedBy: userId ?? undefined,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
permission,
|
|
||||||
minRank,
|
|
||||||
description,
|
|
||||||
groupName,
|
|
||||||
updatedBy: userId ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAudit({
|
|
||||||
userId: userId ?? 0,
|
|
||||||
action: "housekeeping.preset.apply",
|
|
||||||
target: "WebsiteHousekeepingPermissions",
|
|
||||||
after: { preset, count: selected.length },
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/admin/housekeeping");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearAllPermissions(): Promise<void> {
|
export async function clearAllPermissions(): Promise<void> {
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
await rejectLegacyHousekeepingWrite();
|
||||||
|
|
||||||
const userId = await currentUserId();
|
|
||||||
const count = await prisma.websiteHousekeepingPermissions.count();
|
|
||||||
|
|
||||||
await prisma.websiteHousekeepingPermissions.deleteMany();
|
|
||||||
|
|
||||||
await logAudit({
|
|
||||||
userId: userId ?? 0,
|
|
||||||
action: "housekeeping.permission.clear_all",
|
|
||||||
target: "WebsiteHousekeepingPermissions",
|
|
||||||
after: { deletedCount: count },
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/admin/housekeeping");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkDeletePermissions(
|
export async function bulkDeletePermissions(
|
||||||
ids: bigint[],
|
_ids: bigint[],
|
||||||
): Promise<{ count: number }> {
|
): Promise<{ count: number }> {
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
await rejectLegacyHousekeepingWrite();
|
||||||
|
|
||||||
if (!ids.length) return { count: 0 };
|
|
||||||
|
|
||||||
const userId = await currentUserId();
|
|
||||||
|
|
||||||
await prisma.websiteHousekeepingPermissions.deleteMany({
|
|
||||||
where: { id: { in: ids } },
|
|
||||||
});
|
|
||||||
|
|
||||||
await logAudit({
|
|
||||||
userId: userId ?? 0,
|
|
||||||
action: "housekeeping.permission.bulk_delete",
|
|
||||||
target: "WebsiteHousekeepingPermissions",
|
|
||||||
after: { deletedCount: ids.length },
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/admin/housekeeping");
|
|
||||||
return { count: ids.length };
|
|
||||||
}
|
}
|
||||||
+16
-4
@@ -121,7 +121,10 @@ export const closeTicketByUser = authAction(
|
|||||||
// ── Admin actions ────────────────────────────────────────────────────
|
// ── Admin actions ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const adminReplyTicket = adminAction(
|
export const adminReplyTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema },
|
{
|
||||||
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||||
|
schema: replyTicketSchema,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -161,7 +164,10 @@ export const adminReplyTicket = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const updateTicketStatus = adminAction(
|
export const updateTicketStatus = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
|
{
|
||||||
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||||
|
schema: updateTicketStatusSchema,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -196,7 +202,10 @@ export const updateTicketStatus = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const assignTicket = adminAction(
|
export const assignTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
|
{
|
||||||
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||||
|
schema: assignTicketSchema,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -226,7 +235,10 @@ export const assignTicket = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const updateTicketPriority = adminAction(
|
export const updateTicketPriority = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
|
{
|
||||||
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||||
|
schema: updateTicketPrioritySchema,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
|
|||||||
+111
-118
@@ -4,44 +4,10 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getAuditLogs } from "@/lib/services/audit";
|
import { getAuditLogs } from "@/lib/services/audit";
|
||||||
import {
|
import { AuditSection, ExportSection } from "./client";
|
||||||
AuditSection,
|
|
||||||
ExportSection,
|
|
||||||
ImportSection,
|
|
||||||
ManageSection,
|
|
||||||
PresetsSection,
|
|
||||||
TestSection,
|
|
||||||
} from "./client";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const GROUPS = [
|
|
||||||
"Core",
|
|
||||||
"Users",
|
|
||||||
"Rooms",
|
|
||||||
"Bans",
|
|
||||||
"News",
|
|
||||||
"Logs",
|
|
||||||
"Settings",
|
|
||||||
"Wordfilter",
|
|
||||||
"Catalog",
|
|
||||||
"Shop",
|
|
||||||
"Events",
|
|
||||||
"Tickets",
|
|
||||||
"Moderation",
|
|
||||||
"Polls",
|
|
||||||
"Analytics",
|
|
||||||
"DevOps",
|
|
||||||
"Prefixes",
|
|
||||||
"Banners",
|
|
||||||
"Radio",
|
|
||||||
"RCON",
|
|
||||||
"Assets",
|
|
||||||
"Export",
|
|
||||||
"Notifications",
|
|
||||||
"CMS Pages",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function AdminHousekeeping({
|
export default async function AdminHousekeeping({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -53,7 +19,8 @@ export default async function AdminHousekeeping({
|
|||||||
| "ranks"
|
| "ranks"
|
||||||
| "presets"
|
| "presets"
|
||||||
| "test"
|
| "test"
|
||||||
| "audit";
|
| "audit"
|
||||||
|
| "compare";
|
||||||
group?: string;
|
group?: string;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
@@ -64,7 +31,10 @@ export default async function AdminHousekeeping({
|
|||||||
|
|
||||||
const t = await getTranslations("pages.admin.housekeeping");
|
const t = await getTranslations("pages.admin.housekeeping");
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const tab = sp.tab ?? "overview";
|
let tab = sp.tab ?? "overview";
|
||||||
|
if (tab === "manage" || tab === "presets" || tab === "test") {
|
||||||
|
tab = "compare";
|
||||||
|
}
|
||||||
|
|
||||||
type HKPerm = Awaited<
|
type HKPerm = Awaited<
|
||||||
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
|
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
|
||||||
@@ -89,15 +59,48 @@ export default async function AdminHousekeeping({
|
|||||||
rankGroups[p.minRank].push(p);
|
rankGroups[p.minRank].push(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
const missingDeps: Array<{ permission: string; dependsOn: string }> = [];
|
const aclSlugs = await prisma.aclPermission
|
||||||
const permSet = new Set(allPermissions.map((p) => p.permission));
|
.findMany({ select: { slug: true } })
|
||||||
for (const p of allPermissions) {
|
.catch(() => [] as { slug: string }[]);
|
||||||
if (p.dependsOn && !permSet.has(p.dependsOn)) {
|
const aclSlugSet = new Set(aclSlugs.map((p) => p.slug));
|
||||||
missingDeps.push({ permission: p.permission, dependsOn: p.dependsOn });
|
|
||||||
}
|
const aclRolePerms = await prisma.aclModelPermission
|
||||||
|
.findMany({
|
||||||
|
where: { modelType: "Role" },
|
||||||
|
include: {
|
||||||
|
permission: { select: { slug: true } },
|
||||||
|
role: { select: { slug: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
|
const aclMinRankBySlug = new Map<string, number>();
|
||||||
|
for (const row of aclRolePerms) {
|
||||||
|
const roleSlug = row.role.slug;
|
||||||
|
if (!roleSlug.startsWith("rank_")) continue;
|
||||||
|
const rank = Number(roleSlug.slice(5));
|
||||||
|
if (!Number.isFinite(rank)) continue;
|
||||||
|
const slug = row.permission.slug;
|
||||||
|
const prev = aclMinRankBySlug.get(slug);
|
||||||
|
if (prev === undefined || rank < prev) aclMinRankBySlug.set(slug, rank);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allGroups = [...new Set([...GROUPS, ...Object.keys(groupCounts)])];
|
const compareRows = allPermissions.map((p) => {
|
||||||
|
const inAcl = aclSlugSet.has(p.permission);
|
||||||
|
const aclMin = aclMinRankBySlug.get(p.permission);
|
||||||
|
return {
|
||||||
|
permission: p.permission,
|
||||||
|
hkMinRank: p.minRank,
|
||||||
|
inAcl,
|
||||||
|
aclMinRank: aclMin ?? null,
|
||||||
|
aligned: inAcl && aclMin !== undefined ? aclMin === p.minRank : false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const missingInAcl = compareRows.filter((r) => !r.inAcl).length;
|
||||||
|
const rankMismatch = compareRows.filter(
|
||||||
|
(r) => r.inAcl && r.aclMinRank !== null && r.aclMinRank !== r.hkMinRank,
|
||||||
|
).length;
|
||||||
|
const alignedCount = compareRows.filter((r) => r.aligned).length;
|
||||||
|
|
||||||
const auditLogs = await getAuditLogs({
|
const auditLogs = await getAuditLogs({
|
||||||
search: "housekeeping",
|
search: "housekeeping",
|
||||||
@@ -120,6 +123,9 @@ export default async function AdminHousekeeping({
|
|||||||
{t("legacyBannerCta")}
|
{t("legacyBannerCta")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
<p className="m-0 mt-2 text-xs text-[var(--admin-text-muted)]">
|
||||||
|
{t("writesRetired")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-tabs mb-4">
|
<div className="admin-tabs mb-4">
|
||||||
@@ -130,16 +136,10 @@ export default async function AdminHousekeeping({
|
|||||||
{t("tabOverview")}
|
{t("tabOverview")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/housekeeping?tab=manage"
|
href="/admin/housekeeping?tab=compare"
|
||||||
className={`admin-tab ${tab === "manage" ? "active" : ""}`}
|
className={`admin-tab ${tab === "compare" ? "active" : ""}`}
|
||||||
>
|
>
|
||||||
{t("tabManage")}
|
{t("tabCompare")}
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/admin/housekeeping?tab=import"
|
|
||||||
className={`admin-tab ${tab === "import" ? "active" : ""}`}
|
|
||||||
>
|
|
||||||
{t("tabImport")}
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/housekeeping?tab=ranks"
|
href="/admin/housekeeping?tab=ranks"
|
||||||
@@ -148,16 +148,10 @@ export default async function AdminHousekeeping({
|
|||||||
{t("tabRanks")}
|
{t("tabRanks")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/housekeeping?tab=presets"
|
href="/admin/housekeeping?tab=import"
|
||||||
className={`admin-tab ${tab === "presets" ? "active" : ""}`}
|
className={`admin-tab ${tab === "import" ? "active" : ""}`}
|
||||||
>
|
>
|
||||||
{t("tabPresets")}
|
{t("tabExportOnly")}
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/admin/housekeeping?tab=test"
|
|
||||||
className={`admin-tab ${tab === "test" ? "active" : ""}`}
|
|
||||||
>
|
|
||||||
{t("tabTest")}
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/housekeeping?tab=audit"
|
href="/admin/housekeeping?tab=audit"
|
||||||
@@ -183,16 +177,14 @@ export default async function AdminHousekeeping({
|
|||||||
{t("groupCount", { count: Object.keys(groupCounts).length })}
|
{t("groupCount", { count: Object.keys(groupCounts).length })}
|
||||||
</span>
|
</span>
|
||||||
<span className="admin-stat-chip">
|
<span className="admin-stat-chip">
|
||||||
{t("rankCount", { count: Object.keys(rankGroups).length })}
|
{t("compareAligned", { count: alignedCount })}
|
||||||
|
</span>
|
||||||
|
<span className="admin-stat-chip">
|
||||||
|
{t("compareMissingAcl", { count: missingInAcl })}
|
||||||
|
</span>
|
||||||
|
<span className="admin-stat-chip">
|
||||||
|
{t("compareMismatch", { count: rankMismatch })}
|
||||||
</span>
|
</span>
|
||||||
{missingDeps.length > 0 && (
|
|
||||||
<span
|
|
||||||
className="admin-stat-chip"
|
|
||||||
style={{ color: "var(--color-danger)" }}
|
|
||||||
>
|
|
||||||
{t("missingDeps", { count: missingDeps.length })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="mb-6">
|
<section className="mb-6">
|
||||||
@@ -201,19 +193,12 @@ export default async function AdminHousekeeping({
|
|||||||
{Object.entries(groupCounts)
|
{Object.entries(groupCounts)
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
.map(([group, count]) => (
|
.map(([group, count]) => (
|
||||||
<Link
|
<div key={group} className="admin-card p-3">
|
||||||
key={group}
|
|
||||||
href={
|
|
||||||
"/admin/housekeeping?tab=manage&group=" +
|
|
||||||
encodeURIComponent(group)
|
|
||||||
}
|
|
||||||
className="admin-card block p-3 hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
<div className="font-semibold text-sm">{group}</div>
|
<div className="font-semibold text-sm">{group}</div>
|
||||||
<div className="text-xs opacity-60">
|
<div className="text-xs opacity-60">
|
||||||
{count} {count === 1 ? t("permission") : t("permissions")}
|
{count} {count === 1 ? t("permission") : t("permissions")}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -245,52 +230,60 @@ export default async function AdminHousekeeping({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{missingDeps.length > 0 && (
|
|
||||||
<section className="mt-6">
|
|
||||||
<h2
|
|
||||||
className="admin-section-title"
|
|
||||||
style={{ color: "var(--color-danger)" }}
|
|
||||||
>
|
|
||||||
{t("missingDeps")}
|
|
||||||
</h2>
|
|
||||||
<div className="admin-card">
|
|
||||||
<ul className="list-disc pl-5 text-sm">
|
|
||||||
{missingDeps.map((d) => (
|
|
||||||
<li key={d.permission}>
|
|
||||||
<strong>{d.permission}</strong> {t("dependsOn")}{" "}
|
|
||||||
<code>{d.dependsOn}</code> — {t("notFound")}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "manage" && (
|
{tab === "compare" && (
|
||||||
<ManageSection
|
<section>
|
||||||
permissions={allPermissions}
|
<h2 className="admin-section-title">{t("tabCompare")}</h2>
|
||||||
groups={allGroups}
|
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
||||||
groupCounts={groupCounts}
|
{t("compareHint")}
|
||||||
permSet={permSet}
|
</p>
|
||||||
/>
|
<div className="overflow-x-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("colPermission")}</th>
|
||||||
|
<th>{t("colMinRank")}</th>
|
||||||
|
<th>{t("colAclPresent")}</th>
|
||||||
|
<th>{t("colAclMinRank")}</th>
|
||||||
|
<th>{t("colStatus")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{compareRows.map((row) => {
|
||||||
|
const status = !row.inAcl
|
||||||
|
? t("statusMissingAcl")
|
||||||
|
: row.aligned
|
||||||
|
? t("statusAligned")
|
||||||
|
: t("statusMismatch");
|
||||||
|
return (
|
||||||
|
<tr key={row.permission}>
|
||||||
|
<td>
|
||||||
|
<code className="text-xs">{row.permission}</code>
|
||||||
|
</td>
|
||||||
|
<td>{row.hkMinRank}</td>
|
||||||
|
<td>{row.inAcl ? "✓" : "—"}</td>
|
||||||
|
<td>{row.aclMinRank ?? "—"}</td>
|
||||||
|
<td className="text-xs">{status}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "import" && (
|
{tab === "import" && (
|
||||||
<>
|
<div className="mt-2">
|
||||||
<ImportSection />
|
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
|
||||||
<div className="mt-8">
|
{t("exportOnlyHint")}
|
||||||
<ExportSection />
|
</p>
|
||||||
</div>
|
<ExportSection />
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "presets" && <PresetsSection />}
|
|
||||||
|
|
||||||
{tab === "test" && <TestSection permissions={allPermissions} />}
|
|
||||||
|
|
||||||
{tab === "ranks" && (
|
{tab === "ranks" && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="admin-section-title">{t("perRank")}</h2>
|
<h2 className="admin-section-title">{t("perRank")}</h2>
|
||||||
|
|||||||
@@ -5,16 +5,20 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
||||||
import { PermissionsList } from "./permissions-list";
|
import { PermissionsList } from "./permissions-list";
|
||||||
|
|
||||||
export default async function PermissionsPage(_props: {
|
export default async function PermissionsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
|
searchParams?: Promise<{ from?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (
|
if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)) {
|
||||||
!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)
|
|
||||||
) {
|
|
||||||
redirect("/admin");
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sp = (await searchParams) ?? {};
|
||||||
|
const fromHousekeeping = sp.from === "housekeeping";
|
||||||
|
|
||||||
const ranks = await fetchEmulatorRankSummaries(prisma);
|
const ranks = await fetchEmulatorRankSummaries(prisma);
|
||||||
|
|
||||||
// User counts per rank
|
// User counts per rank
|
||||||
@@ -31,5 +35,10 @@ export default async function PermissionsPage(_props: {
|
|||||||
userCount: countMap[rank.id] ?? 0,
|
userCount: countMap[rank.id] ?? 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return <PermissionsList ranks={ranksWithCounts} />;
|
return (
|
||||||
|
<PermissionsList
|
||||||
|
ranks={ranksWithCounts}
|
||||||
|
fromHousekeeping={fromHousekeeping}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,7 @@ interface RankSummary {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ranks: RankSummary[];
|
ranks: RankSummary[];
|
||||||
|
fromHousekeeping?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RANK_ICONS: Record<number, React.ReactNode> = {
|
const RANK_ICONS: Record<number, React.ReactNode> = {
|
||||||
@@ -58,7 +59,10 @@ const RANK_COLORS: Record<number, string> = {
|
|||||||
4: "border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]",
|
4: "border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PermissionsList({ ranks: initialRanks }: Props) {
|
export function PermissionsList({
|
||||||
|
ranks: initialRanks,
|
||||||
|
fromHousekeeping = false,
|
||||||
|
}: Props) {
|
||||||
const { run, isPending } = useServerAction();
|
const { run, isPending } = useServerAction();
|
||||||
const [ranks, setRanks] = useState(initialRanks);
|
const [ranks, setRanks] = useState(initialRanks);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<RankSummary | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<RankSummary | null>(null);
|
||||||
@@ -98,14 +102,23 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{fromHousekeeping ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-4 py-3 text-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
Housekeeping writes are retired. Edit live ACL ranks and permissions
|
||||||
|
here.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div
|
<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)]"
|
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"
|
role="status"
|
||||||
>
|
>
|
||||||
<p className="m-0 font-semibold">Live ACL</p>
|
<p className="m-0 font-semibold">Live ACL</p>
|
||||||
<p className="m-0 mt-1 text-muted-foreground">
|
<p className="m-0 mt-1 text-muted-foreground">
|
||||||
Rank ACL here controls real admin access. The Housekeeping table is
|
Rank ACL here controls real admin and mod access. The Housekeeping
|
||||||
legacy reference only and does not gate routes.
|
table is a read-only legacy archive with an ACL compare view.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -85,18 +85,29 @@ export function AdminTicketDetail({
|
|||||||
staffMembers,
|
staffMembers,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
listHref = "/admin/tickets",
|
||||||
|
userLinkMode = "admin",
|
||||||
}: {
|
}: {
|
||||||
ticket: TicketInfo;
|
ticket: TicketInfo;
|
||||||
messages: TicketMessage[];
|
messages: TicketMessage[];
|
||||||
staffMembers: StaffMember[];
|
staffMembers: StaffMember[];
|
||||||
currentUserId: number;
|
currentUserId: number;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
listHref?: string;
|
||||||
|
userLinkMode?: "admin" | "public";
|
||||||
}) {
|
}) {
|
||||||
const [messages] = useState(initialMessages);
|
const [messages] = useState(initialMessages);
|
||||||
const [reply, setReply] = useState("");
|
const [reply, setReply] = useState("");
|
||||||
const { run, isPending } = useServerAction();
|
const { run, isPending } = useServerAction();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
function creatorHref() {
|
||||||
|
if (userLinkMode === "public") {
|
||||||
|
return `/u/${encodeURIComponent(ticket.creator.username)}`;
|
||||||
|
}
|
||||||
|
return `/admin/users/show/${ticket.creator.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
@@ -152,7 +163,7 @@ export function AdminTicketDetail({
|
|||||||
<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/tickets">
|
<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>
|
||||||
@@ -382,7 +393,7 @@ export function AdminTicketDetail({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/show/${ticket.creator.id}`}
|
href={creatorHref()}
|
||||||
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
|
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
|
||||||
>
|
>
|
||||||
<User className="h-3.5 w-3.5" />
|
<User className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ const STATUS_VARIANT: Record<
|
|||||||
closed: "secondary",
|
closed: "secondary",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TicketsTable({ data }: { data: PaginatedResult<TicketRow> }) {
|
export function TicketsTable({
|
||||||
|
data,
|
||||||
|
basePath = "/admin/tickets",
|
||||||
|
}: {
|
||||||
|
data: PaginatedResult<TicketRow>;
|
||||||
|
basePath?: string;
|
||||||
|
}) {
|
||||||
const t = useTranslations("pages.admin.tickets");
|
const t = useTranslations("pages.admin.tickets");
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
const statusLabel: Record<string, string> = {
|
||||||
@@ -51,7 +57,7 @@ export function TicketsTable({ data }: { data: PaginatedResult<TicketRow> }) {
|
|||||||
#{row.id}
|
#{row.id}
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/tickets/${row.id}`}
|
href={`${basePath}/${row.id}`}
|
||||||
className="font-medium truncate hover:underline"
|
className="font-medium truncate hover:underline"
|
||||||
>
|
>
|
||||||
{row.subject}
|
{row.subject}
|
||||||
|
|||||||
+14
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Ban, Gavel, LogOut, Radio, Shield } from "lucide-react";
|
import { Ban, Gavel, LogOut, Radio, Shield, Ticket, Users } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
@@ -42,11 +42,23 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
|||||||
const showBans =
|
const showBans =
|
||||||
canAccess(permissions, PERMS.MOD_BANS_VIEW, staff.rank) ||
|
canAccess(permissions, PERMS.MOD_BANS_VIEW, staff.rank) ||
|
||||||
canAccess(permissions, PERMS.BANS_VIEW, staff.rank);
|
canAccess(permissions, PERMS.BANS_VIEW, staff.rank);
|
||||||
|
const showTickets =
|
||||||
|
canAccess(permissions, PERMS.MOD_TICKETS_VIEW, staff.rank) ||
|
||||||
|
canAccess(permissions, PERMS.TICKETS_VIEW, staff.rank);
|
||||||
|
const showTeam =
|
||||||
|
canAccess(permissions, PERMS.MOD_TEAM_VIEW, staff.rank) ||
|
||||||
|
canAccess(permissions, PERMS.MODERATION_VIEW, staff.rank);
|
||||||
const showAdmin = canAccess(permissions, PERMS.ADMIN_DASHBOARD, staff.rank);
|
const showAdmin = canAccess(permissions, PERMS.ADMIN_DASHBOARD, staff.rank);
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: "/mod", label: t("nav.overview"), icon: Shield, show: true },
|
{ href: "/mod", label: t("nav.overview"), icon: Shield, show: true },
|
||||||
{ href: "/mod/cfh", label: t("nav.cfh"), icon: Radio, show: showCfh },
|
{ href: "/mod/cfh", label: t("nav.cfh"), icon: Radio, show: showCfh },
|
||||||
|
{
|
||||||
|
href: "/mod/tickets",
|
||||||
|
label: t("nav.tickets"),
|
||||||
|
icon: Ticket,
|
||||||
|
show: showTickets,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/mod/actions",
|
href: "/mod/actions",
|
||||||
label: t("nav.actions"),
|
label: t("nav.actions"),
|
||||||
@@ -54,6 +66,7 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
|||||||
show: showActions,
|
show: showActions,
|
||||||
},
|
},
|
||||||
{ href: "/mod/bans", label: t("nav.bans"), icon: Ban, show: showBans },
|
{ href: "/mod/bans", label: t("nav.bans"), icon: Ban, show: showBans },
|
||||||
|
{ href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
|
||||||
].filter((item) => item.show);
|
].filter((item) => item.show);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+49
-3
@@ -1,4 +1,4 @@
|
|||||||
import { Ban, Gavel, Radio, Shield } from "lucide-react";
|
import { Ban, Gavel, Radio, Shield, Ticket, Users } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatsCard } from "@/components/admin/stats-card";
|
import { StatsCard } from "@/components/admin/stats-card";
|
||||||
@@ -23,8 +23,14 @@ export default async function ModDashboardPage() {
|
|||||||
const showBans =
|
const showBans =
|
||||||
canAccess(permissions, PERMS.MOD_BANS_VIEW, rank) ||
|
canAccess(permissions, PERMS.MOD_BANS_VIEW, rank) ||
|
||||||
canAccess(permissions, PERMS.BANS_VIEW, rank);
|
canAccess(permissions, PERMS.BANS_VIEW, rank);
|
||||||
|
const showTickets =
|
||||||
|
canAccess(permissions, PERMS.MOD_TICKETS_VIEW, rank) ||
|
||||||
|
canAccess(permissions, PERMS.TICKETS_VIEW, rank);
|
||||||
|
const showTeam =
|
||||||
|
canAccess(permissions, PERMS.MOD_TEAM_VIEW, rank) ||
|
||||||
|
canAccess(permissions, PERMS.MODERATION_VIEW, rank);
|
||||||
|
|
||||||
const [openCfh, todayBans, modsOnline] = await Promise.all([
|
const [openCfh, todayBans, modsOnline, openTickets] = await Promise.all([
|
||||||
showCfh
|
showCfh
|
||||||
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
|
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
|
||||||
: Promise.resolve(0),
|
: Promise.resolve(0),
|
||||||
@@ -36,6 +42,11 @@ export default async function ModDashboardPage() {
|
|||||||
prisma.user
|
prisma.user
|
||||||
.count({ where: { online: "1", rank: { gte: minStaffRank } } })
|
.count({ where: { online: "1", rank: { gte: minStaffRank } } })
|
||||||
.catch(() => 0),
|
.catch(() => 0),
|
||||||
|
showTickets
|
||||||
|
? prisma.websiteTicket
|
||||||
|
.count({ where: { status: { not: "closed" } } })
|
||||||
|
.catch(() => 0)
|
||||||
|
: Promise.resolve(0),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,7 +58,7 @@ export default async function ModDashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{showCfh ? (
|
{showCfh ? (
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title={t("stats.openCfh")}
|
title={t("stats.openCfh")}
|
||||||
@@ -56,6 +67,14 @@ export default async function ModDashboardPage() {
|
|||||||
href="/mod/cfh"
|
href="/mod/cfh"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showTickets ? (
|
||||||
|
<StatsCard
|
||||||
|
title={t("stats.openTickets")}
|
||||||
|
value={openTickets}
|
||||||
|
icon={<Ticket className="h-4 w-4" />}
|
||||||
|
href="/mod/tickets"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{showBans ? (
|
{showBans ? (
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title={t("stats.bansToday")}
|
title={t("stats.bansToday")}
|
||||||
@@ -68,6 +87,7 @@ export default async function ModDashboardPage() {
|
|||||||
title={t("stats.modsOnline")}
|
title={t("stats.modsOnline")}
|
||||||
value={modsOnline}
|
value={modsOnline}
|
||||||
icon={<Shield className="h-4 w-4" />}
|
icon={<Shield className="h-4 w-4" />}
|
||||||
|
href={showTeam ? "/mod/team" : undefined}
|
||||||
/>
|
/>
|
||||||
{showActions ? (
|
{showActions ? (
|
||||||
<StatsCard
|
<StatsCard
|
||||||
@@ -78,6 +98,15 @@ export default async function ModDashboardPage() {
|
|||||||
href="/mod/actions"
|
href="/mod/actions"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showTeam ? (
|
||||||
|
<StatsCard
|
||||||
|
title={t("stats.team")}
|
||||||
|
value="→"
|
||||||
|
description={t("stats.teamHint")}
|
||||||
|
icon={<Users className="h-4 w-4" />}
|
||||||
|
href="/mod/team"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-card space-y-2 p-4">
|
<section className="admin-card space-y-2 p-4">
|
||||||
@@ -90,6 +119,16 @@ export default async function ModDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showTickets ? (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/mod/tickets"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t("nav.tickets")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
{showActions ? (
|
{showActions ? (
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
@@ -107,6 +146,13 @@ export default async function ModDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showTeam ? (
|
||||||
|
<li>
|
||||||
|
<Link href="/mod/team" className="text-primary hover:underline">
|
||||||
|
{t("nav.team")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { AvatarImage } from "@/components/shared/avatar-image";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { requireModPermission } from "@/lib/admin/guard";
|
||||||
|
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function ModTeamLitePage() {
|
||||||
|
await requireModPermission([PERMS.MOD_TEAM_VIEW, PERMS.MODERATION_VIEW]);
|
||||||
|
|
||||||
|
const minStaffRank = await getMinStaffRank();
|
||||||
|
const staff = await prisma.user.findMany({
|
||||||
|
where: { rank: { gte: minStaffRank } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
look: true,
|
||||||
|
rank: true,
|
||||||
|
online: true,
|
||||||
|
lastLogin: true,
|
||||||
|
lastOnline: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ online: "desc" }, { rank: "desc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [cfhCounts, webTicketCounts, modActionCounts] = await Promise.all([
|
||||||
|
prisma.supportTickets.groupBy({
|
||||||
|
by: ["modId"],
|
||||||
|
where: { modId: { in: staff.map((s) => s.id) } },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
prisma.websiteTicket.groupBy({
|
||||||
|
by: ["assigneeId"],
|
||||||
|
where: { assigneeId: { in: staff.map((s) => s.id) } },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
prisma.adminAuditLog.groupBy({
|
||||||
|
by: ["userId"],
|
||||||
|
where: {
|
||||||
|
userId: { in: staff.map((s) => s.id) },
|
||||||
|
action: { startsWith: "mod_" },
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cfhMap = new Map(cfhCounts.map((c) => [c.modId, c._count._all]));
|
||||||
|
const webMap = new Map(
|
||||||
|
webTicketCounts.map((c) => [c.assigneeId, c._count._all]),
|
||||||
|
);
|
||||||
|
const actionMap = new Map(
|
||||||
|
modActionCounts.map((c) => [c.userId, c._count._all]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-[var(--admin-success)]">
|
||||||
|
{staff.filter((s) => s.online === "1").length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Online Now</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 text-center">
|
||||||
|
<p className="text-2xl font-bold">{staff.length}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Total Staff</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-primary">
|
||||||
|
{staff.filter((s) => s.rank > minStaffRank).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Above min rank</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-4 p-3 bg-muted/50 text-xs font-medium text-muted-foreground border-b">
|
||||||
|
<span />
|
||||||
|
<span>Name</span>
|
||||||
|
<span>Rank</span>
|
||||||
|
<span>CFH</span>
|
||||||
|
<span>Tickets</span>
|
||||||
|
<span>Actions</span>
|
||||||
|
<span>Last Seen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{staff.map((member) => (
|
||||||
|
<Link
|
||||||
|
key={member.id}
|
||||||
|
href={`/u/${encodeURIComponent(member.username)}`}
|
||||||
|
className="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-4 p-3 items-center hover:bg-accent/50 transition-all duration-150 border-b last:border-0"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<AvatarImage
|
||||||
|
figure={member.look}
|
||||||
|
options={{ headOnly: true, size: "l" }}
|
||||||
|
alt={member.username}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-background ${
|
||||||
|
member.online === "1"
|
||||||
|
? "bg-[var(--admin-success)]"
|
||||||
|
: "bg-[var(--admin-text-muted)]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{member.username}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
Rank {member.rank}
|
||||||
|
</Badge>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-bold">{cfhMap.get(member.id) ?? 0}</p>
|
||||||
|
<p className="text-[9px] text-muted-foreground">CFH</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-bold">{webMap.get(member.id) ?? 0}</p>
|
||||||
|
<p className="text-[9px] text-muted-foreground">Web</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-bold">
|
||||||
|
{actionMap.get(member.id) ?? 0}
|
||||||
|
</p>
|
||||||
|
<p className="text-[9px] text-muted-foreground">Mod</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{member.online === "1"
|
||||||
|
? "Online"
|
||||||
|
: member.lastOnline
|
||||||
|
? new Date(member.lastOnline * 1000).toLocaleDateString()
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { AdminTicketDetail } from "@/app/admin/tickets/[id]/admin-ticket-detail";
|
||||||
|
import { requireModPermission } from "@/lib/admin/guard";
|
||||||
|
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function ModTicketDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
|
||||||
|
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
const { id } = await params;
|
||||||
|
const ticketId = Number(id);
|
||||||
|
if (Number.isNaN(ticketId)) notFound();
|
||||||
|
|
||||||
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
|
where: { id: ticketId },
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
look: true,
|
||||||
|
rank: true,
|
||||||
|
ipRegister: true,
|
||||||
|
mail: true,
|
||||||
|
accountCreated: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assignee: { select: { id: true, username: true } },
|
||||||
|
messages: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, username: true, look: true, rank: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) notFound();
|
||||||
|
|
||||||
|
const minStaffRank = await getMinStaffRank();
|
||||||
|
const staffMembers = await prisma.user.findMany({
|
||||||
|
where: { rank: { gte: minStaffRank } },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
orderBy: { username: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const canEdit =
|
||||||
|
canAccess(permissions, PERMS.MOD_TICKETS_EDIT, session.user.rank) ||
|
||||||
|
canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminTicketDetail
|
||||||
|
ticket={{
|
||||||
|
id: ticket.id,
|
||||||
|
subject: ticket.subject,
|
||||||
|
category: ticket.category,
|
||||||
|
priority: ticket.priority,
|
||||||
|
status: ticket.status,
|
||||||
|
createdAt: ticket.createdAt.toISOString(),
|
||||||
|
closedAt: ticket.closedAt?.toISOString() ?? null,
|
||||||
|
creator: {
|
||||||
|
id: ticket.creator.id,
|
||||||
|
username: ticket.creator.username,
|
||||||
|
look: ticket.creator.look,
|
||||||
|
rank: ticket.creator.rank,
|
||||||
|
mail: ticket.creator.mail ?? "",
|
||||||
|
ipRegister: ticket.creator.ipRegister,
|
||||||
|
accountCreated: ticket.creator.accountCreated,
|
||||||
|
},
|
||||||
|
assignee: ticket.assignee
|
||||||
|
? { id: ticket.assignee.id, username: ticket.assignee.username }
|
||||||
|
: null,
|
||||||
|
}}
|
||||||
|
messages={ticket.messages.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
message: m.message,
|
||||||
|
isStaff: m.isStaff === 1,
|
||||||
|
createdAt: m.createdAt.toISOString(),
|
||||||
|
user: {
|
||||||
|
id: m.user.id,
|
||||||
|
username: m.user.username,
|
||||||
|
look: m.user.look,
|
||||||
|
rank: m.user.rank,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
staffMembers={staffMembers}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
canEdit={canEdit}
|
||||||
|
listHref="/mod/tickets"
|
||||||
|
userLinkMode="public"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import {
|
||||||
|
type TicketRow,
|
||||||
|
TicketsTable,
|
||||||
|
} from "@/app/admin/tickets/tickets-table";
|
||||||
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
|
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";
|
||||||
|
|
||||||
|
function fromDate(d: Date | null | undefined): string {
|
||||||
|
return d ? d.toISOString().slice(0, 10) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ModTicketsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
|
}) {
|
||||||
|
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
|
||||||
|
|
||||||
|
const t = await getTranslations("pages.admin.tickets");
|
||||||
|
const raw = await searchParams;
|
||||||
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
|
const statusFilter = raw.filter_status || raw.status || "active";
|
||||||
|
|
||||||
|
const conditions: Prisma.WebsiteTicketWhereInput[] = [];
|
||||||
|
|
||||||
|
if (statusFilter === "active") {
|
||||||
|
conditions.push({ status: { not: "closed" } });
|
||||||
|
} else if (statusFilter && statusFilter !== "all") {
|
||||||
|
conditions.push({ status: statusFilter });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.search.trim()) {
|
||||||
|
const q = parsed.search.trim();
|
||||||
|
const asId = Number(q);
|
||||||
|
conditions.push({
|
||||||
|
OR: [
|
||||||
|
{ subject: { contains: q } },
|
||||||
|
{ category: { contains: q } },
|
||||||
|
{ creator: { username: { contains: q } } },
|
||||||
|
{ assignee: { username: { contains: q } } },
|
||||||
|
...(Number.isFinite(asId) && asId > 0 ? [{ id: asId }] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Prisma.WebsiteTicketWhereInput =
|
||||||
|
conditions.length === 0
|
||||||
|
? {}
|
||||||
|
: conditions.length === 1
|
||||||
|
? conditions[0]
|
||||||
|
: { AND: conditions };
|
||||||
|
|
||||||
|
const SORT_MAP: Record<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
|
||||||
|
{
|
||||||
|
subject: { subject: "asc" },
|
||||||
|
status: { status: "asc" },
|
||||||
|
priority: { priority: "asc" },
|
||||||
|
assignee: { assigneeId: "asc" },
|
||||||
|
date: { createdAt: "desc" },
|
||||||
|
id: { id: "desc" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseOrder = SORT_MAP[parsed.sort ?? "date"] ?? { createdAt: "desc" };
|
||||||
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
|
const orderBy = {
|
||||||
|
[orderField]: parsed.order,
|
||||||
|
} as Prisma.WebsiteTicketOrderByWithRelationInput;
|
||||||
|
|
||||||
|
const [total, openCount, inProgressCount, waitingCount, closedCount] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.websiteTicket.count({ where }).catch(() => 0),
|
||||||
|
prisma.websiteTicket.count({ where: { status: "open" } }).catch(() => 0),
|
||||||
|
prisma.websiteTicket
|
||||||
|
.count({ where: { status: "in_progress" } })
|
||||||
|
.catch(() => 0),
|
||||||
|
prisma.websiteTicket
|
||||||
|
.count({ where: { status: "waiting" } })
|
||||||
|
.catch(() => 0),
|
||||||
|
prisma.websiteTicket
|
||||||
|
.count({ where: { status: "closed" } })
|
||||||
|
.catch(() => 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||||
|
|
||||||
|
const tickets = await prisma.websiteTicket
|
||||||
|
.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
skip: pagination.offset,
|
||||||
|
take: pagination.perPage,
|
||||||
|
include: {
|
||||||
|
creator: { select: { id: true, username: true } },
|
||||||
|
assignee: { select: { id: true, username: true } },
|
||||||
|
_count: { select: { messages: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
|
const rows: TicketRow[] = tickets.map((ticket) => ({
|
||||||
|
id: ticket.id,
|
||||||
|
subject: ticket.subject,
|
||||||
|
creator: ticket.creator.username,
|
||||||
|
creatorId: ticket.creatorId,
|
||||||
|
category: ticket.category,
|
||||||
|
messages: ticket._count.messages,
|
||||||
|
status: ticket.status,
|
||||||
|
priority: ticket.priority,
|
||||||
|
assignee: ticket.assignee?.username ?? "—",
|
||||||
|
date: fromDate(ticket.createdAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||||
|
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||||
|
<StatusCard
|
||||||
|
label={t("statusInProgress")}
|
||||||
|
value={inProgressCount}
|
||||||
|
state={inProgressCount > 0 ? "warn" : "neutral"}
|
||||||
|
icon="⏳"
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
label={t("statusWaiting")}
|
||||||
|
value={waitingCount}
|
||||||
|
state={waitingCount > 0 ? "warn" : "neutral"}
|
||||||
|
icon="🕒"
|
||||||
|
/>
|
||||||
|
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total === 0 && !parsed.search && statusFilter === "active" ? (
|
||||||
|
<div className="admin-empty">{t("noTickets")}</div>
|
||||||
|
) : (
|
||||||
|
<TicketsTable
|
||||||
|
basePath="/mod/tickets"
|
||||||
|
data={{
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
page: pagination.page,
|
||||||
|
perPage: pagination.perPage,
|
||||||
|
lastPage: pagination.lastPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -70,7 +70,10 @@ export async function requireMod(): Promise<StaffUser> {
|
|||||||
canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank) ||
|
canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank) ||
|
||||||
canAccess(permissions, PERMS.MOD_CFH_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_ACTIONS, session.user.rank) ||
|
||||||
canAccess(permissions, PERMS.MOD_BANS_VIEW, session.user.rank);
|
canAccess(permissions, PERMS.MOD_BANS_VIEW, session.user.rank) ||
|
||||||
|
canAccess(permissions, PERMS.MOD_TICKETS_VIEW, session.user.rank) ||
|
||||||
|
canAccess(permissions, PERMS.MOD_TEAM_VIEW, session.user.rank) ||
|
||||||
|
canAccess(permissions, PERMS.TICKETS_VIEW, session.user.rank);
|
||||||
if (!ok) redirectSafe("/", "/");
|
if (!ok) redirectSafe("/", "/");
|
||||||
return {
|
return {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
|
|||||||
+22
-3
@@ -2726,7 +2726,21 @@
|
|||||||
"importTitle": "Import permissions",
|
"importTitle": "Import permissions",
|
||||||
"legacyBannerTitle": "This table is not live ACL",
|
"legacyBannerTitle": "This table is not live ACL",
|
||||||
"legacyBannerBody": "Runtime access uses the ACL permissions system. Use Housekeeping only as a legacy reference.",
|
"legacyBannerBody": "Runtime access uses the ACL permissions system. Use Housekeeping only as a legacy reference.",
|
||||||
"legacyBannerCta": "Open live Permissions"
|
"legacyBannerCta": "Open live Permissions",
|
||||||
|
"writesRetired": "Create/edit/import/presets on this table are disabled. Change live access under Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Rooms",
|
"title": "Rooms",
|
||||||
@@ -3028,14 +3042,19 @@
|
|||||||
"actions": "Quick actions",
|
"actions": "Quick actions",
|
||||||
"bans": "Bans",
|
"bans": "Bans",
|
||||||
"fullAdmin": "Full admin",
|
"fullAdmin": "Full admin",
|
||||||
"backToSite": "Back to site"
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"openCfh": "Open CFH",
|
"openCfh": "Open CFH",
|
||||||
"bansToday": "Bans today",
|
"bansToday": "Bans today",
|
||||||
"modsOnline": "Mods online",
|
"modsOnline": "Mods online",
|
||||||
"quickActions": "Quick actions",
|
"quickActions": "Quick actions",
|
||||||
"quickActionsHint": "Kick, mute, alert"
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-3
@@ -2573,7 +2573,21 @@
|
|||||||
"importTitle": "Import permissions",
|
"importTitle": "Import permissions",
|
||||||
"legacyBannerTitle": "Questa tabella non e ACL live",
|
"legacyBannerTitle": "Questa tabella non e ACL live",
|
||||||
"legacyBannerBody": "L'accesso runtime usa il sistema ACL. Usa Housekeeping solo come riferimento legacy.",
|
"legacyBannerBody": "L'accesso runtime usa il sistema ACL. Usa Housekeeping solo come riferimento legacy.",
|
||||||
"legacyBannerCta": "Apri Permessi live"
|
"legacyBannerCta": "Apri Permessi live",
|
||||||
|
"writesRetired": "Create/modifica/import/preset su questa tabella sono disabilitati. Cambia l'accesso live in Permessi.",
|
||||||
|
"tabCompare": "Confronto ACL",
|
||||||
|
"tabExportOnly": "Esporta archivio",
|
||||||
|
"compareHint": "Confronta le righe legacy housekeeping con i grant ACL live rank_* (grado minimo con lo slug).",
|
||||||
|
"compareAligned": "{count} allineati",
|
||||||
|
"compareMissingAcl": "{count} mancanti in ACL",
|
||||||
|
"compareMismatch": "{count} mismatch grado",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "Min grado ACL",
|
||||||
|
"colStatus": "Stato",
|
||||||
|
"statusAligned": "Allineato",
|
||||||
|
"statusMissingAcl": "Manca in ACL",
|
||||||
|
"statusMismatch": "Grado diverso",
|
||||||
|
"exportOnlyHint": "Le scritture sono disabilitate. Puoi ancora esportare la tabella legacy per archivio."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Stanze",
|
"title": "Stanze",
|
||||||
@@ -3031,14 +3045,19 @@
|
|||||||
"actions": "Azioni rapide",
|
"actions": "Azioni rapide",
|
||||||
"bans": "Ban",
|
"bans": "Ban",
|
||||||
"fullAdmin": "Admin completo",
|
"fullAdmin": "Admin completo",
|
||||||
"backToSite": "Torna al sito"
|
"backToSite": "Torna al sito",
|
||||||
|
"tickets": "Ticket",
|
||||||
|
"team": "Team"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"openCfh": "CFH aperti",
|
"openCfh": "CFH aperti",
|
||||||
"bansToday": "Ban oggi",
|
"bansToday": "Ban oggi",
|
||||||
"modsOnline": "Mod online",
|
"modsOnline": "Mod online",
|
||||||
"quickActions": "Azioni rapide",
|
"quickActions": "Azioni rapide",
|
||||||
"quickActionsHint": "Kick, mute, alert"
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Ticket aperti",
|
||||||
|
"team": "Team mod",
|
||||||
|
"teamHint": "Staff online e carico"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-3
@@ -2548,7 +2548,21 @@
|
|||||||
"importTitle": "Permissies importeren",
|
"importTitle": "Permissies importeren",
|
||||||
"legacyBannerTitle": "Deze tabel is geen live ACL",
|
"legacyBannerTitle": "Deze tabel is geen live ACL",
|
||||||
"legacyBannerBody": "Runtime-toegang gebruikt het ACL-systeem. Gebruik Housekeeping alleen als legacy-referentie.",
|
"legacyBannerBody": "Runtime-toegang gebruikt het ACL-systeem. Gebruik Housekeeping alleen als legacy-referentie.",
|
||||||
"legacyBannerCta": "Open live Permissions"
|
"legacyBannerCta": "Open live Permissions",
|
||||||
|
"writesRetired": "Aanmaken/bewerken/import/presets op deze tabel zijn uitgeschakeld. Wijzig live toegang onder Permissions.",
|
||||||
|
"tabCompare": "ACL vergelijking",
|
||||||
|
"tabExportOnly": "Exporteer archief",
|
||||||
|
"compareHint": "Vergelijkt legacy housekeeping min-rank rijen met live ACL rank_* grants (laagste rang met de slug).",
|
||||||
|
"compareAligned": "{count} uitgelijnd",
|
||||||
|
"compareMissingAcl": "{count} ontbreekt in ACL",
|
||||||
|
"compareMismatch": "{count} rang mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rang",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Uitgelijnd",
|
||||||
|
"statusMissingAcl": "Ontbreekt in ACL",
|
||||||
|
"statusMismatch": "Rang verschilt",
|
||||||
|
"exportOnlyHint": "Schrijven is uitgeschakeld. Je kunt de legacy-tabel nog exporteren voor archief."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Kamers",
|
"title": "Kamers",
|
||||||
@@ -3029,14 +3043,19 @@
|
|||||||
"actions": "Snelle acties",
|
"actions": "Snelle acties",
|
||||||
"bans": "Bans",
|
"bans": "Bans",
|
||||||
"fullAdmin": "Volledige admin",
|
"fullAdmin": "Volledige admin",
|
||||||
"backToSite": "Terug naar site"
|
"backToSite": "Terug naar site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"openCfh": "Open CFH",
|
"openCfh": "Open CFH",
|
||||||
"bansToday": "Bans vandaag",
|
"bansToday": "Bans vandaag",
|
||||||
"modsOnline": "Mods online",
|
"modsOnline": "Mods online",
|
||||||
"quickActions": "Snelle acties",
|
"quickActions": "Snelle acties",
|
||||||
"quickActionsHint": "Kick, mute, alert"
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & werklast"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user