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
+111
-118
@@ -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> — {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>
|
||||
|
||||
@@ -5,16 +5,20 @@ import { prisma } from "@/lib/prisma";
|
||||
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
||||
import { PermissionsList } from "./permissions-list";
|
||||
|
||||
export default async function PermissionsPage(_props: {
|
||||
export default async function PermissionsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams?: Promise<{ from?: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (
|
||||
!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)
|
||||
) {
|
||||
if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const sp = (await searchParams) ?? {};
|
||||
const fromHousekeeping = sp.from === "housekeeping";
|
||||
|
||||
const ranks = await fetchEmulatorRankSummaries(prisma);
|
||||
|
||||
// User counts per rank
|
||||
@@ -31,5 +35,10 @@ export default async function PermissionsPage(_props: {
|
||||
userCount: countMap[rank.id] ?? 0,
|
||||
}));
|
||||
|
||||
return <PermissionsList ranks={ranksWithCounts} />;
|
||||
return (
|
||||
<PermissionsList
|
||||
ranks={ranksWithCounts}
|
||||
fromHousekeeping={fromHousekeeping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ interface RankSummary {
|
||||
|
||||
interface Props {
|
||||
ranks: RankSummary[];
|
||||
fromHousekeeping?: boolean;
|
||||
}
|
||||
|
||||
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)]",
|
||||
};
|
||||
|
||||
export function PermissionsList({ ranks: initialRanks }: Props) {
|
||||
export function PermissionsList({
|
||||
ranks: initialRanks,
|
||||
fromHousekeeping = false,
|
||||
}: Props) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const [ranks, setRanks] = useState(initialRanks);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RankSummary | null>(null);
|
||||
@@ -98,14 +102,23 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
|
||||
|
||||
return (
|
||||
<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
|
||||
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.
|
||||
Rank ACL here controls real admin and mod access. The Housekeeping
|
||||
table is a read-only legacy archive with an ACL compare view.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -85,18 +85,29 @@ export function AdminTicketDetail({
|
||||
staffMembers,
|
||||
currentUserId,
|
||||
canEdit,
|
||||
listHref = "/admin/tickets",
|
||||
userLinkMode = "admin",
|
||||
}: {
|
||||
ticket: TicketInfo;
|
||||
messages: TicketMessage[];
|
||||
staffMembers: StaffMember[];
|
||||
currentUserId: number;
|
||||
canEdit: boolean;
|
||||
listHref?: string;
|
||||
userLinkMode?: "admin" | "public";
|
||||
}) {
|
||||
const [messages] = useState(initialMessages);
|
||||
const [reply, setReply] = useState("");
|
||||
const { run, isPending } = useServerAction();
|
||||
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
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -152,7 +163,7 @@ export function AdminTicketDetail({
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<Link href="/admin/tickets">
|
||||
<Link href={listHref}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -382,7 +393,7 @@ export function AdminTicketDetail({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Link
|
||||
href={`/admin/users/show/${ticket.creator.id}`}
|
||||
href={creatorHref()}
|
||||
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
|
||||
>
|
||||
<User className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -29,7 +29,13 @@ const STATUS_VARIANT: Record<
|
||||
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 statusLabel: Record<string, string> = {
|
||||
@@ -51,7 +57,7 @@ export function TicketsTable({ data }: { data: PaginatedResult<TicketRow> }) {
|
||||
#{row.id}
|
||||
</span>
|
||||
<Link
|
||||
href={`/admin/tickets/${row.id}`}
|
||||
href={`${basePath}/${row.id}`}
|
||||
className="font-medium truncate hover:underline"
|
||||
>
|
||||
{row.subject}
|
||||
|
||||
Reference in new issue
Block a user