feat(mod): tickets+team tabs; retire HK writes for live ACL
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 30s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-22 21:35:35 +02:00
1 parent 084cca6ea4
commit 75cace41ea
16 files changed
+735 -1002

No files matched your search

+111 -118
View File
@@ -4,44 +4,10 @@ import { getTranslations } from "next-intl/server";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { getAuditLogs } from "@/lib/services/audit";
import {
AuditSection,
ExportSection,
ImportSection,
ManageSection,
PresetsSection,
TestSection,
} from "./client";
import { AuditSection, ExportSection } from "./client";
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({
searchParams,
}: {
@@ -53,7 +19,8 @@ export default async function AdminHousekeeping({
| "ranks"
| "presets"
| "test"
| "audit";
| "audit"
| "compare";
group?: string;
}>;
}) {
@@ -64,7 +31,10 @@ export default async function AdminHousekeeping({
const t = await getTranslations("pages.admin.housekeeping");
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<
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
@@ -89,15 +59,48 @@ export default async function AdminHousekeeping({
rankGroups[p.minRank].push(p);
}
const missingDeps: Array<{ permission: string; dependsOn: string }> = [];
const permSet = new Set(allPermissions.map((p) => p.permission));
for (const p of allPermissions) {
if (p.dependsOn && !permSet.has(p.dependsOn)) {
missingDeps.push({ permission: p.permission, dependsOn: p.dependsOn });
}
const aclSlugs = await prisma.aclPermission
.findMany({ select: { slug: true } })
.catch(() => [] as { slug: string }[]);
const aclSlugSet = new Set(aclSlugs.map((p) => p.slug));
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({
search: "housekeeping",
@@ -120,6 +123,9 @@ export default async function AdminHousekeeping({
{t("legacyBannerCta")}
</Link>
</p>
<p className="m-0 mt-2 text-xs text-[var(--admin-text-muted)]">
{t("writesRetired")}
</p>
</div>
<div className="admin-tabs mb-4">
@@ -130,16 +136,10 @@ export default async function AdminHousekeeping({
{t("tabOverview")}
</Link>
<Link
href="/admin/housekeeping?tab=manage"
className={`admin-tab ${tab === "manage" ? "active" : ""}`}
href="/admin/housekeeping?tab=compare"
className={`admin-tab ${tab === "compare" ? "active" : ""}`}
>
{t("tabManage")}
</Link>
<Link
href="/admin/housekeeping?tab=import"
className={`admin-tab ${tab === "import" ? "active" : ""}`}
>
{t("tabImport")}
{t("tabCompare")}
</Link>
<Link
href="/admin/housekeeping?tab=ranks"
@@ -148,16 +148,10 @@ export default async function AdminHousekeeping({
{t("tabRanks")}
</Link>
<Link
href="/admin/housekeeping?tab=presets"
className={`admin-tab ${tab === "presets" ? "active" : ""}`}
href="/admin/housekeeping?tab=import"
className={`admin-tab ${tab === "import" ? "active" : ""}`}
>
{t("tabPresets")}
</Link>
<Link
href="/admin/housekeeping?tab=test"
className={`admin-tab ${tab === "test" ? "active" : ""}`}
>
{t("tabTest")}
{t("tabExportOnly")}
</Link>
<Link
href="/admin/housekeeping?tab=audit"
@@ -183,16 +177,14 @@ export default async function AdminHousekeeping({
{t("groupCount", { count: Object.keys(groupCounts).length })}
</span>
<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>
{missingDeps.length > 0 && (
<span
className="admin-stat-chip"
style={{ color: "var(--color-danger)" }}
>
{t("missingDeps", { count: missingDeps.length })}
</span>
)}
</div>
<section className="mb-6">
@@ -201,19 +193,12 @@ export default async function AdminHousekeeping({
{Object.entries(groupCounts)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, count]) => (
<Link
key={group}
href={
"/admin/housekeeping?tab=manage&group=" +
encodeURIComponent(group)
}
className="admin-card block p-3 hover:opacity-80 transition-opacity"
>
<div key={group} className="admin-card p-3">
<div className="font-semibold text-sm">{group}</div>
<div className="text-xs opacity-60">
{count} {count === 1 ? t("permission") : t("permissions")}
</div>
</Link>
</div>
))}
</div>
</section>
@@ -245,52 +230,60 @@ export default async function AdminHousekeeping({
</table>
</div>
</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> &mdash; {t("notFound")}
</li>
))}
</ul>
</div>
</section>
)}
</>
)}
{tab === "manage" && (
<ManageSection
permissions={allPermissions}
groups={allGroups}
groupCounts={groupCounts}
permSet={permSet}
/>
{tab === "compare" && (
<section>
<h2 className="admin-section-title">{t("tabCompare")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("compareHint")}
</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" && (
<>
<ImportSection />
<div className="mt-8">
<ExportSection />
</div>
</>
<div className="mt-2">
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
{t("exportOnlyHint")}
</p>
<ExportSection />
</div>
)}
{tab === "presets" && <PresetsSection />}
{tab === "test" && <TestSection permissions={allPermissions} />}
{tab === "ranks" && (
<section>
<h2 className="admin-section-title">{t("perRank")}</h2>