refactor(housekeeping): split client, add loading states, URL persistence, fix dead code
Local Build and Deploy / deploy (push) Failing after 48s
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:
1 parent
0a350a354e
commit
70f5e37373
7 files changed
+319
-309
No files matched your search
Binary file not shown.
Binary file not shown.
@@ -131,9 +131,6 @@ export async function bulkImportPermissions(formData: FormData): Promise<void> {
|
|||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
|
||||||
const raw = sanitize(String(formData.get("json") ?? ""));
|
const raw = sanitize(String(formData.get("json") ?? ""));
|
||||||
const errors: string[] = [];
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
@@ -157,7 +154,6 @@ export async function bulkImportPermissions(formData: FormData): Promise<void> {
|
|||||||
const dependsOn = sanitize(String(item.dependsOn ?? "")) || null;
|
const dependsOn = sanitize(String(item.dependsOn ?? "")) || null;
|
||||||
|
|
||||||
if (!permission || !Number.isFinite(minRank) || minRank < 0) {
|
if (!permission || !Number.isFinite(minRank) || minRank < 0) {
|
||||||
errors.push("Invalid entry: " + JSON.stringify(item));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,9 +176,8 @@ export async function bulkImportPermissions(formData: FormData): Promise<void> {
|
|||||||
updatedBy: userId ?? undefined,
|
updatedBy: userId ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
count++;
|
|
||||||
} catch {
|
} 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);
|
const granted = perms.map((p) => p.permission);
|
||||||
// Result is displayed via the revalidated page
|
revalidatePath("/admin/housekeeping");
|
||||||
revalidatePath(
|
|
||||||
"/admin/housekeeping?tab=test&result=" +
|
|
||||||
encodeURIComponent(JSON.stringify({ rank, permissions: granted })),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
applyPreset,
|
applyPreset,
|
||||||
@@ -25,34 +25,30 @@ interface HKPerm {
|
|||||||
dependsOn: string | null;
|
dependsOn: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ManageClientProps {
|
interface SharedProps {
|
||||||
permissions: HKPerm[];
|
permissions: HKPerm[];
|
||||||
groups: string[];
|
groups: string[];
|
||||||
groupCounts: Record<string, number>;
|
groupCounts: Record<string, number>;
|
||||||
permSet: Set<string>;
|
permSet: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManageClient({
|
export function ManageSection(props: SharedProps) {
|
||||||
permissions,
|
|
||||||
groups,
|
|
||||||
groupCounts,
|
|
||||||
permSet,
|
|
||||||
}: ManageClientProps) {
|
|
||||||
const t = useTranslations("pages.admin.housekeeping");
|
const t = useTranslations("pages.admin.housekeeping");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [search, setSearch] = useState("");
|
const sp = useSearchParams();
|
||||||
const [groupFilter, setGroupFilter] = useState("");
|
const [search, setSearch] = useState(sp.get("search") ?? "");
|
||||||
|
const [groupFilter, setGroupFilter] = useState(sp.get("group") ?? "");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pendingDel, setPendingDel] = useState<string | null>(null);
|
const [pendingDel, setPendingDel] = useState<string | null>(null);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return permissions.filter((p) => {
|
return props.permissions.filter((p) => {
|
||||||
if (search && !p.permission.toLowerCase().includes(search.toLowerCase()))
|
if (search && !p.permission.toLowerCase().includes(search.toLowerCase()))
|
||||||
return false;
|
return false;
|
||||||
if (groupFilter && (p.groupName ?? "") !== groupFilter) return false;
|
if (groupFilter && (p.groupName ?? "") !== groupFilter) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [permissions, search, groupFilter]);
|
}, [props.permissions, search, groupFilter]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
const safePage = Math.min(page, totalPages);
|
const safePage = Math.min(page, totalPages);
|
||||||
@@ -61,6 +57,20 @@ export function ManageClient({
|
|||||||
safePage * PAGE_SIZE,
|
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(
|
const handleUpsert = useCallback(
|
||||||
async (formData: FormData) => {
|
async (formData: FormData) => {
|
||||||
try {
|
try {
|
||||||
@@ -77,6 +87,7 @@ export function ManageClient({
|
|||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
if (!confirm(t("confirmDelete"))) return;
|
if (!confirm(t("confirmDelete"))) return;
|
||||||
|
setPendingDel(id);
|
||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.set("id", id);
|
fd.set("id", id);
|
||||||
@@ -86,99 +97,22 @@ export function ManageClient({
|
|||||||
} catch {
|
} catch {
|
||||||
toast.error(t("errorOccurred"));
|
toast.error(t("errorOccurred"));
|
||||||
}
|
}
|
||||||
|
setPendingDel(null);
|
||||||
},
|
},
|
||||||
[router, t],
|
[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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* ── Manage tab ── */}
|
|
||||||
<div className="admin-filter-bar">
|
<div className="admin-filter-bar">
|
||||||
<div className="flex gap-2 flex-1 flex-wrap">
|
<div className="flex gap-2 flex-1 flex-wrap">
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value);
|
const v = e.target.value;
|
||||||
|
setSearch(v);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
syncUrl(v, groupFilter);
|
||||||
}}
|
}}
|
||||||
placeholder={t("searchPermission")}
|
placeholder={t("searchPermission")}
|
||||||
className="flex-1 min-w-[200px]"
|
className="flex-1 min-w-[200px]"
|
||||||
@@ -186,15 +120,17 @@ export function ManageClient({
|
|||||||
<select
|
<select
|
||||||
value={groupFilter}
|
value={groupFilter}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setGroupFilter(e.target.value);
|
const v = e.target.value;
|
||||||
|
setGroupFilter(v);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
syncUrl(search, v);
|
||||||
}}
|
}}
|
||||||
className="w-[180px]"
|
className="w-[180px]"
|
||||||
>
|
>
|
||||||
<option value="">{t("allGroups")}</option>
|
<option value="">{t("allGroups")}</option>
|
||||||
{groups.map((g) => (
|
{props.groups.map((g) => (
|
||||||
<option key={g} value={g}>
|
<option key={g} value={g}>
|
||||||
{g} ({groupCounts[g] ?? 0})
|
{g} ({props.groupCounts[g] ?? 0})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -202,7 +138,9 @@ export function ManageClient({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="admin-section-title">{t("addPermission")}</h2>
|
<h2 className="admin-section-title">
|
||||||
|
{t("addPermission")}
|
||||||
|
</h2>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -232,7 +170,7 @@ export function ManageClient({
|
|||||||
/>
|
/>
|
||||||
<select name="groupName" className="w-[150px]">
|
<select name="groupName" className="w-[150px]">
|
||||||
<option value="">{t("noGroup")}</option>
|
<option value="">{t("noGroup")}</option>
|
||||||
{groups.map((g) => (
|
{props.groups.map((g) => (
|
||||||
<option key={g} value={g}>
|
<option key={g} value={g}>
|
||||||
{g}
|
{g}
|
||||||
</option>
|
</option>
|
||||||
@@ -339,18 +277,18 @@ export function ManageClient({
|
|||||||
{p.dependsOn ? (
|
{p.dependsOn ? (
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
permSet.has(p.dependsOn)
|
props.permSet.has(p.dependsOn)
|
||||||
? "opacity-70"
|
? "opacity-70"
|
||||||
: "opacity-100 font-semibold"
|
: "opacity-100 font-semibold"
|
||||||
}
|
}
|
||||||
style={
|
style={
|
||||||
!permSet.has(p.dependsOn)
|
!props.permSet.has(p.dependsOn)
|
||||||
? { color: "var(--color-danger)" }
|
? { color: "var(--color-danger)" }
|
||||||
: {}
|
: {}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{p.dependsOn}
|
{p.dependsOn}
|
||||||
{!permSet.has(p.dependsOn) ? " " + t("missing") : ""}
|
{!props.permSet.has(p.dependsOn) ? " " + t("missing") : ""}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="opacity-50">\u2014</span>
|
<span className="opacity-50">\u2014</span>
|
||||||
@@ -396,134 +334,240 @@ export function ManageClient({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</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 t = useTranslations("pages.admin.housekeeping");
|
||||||
const [data, setData] = useState<string | null>(null);
|
const [data, setData] = useState<string | null>(null);
|
||||||
const [copying, setCopying] = useState(false);
|
const [copying, setCopying] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
const json = await exportPermissions();
|
setExporting(true);
|
||||||
setData(json);
|
try {
|
||||||
|
const json = await exportPermissions();
|
||||||
|
setData(json);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
@@ -538,28 +582,35 @@ function ExportClient() {
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<section>
|
||||||
<button
|
<h2 className="admin-section-title">{t("exportTitle")}</h2>
|
||||||
type="button"
|
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
||||||
onClick={handleExport}
|
{t("exportHint")}
|
||||||
className="btn btn-outline mb-3"
|
</p>
|
||||||
>
|
<div>
|
||||||
{t("export")}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
{data && (
|
onClick={handleExport}
|
||||||
<div className="relative">
|
disabled={exporting}
|
||||||
<pre className="admin-card font-mono text-xs overflow-auto max-h-[300px] p-3">
|
className="btn btn-outline mb-3"
|
||||||
{data}
|
>
|
||||||
</pre>
|
{exporting ? t("loading") : t("export")}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
{data && (
|
||||||
onClick={handleCopy}
|
<div className="relative">
|
||||||
className="btn btn-outline btn-sm absolute top-2 right-2"
|
<pre className="admin-card font-mono text-xs overflow-auto max-h-[300px] p-3">
|
||||||
>
|
{data}
|
||||||
{copying ? t("copied") : t("copy")}
|
</pre>
|
||||||
</button>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
)}
|
onClick={handleCopy}
|
||||||
</div>
|
className="btn btn-outline btn-sm absolute top-2 right-2"
|
||||||
|
>
|
||||||
|
{copying ? t("copied") : t("copy")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,35 +3,21 @@ import { redirect } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
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 { ManageClient } from "./client";
|
import {
|
||||||
|
ExportSection,
|
||||||
|
ImportSection,
|
||||||
|
ManageSection,
|
||||||
|
PresetsSection,
|
||||||
|
TestSection,
|
||||||
|
} from "./client";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const GROUPS = [
|
const GROUPS = [
|
||||||
"Core",
|
"Core", "Users", "Rooms", "Bans", "News", "Logs", "Settings",
|
||||||
"Users",
|
"Wordfilter", "Catalog", "Shop", "Events", "Tickets", "Moderation",
|
||||||
"Rooms",
|
"Polls", "Analytics", "DevOps", "Prefixes", "Banners", "Radio",
|
||||||
"Bans",
|
"RCON", "Assets", "Export", "Notifications", "CMS Pages",
|
||||||
"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({
|
||||||
@@ -241,7 +227,7 @@ export default async function AdminHousekeeping({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "manage" && (
|
{tab === "manage" && (
|
||||||
<ManageClient
|
<ManageSection
|
||||||
permissions={allPermissions}
|
permissions={allPermissions}
|
||||||
groups={allGroups}
|
groups={allGroups}
|
||||||
groupCounts={groupCounts}
|
groupCounts={groupCounts}
|
||||||
@@ -250,31 +236,17 @@ export default async function AdminHousekeeping({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "import" && (
|
{tab === "import" && (
|
||||||
<ManageClient
|
<>
|
||||||
permissions={allPermissions}
|
<ImportSection />
|
||||||
groups={allGroups}
|
<div className="mt-8">
|
||||||
groupCounts={groupCounts}
|
<ExportSection />
|
||||||
permSet={permSet}
|
</div>
|
||||||
/>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "presets" && (
|
{tab === "presets" && <PresetsSection />}
|
||||||
<ManageClient
|
|
||||||
permissions={allPermissions}
|
|
||||||
groups={allGroups}
|
|
||||||
groupCounts={groupCounts}
|
|
||||||
permSet={permSet}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "test" && (
|
{tab === "test" && <TestSection permissions={allPermissions} />}
|
||||||
<ManageClient
|
|
||||||
permissions={allPermissions}
|
|
||||||
groups={allGroups}
|
|
||||||
groupCounts={groupCounts}
|
|
||||||
permSet={permSet}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "ranks" && (
|
{tab === "ranks" && (
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -1881,6 +1881,7 @@
|
|||||||
"invalidRank": "Invalid rank",
|
"invalidRank": "Invalid rank",
|
||||||
"testDone": "Test done: {count} permissions found",
|
"testDone": "Test done: {count} permissions found",
|
||||||
"testing": "Testing...",
|
"testing": "Testing...",
|
||||||
|
"loading": "Loading...",
|
||||||
"prev": "Previous",
|
"prev": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"form": {
|
"form": {
|
||||||
|
|||||||
@@ -1881,6 +1881,7 @@
|
|||||||
"invalidRank": "Ongeldige rang",
|
"invalidRank": "Ongeldige rang",
|
||||||
"testDone": "Test voltooid: {count} permissies gevonden",
|
"testDone": "Test voltooid: {count} permissies gevonden",
|
||||||
"testing": "Testen...",
|
"testing": "Testen...",
|
||||||
|
"loading": "Laden...",
|
||||||
"prev": "Vorige",
|
"prev": "Vorige",
|
||||||
"next": "Volgende",
|
"next": "Volgende",
|
||||||
"form": {
|
"form": {
|
||||||
|
|||||||
Reference in new issue
Block a user