Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 43s

This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
This commit is contained in:
Simo committed 2026-08-30 21:31:34 +02:00
1 parent 488b6e57c4
commit b1ddda66ff
802 files changed
+61370 -76659

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useCallback, useMemo, useState } from "react";
import { exportPermissions } from "@/actions/admin-housekeeping";
import { Button } from "@/components/ui/button";
/* ────────── Export Section ────────── */
export function ExportSection() {
const t = useTranslations("pages.admin.housekeeping");
const [data, setData] = useState<string | null>(null);
const [copying, setCopying] = useState(false);
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(async () => {
setExporting(true);
try {
const json = await exportPermissions();
setData(json);
} finally {
setExporting(false);
}
}, []);
const handleCopy = useCallback(async () => {
if (!data) return;
setCopying(true);
try {
await navigator.clipboard.writeText(data);
} catch {
/* fallback */
}
setTimeout(() => setCopying(false), 1500);
}, [data]);
return (
<section>
<h2 className="admin-section-title">{t("exportTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("exportHint")}
</p>
<div>
<Button
type="button"
onClick={handleExport}
disabled={exporting}
variant="outline"
className="mb-3"
>
{exporting ? t("loading") : t("export")}
</Button>
{data && (
<div className="relative">
<pre className="admin-card font-mono text-xs overflow-auto max-h-[300px] p-3">
{data}
</pre>
<Button
type="button"
onClick={handleCopy}
variant="outline"
size="sm"
className="absolute top-2 right-2"
>
{copying ? t("copied") : t("copy")}
</Button>
</div>
)}
</div>
</section>
);
}
/* ────────── Audit Section ────────── */
interface AuditEntry {
id: number;
userId: number;
username: string | null;
action: string;
target: string;
targetId: number | null;
createdAt: string;
}
interface AuditSectionProps {
initialLogs: {
rows: AuditEntry[];
total: number;
page: number;
perPage: number;
lastPage: number;
} | null;
}
export function AuditSection({ initialLogs }: AuditSectionProps) {
const t = useTranslations("pages.admin.housekeeping");
const locale = useLocale();
const router = useRouter();
const dateFmt = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
dateStyle: "short",
timeStyle: "short",
}),
[locale],
);
if (!initialLogs || initialLogs.rows.length === 0) {
return (
<section>
<h2 className="admin-section-title">{t("auditTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("auditHint")}
</p>
<div className="admin-card">
<p className="text-sm opacity-70">{t("auditLogsAt")}</p>
<Button
type="button"
onClick={() => router.push("/admin/logs/audit")}
variant="outline"
className="mt-2"
>
{t("viewAuditLogs")}
</Button>
</div>
</section>
);
}
return (
<section>
<h2 className="admin-section-title">{t("auditTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("auditHint")}
</p>
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th className="w-[160px]">{t("auditTime")}</th>
<th className="w-[120px]">{t("auditUser")}</th>
<th>{t("auditAction")}</th>
<th>{t("auditTarget")}</th>
</tr>
</thead>
<tbody>
{initialLogs.rows.map((log) => (
<tr key={log.id}>
<td className="text-xs whitespace-nowrap">
{dateFmt.format(new Date(log.createdAt))}
</td>
<td className="text-xs">{log.username ?? "System"}</td>
<td className="text-xs font-mono">{log.action}</td>
<td className="text-xs">
{log.target}
{log.targetId != null ? ` #${log.targetId}` : ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 text-xs opacity-60 text-center">
{t("auditLogsAt")}{" "}
<button
type="button"
onClick={() => router.push("/admin/logs/audit")}
className="underline hover:no-underline"
>
{t("viewAuditLogs")}
</button>
</div>
</section>
);
}
+363
View File
@@ -0,0 +1,363 @@
import { asc, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import {
AclModelPermission,
AclPermission,
AclRole,
db,
WebsiteHousekeepingPermissions,
} from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getAuditLogs } from "@/lib/services/audit";
import { AuditSection, ExportSection } from "./client";
export default async function AdminHousekeeping({
searchParams,
}: {
searchParams: Promise<{
tab?:
| "overview"
| "manage"
| "import"
| "ranks"
| "presets"
| "test"
| "audit"
| "compare";
group?: string;
}>;
}) {
const ctx = await getAdminContext();
if (!canAccess(ctx.permissions, PERMS.SETTINGS_VIEW, ctx.session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.housekeeping");
const sp = await searchParams;
const tab = sp.tab ?? "overview";
const allPermissions = await db
.select()
.from(WebsiteHousekeepingPermissions)
.orderBy(
asc(WebsiteHousekeepingPermissions.groupName),
asc(WebsiteHousekeepingPermissions.permission),
)
.catch((): (typeof WebsiteHousekeepingPermissions.$inferSelect)[] => []);
const ranks = allPermissions.map((p) => p.minRank);
const maxRank = ranks.length ? Math.max(...ranks) : 0;
const totalMin = ranks.reduce((sum, r) => sum + r, 0);
const groupCounts: Record<string, number> = {};
for (const p of allPermissions) {
const g: string = p.groupName ?? "Uncategorized";
groupCounts[g] = (groupCounts[g] || 0) + 1;
}
const rankGroups: Record<
string,
(typeof WebsiteHousekeepingPermissions.$inferSelect)[]
> = {};
for (const p of allPermissions) {
const rk = String(p.minRank);
if (!rankGroups[rk]) rankGroups[rk] = [];
rankGroups[rk].push(p);
}
const aclSlugs = await db
.select({ slug: AclPermission.slug })
.from(AclPermission)
.catch(() => [] as { slug: string }[]);
const aclSlugSet = new Set(aclSlugs.map((p) => p.slug));
const aclRolePerms = await db
.select({
roleSlug: AclRole.slug,
permissionSlug: AclPermission.slug,
})
.from(AclModelPermission)
.innerJoin(
AclPermission,
eq(AclModelPermission.permissionId, AclPermission.id),
)
.innerJoin(AclRole, eq(AclModelPermission.modelId, AclRole.id))
.where(eq(AclModelPermission.modelType, "Role"))
.catch((): { roleSlug: string; permissionSlug: string }[] => []);
const aclMinRankBySlug = new Map<string, number>();
for (const row of aclRolePerms) {
const roleSlug = row.roleSlug;
if (!roleSlug.startsWith("rank_")) continue;
const rank = Number(roleSlug.slice(5));
if (!Number.isFinite(rank)) continue;
const slug = row.permissionSlug;
const prev = aclMinRankBySlug.get(slug);
if (prev === undefined || rank < prev) aclMinRankBySlug.set(slug, rank);
}
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",
perPage: 25,
}).catch(() => null);
return (
<main>
<div
className="mb-4 rounded-lg border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-4 py-3 text-sm text-[var(--admin-text)]"
role="status"
>
<p className="m-0 font-semibold">{t("legacyBannerTitle")}</p>
<p className="m-0 mt-1 text-[var(--admin-text-muted)]">
{t("legacyBannerBody")}{" "}
<Link
href="/admin/permissions"
className="font-medium text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
{t("legacyBannerCta")}
</Link>
</p>
<p className="m-0 mt-2 text-xs text-[var(--admin-text-muted)]">
{t("writesRetired")}
</p>
</div>
<div className="admin-tabs mb-4">
<Link
href="/admin/housekeeping?tab=overview"
className={`admin-tab ${tab === "overview" ? "active" : ""}`}
>
{t("tabOverview")}
</Link>
<Link
href="/admin/housekeeping?tab=compare"
className={`admin-tab ${tab === "compare" ? "active" : ""}`}
>
{t("tabCompare")}
</Link>
<Link
href="/admin/housekeeping?tab=ranks"
className={`admin-tab ${tab === "ranks" ? "active" : ""}`}
>
{t("tabRanks")}
</Link>
<Link
href="/admin/housekeeping?tab=import"
className={`admin-tab ${tab === "import" ? "active" : ""}`}
>
{t("tabExportOnly")}
</Link>
<Link
href="/admin/housekeeping?tab=audit"
className={`admin-tab ${tab === "audit" ? "active" : ""}`}
>
{t("tabAudit")}
</Link>
</div>
{tab === "overview" && (
<>
<div className="admin-stat-row">
<span className="admin-stat-chip">
{t("permissionsCount", { count: allPermissions.length })}
</span>
<span className="admin-stat-chip">
{t("highestMinRank", { rank: maxRank })}
</span>
<span className="admin-stat-chip">
{t("totalMinRank", { total: totalMin })}
</span>
<span className="admin-stat-chip">
{t("groupCount", { count: Object.keys(groupCounts).length })}
</span>
<span className="admin-stat-chip">
{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>
</div>
<section className="mb-6">
<h2 className="admin-section-title">{t("perGroup")}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(groupCounts)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, count]) => (
<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>
</div>
))}
</div>
</section>
<section>
<h2 className="admin-section-title">{t("perRank")}</h2>
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("rank")}</th>
<th>{t("count")}</th>
<th>{t("permissions")}</th>
</tr>
</thead>
<tbody>
{Object.entries(rankGroups)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([rank, perms]) => (
<tr key={rank}>
<td className="font-semibold">Rank {rank}</td>
<td>{perms.length}</td>
<td className="text-xs opacity-70">
{perms.map((p) => p.permission).join(", ")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
)}
{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" && (
<div className="mt-2">
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
{t("exportOnlyHint")}
</p>
<ExportSection />
</div>
)}
{tab === "ranks" && (
<section>
<h2 className="admin-section-title">{t("perRank")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("rankOverviewHint")}
</p>
{Object.keys(rankGroups).length === 0 ? (
<div className="admin-empty">{t("noPermissions")}</div>
) : (
<div className="space-y-4">
{Object.entries(rankGroups)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([rank, perms]) => {
const groupsInRank: Record<string, typeof perms> = {};
for (const p of perms) {
const g = p.groupName ?? "Uncategorized";
if (!groupsInRank[g]) groupsInRank[g] = [];
groupsInRank[g].push(p);
}
return (
<div key={rank} className="admin-card">
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-bold">
{t("rank")} {rank}
</h3>
<span className="text-sm opacity-60">
{perms.length} {t("permissions")}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{Object.entries(groupsInRank)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, groupPerms]) => (
<div key={group}>
<div className="text-xs font-semibold uppercase tracking-wider opacity-60 mb-1">
{group}
</div>
<ul className="list-disc pl-4 text-xs space-y-0.5">
{groupPerms.map((p) => (
<li key={p.permission}>
{p.permission}
{p.dependsOn ? (
<span className="opacity-50 ml-1">
&rarr; {p.dependsOn}
</span>
) : null}
</li>
))}
</ul>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</section>
)}
{tab === "audit" && <AuditSection initialLogs={auditLogs} />}
</main>
);
}