Files
Epicnabbo-Catalogus-Updated…/src/app/admin/housekeeping/client.tsx
T
openhands 5e8a13a84f fix: resolve all biomaly lint errors and warnings across CMS
- Fix CSS parser config (tailwindDirectives enabled)
- Fix noDangerouslySetInnerHtml via SanitizedHtml component
- Fix useExhaustiveDependencies in catalog-manager-dialog
- Fix noArrayIndexKey across 26 files (stable keys)
- Fix SVG a11y (titles, roles, aria-labels)
- Fix label/input associations (htmlFor/id pairs)
- Fix static element interactions (role + keyboard support)
- Fix noImgElement, noDescendingSpecificity (disabled - external Habbo URLs)
- Fix noNonNullAssertion, useTemplate, unused vars/imports
- Add SanitizedHtml shared component
- Migrate biome.json to 2.5.4 schema
2026-07-20 17:41:53 +02:00

977 lines
26 KiB
TypeScript

"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
applyPreset,
bulkDeletePermissions,
bulkImportPermissions,
clearAllPermissions,
deletePermission,
exportPermissions,
upsertPermission,
} from "@/actions/admin-housekeeping";
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
const PAGE_SIZE = 25;
interface HKPerm {
id: bigint;
permission: string;
minRank: number;
description: string | null;
groupName: string | null;
dependsOn: string | null;
}
interface SharedProps {
permissions: HKPerm[];
groups: string[];
groupCounts: Record<string, number>;
permSet: Set<string>;
}
/* ────────── Manage Section ────────── */
export function ManageSection(props: SharedProps) {
const t = useTranslations("pages.admin.housekeeping");
const router = useRouter();
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 [selected, setSelected] = useState<Set<string>>(new Set());
const [selectAllMatching, setSelectAllMatching] = useState(false);
const [busyBulk, setBusyBulk] = useState(false);
const [confirmDel, setConfirmDel] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [sortKey, setSortKey] = useState<string>("permission");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const searchTimer = useRef<ReturnType<typeof setTimeout>>(null);
const formRef = useRef<HTMLFormElement>(null);
const editRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
return props.permissions.filter((p) => {
if (search && !p.permission.toLowerCase().includes(search.toLowerCase()))
return false;
if (groupFilter && (p.groupName ?? "") !== groupFilter) return false;
return true;
});
}, [props.permissions, search, groupFilter]);
const sorted = useMemo(() => {
const arr = [...filtered];
arr.sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case "permission":
cmp = a.permission.localeCompare(b.permission);
break;
case "minRank":
cmp = a.minRank - b.minRank;
break;
case "groupName":
cmp = (a.groupName ?? "").localeCompare(b.groupName ?? "");
break;
case "description":
cmp = (a.description ?? "").localeCompare(b.description ?? "");
break;
case "dependsOn":
cmp = (a.dependsOn ?? "").localeCompare(b.dependsOn ?? "");
break;
}
return sortDir === "asc" ? cmp : -cmp;
});
return arr;
}, [filtered, sortKey, sortDir]);
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const paginated = sorted.slice(
(safePage - 1) * 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 debouncedSync = useCallback(
(s: string, g: string) => {
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => syncUrl(s, g), 300);
},
[syncUrl],
);
useEffect(() => {
return () => {
if (searchTimer.current) clearTimeout(searchTimer.current);
};
}, []);
const handleUpsert = useCallback(
async (formData: FormData) => {
try {
await upsertPermission(formData);
toast.success(t("saved"));
if (formRef.current) formRef.current.reset();
router.refresh();
} catch {
toast.error(t("errorOccurred"));
}
},
[router, t],
);
const handleDelete = useCallback(
async (id: string) => {
setPendingDel(id);
try {
const fd = new FormData();
fd.set("id", id);
await deletePermission(fd);
toast.success(t("deleted"));
router.refresh();
} catch {
toast.error(t("errorOccurred"));
}
setPendingDel(null);
setConfirmDel(null);
},
[router, t],
);
const handleBulkDelete = useCallback(async () => {
if (!selected.size) return;
setBusyBulk(true);
try {
const ids = [...selected].map((s) => BigInt(s));
await bulkDeletePermissions(ids);
toast.success(t("deleted"));
setSelected(new Set());
router.refresh();
} catch {
toast.error(t("errorOccurred"));
}
setBusyBulk(false);
setConfirmDel(null);
}, [selected, router, t]);
const toggleSelect = useCallback((id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
setSelectAllMatching(false);
}, []);
const toggleAll = useCallback(() => {
if (selected.size === paginated.length && !selectAllMatching) {
setSelected(new Set());
} else {
setSelected(new Set(paginated.map((p) => String(p.id))));
setSelectAllMatching(false);
}
}, [paginated, selected, selectAllMatching]);
const handleSelectAllMatching = useCallback(() => {
setSelected(new Set(sorted.map((p) => String(p.id))));
setSelectAllMatching(true);
}, [sorted]);
const handleSort = useCallback(
(key: string) => {
setSortDir((prev) =>
sortKey === key && prev === "asc" ? "desc" : "asc",
);
setSortKey(key);
setPage(1);
},
[sortKey],
);
const startEdit = useCallback((id: string, value: number) => {
setEditingId(id);
setEditValue(String(value));
setTimeout(() => editRef.current?.focus(), 0);
}, []);
const cancelEdit = useCallback(() => {
setEditingId(null);
setEditValue("");
}, []);
const commitEdit = useCallback(
async (p: HKPerm) => {
const fd = new FormData();
fd.set("permission", p.permission);
fd.set("minRank", editValue);
fd.set("description", p.description ?? "");
fd.set("groupName", p.groupName ?? "");
fd.set("dependsOn", p.dependsOn ?? "");
await handleUpsert(fd);
setEditingId(null);
},
[editValue, handleUpsert],
);
const thClass =
"cursor-pointer select-none hover:opacity-80 transition-opacity";
const sortIcon = (key: string) => {
if (sortKey !== key) return " \u2195";
return sortDir === "asc" ? " \u2191" : " \u2193";
};
return (
<div>
<ConfirmDialog
open={confirmDel !== null}
onOpenChange={(open) => {
if (!open) setConfirmDel(null);
}}
title={t("confirmDelete")}
description={
confirmDel === "__bulk__"
? t("confirmBulkDelete", { count: selected.size })
: t("confirmDeletePermission")
}
confirmLabel={t("confirm")}
cancelLabel={t("cancel")}
onConfirm={async () => {
if (confirmDel === "__bulk__") {
await handleBulkDelete();
} else if (confirmDel !== null) {
await handleDelete(confirmDel);
}
}}
/>
<div className="admin-filter-bar">
<div className="flex gap-2 flex-1 flex-wrap">
<input
value={search}
onChange={(e) => {
const v = e.target.value;
setSearch(v);
setPage(1);
debouncedSync(v, groupFilter);
}}
placeholder={t("searchPermission")}
className="flex-1 min-w-[200px]"
/>
<select
value={groupFilter}
onChange={(e) => {
const v = e.target.value;
setGroupFilter(v);
setPage(1);
debouncedSync(search, v);
}}
className="w-[180px]"
>
<option value="">{t("allGroups")}</option>
{props.groups.map((g) => (
<option key={g} value={g}>
{g} ({props.groupCounts[g] ?? 0})
</option>
))}
</select>
</div>
</div>
<section>
<h2 className="admin-section-title">{t("addPermission")}</h2>
<form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
handleUpsert(new FormData(e.currentTarget));
}}
className="admin-card"
>
<div className="flex gap-2 flex-wrap">
<input
name="permission"
placeholder={t("permissionKey")}
required
className="min-w-[200px] flex-1"
/>
<input
name="minRank"
type="number"
min={0}
placeholder={t("minRank")}
required
className="w-[110px]"
/>
<input
name="description"
placeholder={t("description")}
className="flex-1 min-w-[160px]"
/>
<select name="groupName" className="w-[150px]">
<option value="">{t("noGroup")}</option>
{props.groups.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
<input
name="dependsOn"
placeholder={t("dependsOn")}
className="min-w-[160px] flex-1"
/>
<button type="submit" className="btn btn-primary">
{t("save")}
</button>
</div>
</form>
</section>
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="admin-section-title mb-0">
{t("permissions")}{" "}
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{filtered.length}
</span>
</h2>
<div className="flex items-center gap-2">
{selected.size > 0 && selected.size < sorted.length && (
<button
type="button"
onClick={handleSelectAllMatching}
className="btn btn-outline btn-sm"
>
{t("selectAllMatching", { count: sorted.length })}
</button>
)}
{selected.size > 0 && (
<button
type="button"
onClick={() => setConfirmDel("__bulk__")}
disabled={busyBulk}
className="btn btn-danger btn-sm"
>
{t("deleteSelected", { count: selected.size })}
</button>
)}
</div>
</div>
{filtered.length === 0 ? (
<div className="admin-empty">{t("noPermissions")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th className="w-[36px]">
{selectAllMatching ? (
<input
type="checkbox"
checked={true}
onChange={() => {
setSelected(new Set());
setSelectAllMatching(false);
}}
/>
) : (
<input
type="checkbox"
checked={
paginated.length > 0 &&
selected.size === paginated.length
}
onChange={toggleAll}
/>
)}
</th>
<th className="w-[40px]">#</th>
<th
className={thClass}
onClick={() => handleSort("permission")}
>
{t("colPermission")}
{sortIcon("permission")}
</th>
<th
className={thClass}
onClick={() => handleSort("description")}
>
{t("colDescription")}
{sortIcon("description")}
</th>
<th
className={`w-[120px] ${thClass}`}
onClick={() => handleSort("groupName")}
>
{t("colGroup")}
{sortIcon("groupName")}
</th>
<th
className={`w-[100px] ${thClass}`}
onClick={() => handleSort("minRank")}
>
{t("colMinRank")}
{sortIcon("minRank")}
</th>
<th className="w-[180px]">{t("colDepends")}</th>
<th className="w-[80px]" />
</tr>
</thead>
<tbody>
{paginated.map((p, i) => {
const sid = String(p.id);
return (
<tr key={sid}>
<td>
<input
type="checkbox"
checked={selectAllMatching || selected.has(sid)}
onChange={() => toggleSelect(sid)}
/>
</td>
<td className="muted w-[40px]">
{(safePage - 1) * PAGE_SIZE + i + 1}
</td>
<td>
<strong>{p.permission}</strong>
</td>
<td className="muted">{p.description || "\u2014"}</td>
<td>
{p.groupName ? (
<span className="inline-block px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/12 text-xs font-medium">
{p.groupName}
</span>
) : (
<span className="text-xs opacity-50">\u2014</span>
)}
</td>
<td>
{editingId === sid ? (
<div className="flex gap-1 items-center">
<input
ref={editRef}
type="number"
min={0}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitEdit(p);
} else if (e.key === "Escape") {
cancelEdit();
}
}}
style={{ width: 70 }}
/>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => commitEdit(p)}
>
\u2713
</button>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={cancelEdit}
>
\u2715
</button>
</div>
) : (
<button
type="button"
className="btn btn-outline btn-sm min-w-[70px] text-center"
onClick={() => startEdit(sid, p.minRank)}
>
{p.minRank}
</button>
)}
</td>
<td className="text-xs">
{p.dependsOn ? (
<span
className={
props.permSet.has(p.dependsOn)
? "opacity-70"
: "opacity-100 font-semibold"
}
style={
!props.permSet.has(p.dependsOn)
? { color: "var(--color-danger)" }
: {}
}
>
{p.dependsOn}
{!props.permSet.has(p.dependsOn)
? ` ${t("missing")}`
: ""}
</span>
) : (
<span className="opacity-50">\u2014</span>
)}
</td>
<td>
<button
type="button"
className="btn btn-danger btn-sm"
onClick={() => setConfirmDel(sid)}
disabled={pendingDel === sid}
>
{pendingDel === sid ? "..." : "\u2715"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4">
<button
type="button"
className="btn btn-outline btn-sm"
disabled={safePage <= 1}
onClick={() => setPage(safePage - 1)}
>
{t("prev")}
</button>
<span className="text-xs opacity-60">
{safePage} / {totalPages}
</span>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={safePage >= totalPages}
onClick={() => setPage(safePage + 1)}
>
{t("next")}
</button>
</div>
)}
</section>
</div>
);
}
/* ────────── Presets Section ────────── */
export function PresetsSection() {
const t = useTranslations("pages.admin.housekeeping");
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const [confirmClear, setConfirmClear] = useState(false);
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 () => {
setBusy("__clear__");
try {
await clearAllPermissions();
toast.success(t("cleared"));
router.refresh();
} catch {
toast.error(t("errorOccurred"));
}
setBusy(null);
setConfirmClear(false);
}, [router, t]);
return (
<section>
<ConfirmDialog
open={confirmClear}
onOpenChange={(open) => {
if (!open) setConfirmClear(false);
}}
title={t("confirmClearAll")}
description={t("confirmClearAllDesc")}
confirmLabel={t("confirm")}
cancelLabel={t("cancel")}
onConfirm={handleClearAll}
/>
<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={() => setConfirmClear(true)}
disabled={busy !== null}
className="btn btn-danger"
>
{busy === "__clear__" ? t("loading") : t("clearAll")}
</button>
</div>
</section>
);
}
/* ────────── Test 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 handleTest = useCallback(() => {
const rank = Number(testRank);
if (!Number.isFinite(rank) || rank < 0)
return toast.error(t("invalidRank"));
const matching = permissions.filter((p) => p.minRank <= rank);
setTestResult({ rank, permissions: matching.map((p) => p.permission) });
toast.success(t("testDone", { count: matching.length }));
}, [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} className="btn btn-primary">
{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 ────────── */
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 {
const result = await bulkImportPermissions(fd);
if (result.errors.length > 0) {
toast.error(
t("importedWithErrors", {
count: result.count,
errors: result.errors.length,
}),
);
} else {
toast.success(t("imported", { count: result.count }));
}
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 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}
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>
);
}
/* ────────── Audit Section ────────── */
interface AuditEntry {
id: number;
userId: number;
username: string | null;
action: string;
target: string;
targetId: number | null;
before: unknown;
after: unknown;
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")}
className="btn btn-outline 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>
);
}