refactor(housekeeping): split client, add loading states, URL persistence, fix dead code
Local Build and Deploy / deploy (push) Failing after 48s

- Split ManageClient into ManageSection, PresetsSection, TestSection,
  ImportSection, ExportSection - each tab only renders what it needs
- Add loading/disabled states to preset, import, export, clear buttons
- Persist search and group filter via URL searchParams (survives refresh)
- Remove dead getAuditHistory export from actions
- Fix testRankPermission - remove unnecessary revalidatePath with JSON result
- Clean up unused errors/count variables in bulkImportPermissions
- Add 'loading' translation key to housekeeping section (en/nl)
This commit is contained in:
openhands committed 2026-07-17 19:28:19 +02:00
1 parent 0a350a354e
commit 70f5e37373
7 files changed
+319 -309

No files matched your search

Binary file not shown.
Binary file not shown.
+2 -17
View File
@@ -131,9 +131,6 @@ export async function bulkImportPermissions(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const raw = sanitize(String(formData.get("json") ?? ""));
const errors: string[] = [];
let count = 0;
if (!raw) return;
let parsed: unknown;
@@ -157,7 +154,6 @@ export async function bulkImportPermissions(formData: FormData): Promise<void> {
const dependsOn = sanitize(String(item.dependsOn ?? "")) || null;
if (!permission || !Number.isFinite(minRank) || minRank < 0) {
errors.push("Invalid entry: " + JSON.stringify(item));
continue;
}
@@ -180,9 +176,8 @@ export async function bulkImportPermissions(formData: FormData): Promise<void> {
updatedBy: userId ?? undefined,
},
});
count++;
} catch {
errors.push("Failed to upsert: " + permission);
// Failed to upsert
}
}
@@ -881,17 +876,7 @@ export async function testRankPermission(formData: FormData): Promise<void> {
});
const granted = perms.map((p) => p.permission);
// Result is displayed via the revalidated page
revalidatePath(
"/admin/housekeeping?tab=test&result=" +
encodeURIComponent(JSON.stringify({ rank, permissions: granted })),
);
revalidatePath("/admin/housekeeping");
}
export async function getAuditHistory(): Promise<unknown[]> {
await requirePermission(PERMS.SETTINGS_VIEW);
const { getAuditLogs } = await import("@/lib/services/audit");
const result = await getAuditLogs({ search: "housekeeping", perPage: 50 });
return result.rows;
}
+295 -244
View File
@@ -1,8 +1,8 @@
"use client";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
applyPreset,
@@ -25,34 +25,30 @@ interface HKPerm {
dependsOn: string | null;
}
interface ManageClientProps {
interface SharedProps {
permissions: HKPerm[];
groups: string[];
groupCounts: Record<string, number>;
permSet: Set<string>;
}
export function ManageClient({
permissions,
groups,
groupCounts,
permSet,
}: ManageClientProps) {
export function ManageSection(props: SharedProps) {
const t = useTranslations("pages.admin.housekeeping");
const router = useRouter();
const [search, setSearch] = useState("");
const [groupFilter, setGroupFilter] = useState("");
const sp = useSearchParams();
const [search, setSearch] = useState(sp.get("search") ?? "");
const [groupFilter, setGroupFilter] = useState(sp.get("group") ?? "");
const [page, setPage] = useState(1);
const [pendingDel, setPendingDel] = useState<string | null>(null);
const filtered = useMemo(() => {
return permissions.filter((p) => {
return props.permissions.filter((p) => {
if (search && !p.permission.toLowerCase().includes(search.toLowerCase()))
return false;
if (groupFilter && (p.groupName ?? "") !== groupFilter) return false;
return true;
});
}, [permissions, search, groupFilter]);
}, [props.permissions, search, groupFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
@@ -61,6 +57,20 @@ export function ManageClient({
safePage * PAGE_SIZE,
);
const syncUrl = useCallback(
(s: string, g: string) => {
const params = new URLSearchParams();
if (s) params.set("search", s);
if (g) params.set("group", g);
const qs = params.toString();
router.replace(
"/admin/housekeeping?tab=manage" + (qs ? "&" + qs : ""),
{ scroll: false },
);
},
[router],
);
const handleUpsert = useCallback(
async (formData: FormData) => {
try {
@@ -77,6 +87,7 @@ export function ManageClient({
const handleDelete = useCallback(
async (id: string) => {
if (!confirm(t("confirmDelete"))) return;
setPendingDel(id);
try {
const fd = new FormData();
fd.set("id", id);
@@ -86,99 +97,22 @@ export function ManageClient({
} catch {
toast.error(t("errorOccurred"));
}
setPendingDel(null);
},
[router, t],
);
const handleBulkImport = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const raw = String(fd.get("json") ?? "").trim();
if (!raw) return toast.error(t("emptyInput"));
try {
JSON.parse(raw);
} catch {
return toast.error(t("invalidJson"));
}
try {
await bulkImportPermissions(fd);
toast.success(t("imported"));
(e.currentTarget.querySelector(
"textarea",
) as HTMLTextAreaElement)!.value = "";
router.refresh();
} catch {
toast.error(t("errorOccurred"));
}
},
[router, t],
);
const handleApplyPreset = useCallback(
async (preset: string) => {
try {
const fd = new FormData();
fd.set("preset", preset);
await applyPreset(fd);
toast.success(t("presetApplied"));
router.refresh();
} catch {
toast.error(t("errorOccurred"));
}
},
[router, t],
);
const handleClearAll = useCallback(async () => {
if (!confirm(t("confirmClearAll"))) return;
try {
await clearAllPermissions();
toast.success(t("cleared"));
router.refresh();
} catch {
toast.error(t("errorOccurred"));
}
}, [router, t]);
const [testRank, setTestRank] = useState("");
const [testResult, setTestResult] = useState<{
rank: number;
permissions: string[];
} | null>(null);
const [testing, setTesting] = useState(false);
const handleTest = useCallback(async () => {
const rank = Number(testRank);
if (!Number.isFinite(rank) || rank < 0)
return toast.error(t("invalidRank"));
setTesting(true);
try {
const fd = new FormData();
fd.set("testRank", testRank);
await testRankPermission(fd);
// The action revalidates the page; we also show result client-side
const matching = permissions.filter((p) => p.minRank <= rank);
setTestResult({ rank, permissions: matching.map((p) => p.permission) });
toast.success(t("testDone", { count: matching.length }));
} catch {
toast.error(t("errorOccurred"));
}
setTesting(false);
}, [testRank, permissions, t]);
return (
<div>
{/* ── Manage tab ── */}
<div className="admin-filter-bar">
<div className="flex gap-2 flex-1 flex-wrap">
<input
value={search}
onChange={(e) => {
setSearch(e.target.value);
const v = e.target.value;
setSearch(v);
setPage(1);
syncUrl(v, groupFilter);
}}
placeholder={t("searchPermission")}
className="flex-1 min-w-[200px]"
@@ -186,15 +120,17 @@ export function ManageClient({
<select
value={groupFilter}
onChange={(e) => {
setGroupFilter(e.target.value);
const v = e.target.value;
setGroupFilter(v);
setPage(1);
syncUrl(search, v);
}}
className="w-[180px]"
>
<option value="">{t("allGroups")}</option>
{groups.map((g) => (
{props.groups.map((g) => (
<option key={g} value={g}>
{g} ({groupCounts[g] ?? 0})
{g} ({props.groupCounts[g] ?? 0})
</option>
))}
</select>
@@ -202,7 +138,9 @@ export function ManageClient({
</div>
<section>
<h2 className="admin-section-title">{t("addPermission")}</h2>
<h2 className="admin-section-title">
{t("addPermission")}
</h2>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -232,7 +170,7 @@ export function ManageClient({
/>
<select name="groupName" className="w-[150px]">
<option value="">{t("noGroup")}</option>
{groups.map((g) => (
{props.groups.map((g) => (
<option key={g} value={g}>
{g}
</option>
@@ -339,18 +277,18 @@ export function ManageClient({
{p.dependsOn ? (
<span
className={
permSet.has(p.dependsOn)
props.permSet.has(p.dependsOn)
? "opacity-70"
: "opacity-100 font-semibold"
}
style={
!permSet.has(p.dependsOn)
!props.permSet.has(p.dependsOn)
? { color: "var(--color-danger)" }
: {}
}
>
{p.dependsOn}
{!permSet.has(p.dependsOn) ? " " + t("missing") : ""}
{!props.permSet.has(p.dependsOn) ? " " + t("missing") : ""}
</span>
) : (
<span className="opacity-50">\u2014</span>
@@ -396,134 +334,240 @@ export function ManageClient({
</div>
)}
</section>
{/* ── Presets ── */}
<section className="mt-8">
<h2 className="admin-section-title">{t("presets")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("presetsHint")}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{(["moderator", "admin", "superadmin"] as const).map((preset) => {
const labels: Record<string, string> = {
moderator: t("presetModerator"),
admin: t("presetAdmin"),
superadmin: t("presetSuperAdmin"),
};
const descs: Record<string, string> = {
moderator: t("presetModeratorDesc"),
admin: t("presetAdminDesc"),
superadmin: t("presetSuperAdminDesc"),
};
return (
<div key={preset} className="admin-card">
<h3 className="font-bold text-lg mb-1">{labels[preset]}</h3>
<p className="text-xs opacity-60 mb-3">{descs[preset]}</p>
<button
type="button"
onClick={() => handleApplyPreset(preset)}
className="btn btn-primary w-full"
>
{t("applyPreset")}
</button>
</div>
);
})}
</div>
<div className="mt-4">
<button
type="button"
onClick={handleClearAll}
className="btn btn-danger"
>
{t("clearAll")}
</button>
</div>
</section>
{/* ── Test ── */}
<section className="mt-8">
<h2 className="admin-section-title">{t("testTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("testHint")}
</p>
<div className="admin-card flex gap-2 flex-wrap">
<input
value={testRank}
onChange={(e) => setTestRank(e.target.value)}
type="number"
min={0}
placeholder={t("rank")}
className="w-[150px]"
/>
<button
type="button"
onClick={handleTest}
disabled={testing}
className="btn btn-primary"
>
{testing ? t("testing") : t("test")}
</button>
</div>
{testResult && (
<div className="admin-card mt-3">
<div className="font-semibold mb-2">
{t("rank")} {testResult.rank}: {testResult.permissions.length}{" "}
{t("permissions")}
</div>
<div className="max-h-48 overflow-y-auto text-xs space-y-0.5">
{testResult.permissions.map((p) => (
<div key={p} className="font-mono">
{p}
</div>
))}
</div>
</div>
)}
</section>
{/* ── Import ── */}
<section className="mt-8">
<h2 className="admin-section-title">{t("importTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("importHint")}
</p>
<form onSubmit={handleBulkImport} className="admin-card">
<div className="flex flex-col gap-2">
<textarea
name="json"
placeholder={t("importPlaceholder")}
required
rows={6}
className="w-full font-mono text-sm"
/>
<button type="submit" className="btn btn-primary self-start">
{t("import")}
</button>
</div>
</form>
</section>
{/* ── Export ── */}
<section className="mt-8">
<h2 className="admin-section-title">{t("exportTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("exportHint")}
</p>
<ExportClient />
</section>
</div>
);
}
function ExportClient() {
export function PresetsSection() {
const t = useTranslations("pages.admin.housekeeping");
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const handleApplyPreset = useCallback(
async (preset: string) => {
setBusy(preset);
try {
const fd = new FormData();
fd.set("preset", preset);
await applyPreset(fd);
toast.success(t("presetApplied"));
router.refresh();
} catch {
toast.error(t("errorOccurred"));
}
setBusy(null);
},
[router, t],
);
const handleClearAll = useCallback(async () => {
if (!confirm(t("confirmClearAll"))) return;
setBusy("__clear__");
try {
await clearAllPermissions();
toast.success(t("cleared"));
router.refresh();
} catch {
toast.error(t("errorOccurred"));
}
setBusy(null);
}, [router, t]);
return (
<section>
<h2 className="admin-section-title">{t("presets")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("presetsHint")}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{(["moderator", "admin", "superadmin"] as const).map((preset) => {
const labels: Record<string, string> = {
moderator: t("presetModerator"),
admin: t("presetAdmin"),
superadmin: t("presetSuperAdmin"),
};
const descs: Record<string, string> = {
moderator: t("presetModeratorDesc"),
admin: t("presetAdminDesc"),
superadmin: t("presetSuperAdminDesc"),
};
const loading = busy === preset;
return (
<div key={preset} className="admin-card">
<h3 className="font-bold text-lg mb-1">{labels[preset]}</h3>
<p className="text-xs opacity-60 mb-3">{descs[preset]}</p>
<button
type="button"
onClick={() => handleApplyPreset(preset)}
disabled={loading || busy === "__clear__"}
className="btn btn-primary w-full"
>
{loading ? t("loading") : t("applyPreset")}
</button>
</div>
);
})}
</div>
<div className="mt-4">
<button
type="button"
onClick={handleClearAll}
disabled={busy !== null}
className="btn btn-danger"
>
{busy === "__clear__" ? t("loading") : t("clearAll")}
</button>
</div>
</section>
);
}
export function TestSection({ permissions }: { permissions: HKPerm[] }) {
const t = useTranslations("pages.admin.housekeeping");
const [testRank, setTestRank] = useState("");
const [testResult, setTestResult] = useState<{
rank: number;
permissions: string[];
} | null>(null);
const [testing, setTesting] = useState(false);
const handleTest = useCallback(async () => {
const rank = Number(testRank);
if (!Number.isFinite(rank) || rank < 0)
return toast.error(t("invalidRank"));
setTesting(true);
try {
const fd = new FormData();
fd.set("testRank", testRank);
await testRankPermission(fd);
const matching = permissions.filter((p) => p.minRank <= rank);
setTestResult({ rank, permissions: matching.map((p) => p.permission) });
toast.success(t("testDone", { count: matching.length }));
} catch {
toast.error(t("errorOccurred"));
}
setTesting(false);
}, [testRank, permissions, t]);
return (
<section>
<h2 className="admin-section-title">{t("testTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("testHint")}
</p>
<div className="admin-card flex gap-2 flex-wrap">
<input
value={testRank}
onChange={(e) => setTestRank(e.target.value)}
type="number"
min={0}
placeholder={t("rank")}
className="w-[150px]"
/>
<button
type="button"
onClick={handleTest}
disabled={testing}
className="btn btn-primary"
>
{testing ? t("testing") : t("test")}
</button>
</div>
{testResult && (
<div className="admin-card mt-3">
<div className="font-semibold mb-2">
{t("rank")} {testResult.rank}: {testResult.permissions.length}{" "}
{t("permissions")}
</div>
<div className="max-h-48 overflow-y-auto text-xs space-y-0.5">
{testResult.permissions.map((p) => (
<div key={p} className="font-mono">
{p}
</div>
))}
</div>
</div>
)}
</section>
);
}
export function ImportSection() {
const t = useTranslations("pages.admin.housekeeping");
const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [importing, setImporting] = useState(false);
const handleBulkImport = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const raw = String(fd.get("json") ?? "").trim();
if (!raw) return toast.error(t("emptyInput"));
try {
JSON.parse(raw);
} catch {
return toast.error(t("invalidJson"));
}
setImporting(true);
try {
await bulkImportPermissions(fd);
toast.success(t("imported"));
if (textareaRef.current) textareaRef.current.value = "";
router.refresh();
} catch {
toast.error(t("errorOccurred"));
}
setImporting(false);
},
[router, t],
);
return (
<section>
<h2 className="admin-section-title">{t("importTitle")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("importHint")}
</p>
<form onSubmit={handleBulkImport} className="admin-card">
<div className="flex flex-col gap-2">
<textarea
ref={textareaRef}
name="json"
placeholder={t("importPlaceholder")}
required
rows={6}
className="w-full font-mono text-sm"
/>
<button
type="submit"
disabled={importing}
className="btn btn-primary self-start"
>
{importing ? t("loading") : t("import")}
</button>
</div>
</form>
</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 () => {
const json = await exportPermissions();
setData(json);
setExporting(true);
try {
const json = await exportPermissions();
setData(json);
} finally {
setExporting(false);
}
}, []);
const handleCopy = useCallback(async () => {
@@ -538,28 +582,35 @@ function ExportClient() {
}, [data]);
return (
<div>
<button
type="button"
onClick={handleExport}
className="btn btn-outline mb-3"
>
{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}
className="btn btn-outline btn-sm absolute top-2 right-2"
>
{copying ? t("copied") : t("copy")}
</button>
</div>
)}
</div>
<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}
className="btn btn-outline 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}
className="btn btn-outline btn-sm absolute top-2 right-2"
>
{copying ? t("copied") : t("copy")}
</button>
</div>
)}
</div>
</section>
);
}
+20 -48
View File
@@ -3,35 +3,21 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { ManageClient } from "./client";
import {
ExportSection,
ImportSection,
ManageSection,
PresetsSection,
TestSection,
} 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",
"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({
@@ -241,7 +227,7 @@ export default async function AdminHousekeeping({
)}
{tab === "manage" && (
<ManageClient
<ManageSection
permissions={allPermissions}
groups={allGroups}
groupCounts={groupCounts}
@@ -250,31 +236,17 @@ export default async function AdminHousekeeping({
)}
{tab === "import" && (
<ManageClient
permissions={allPermissions}
groups={allGroups}
groupCounts={groupCounts}
permSet={permSet}
/>
<>
<ImportSection />
<div className="mt-8">
<ExportSection />
</div>
</>
)}
{tab === "presets" && (
<ManageClient
permissions={allPermissions}
groups={allGroups}
groupCounts={groupCounts}
permSet={permSet}
/>
)}
{tab === "presets" && <PresetsSection />}
{tab === "test" && (
<ManageClient
permissions={allPermissions}
groups={allGroups}
groupCounts={groupCounts}
permSet={permSet}
/>
)}
{tab === "test" && <TestSection permissions={allPermissions} />}
{tab === "ranks" && (
<section>
+1
View File
@@ -1881,6 +1881,7 @@
"invalidRank": "Invalid rank",
"testDone": "Test done: {count} permissions found",
"testing": "Testing...",
"loading": "Loading...",
"prev": "Previous",
"next": "Next",
"form": {
+1
View File
@@ -1881,6 +1881,7 @@
"invalidRank": "Ongeldige rang",
"testDone": "Test voltooid: {count} permissies gevonden",
"testing": "Testen...",
"loading": "Laden...",
"prev": "Vorige",
"next": "Volgende",
"form": {