fix(admin): add client-side search, pagination, toast feedback, confirm dialogs and test result display
Local Build and Deploy / deploy (push) Successful in 1m21s
Local Build and Deploy / deploy (push) Successful in 1m21s
- Rewrite manage/import/presets/test tabs as client component with sonner toasts - Add client-side search filtering (no page reload) - Add pagination (25 per page) - Add confirm dialog before delete and clear-all - Show test results inline after submission - Add toast feedback for import, preset apply, save, delete - Add prev/next pagination labels
This commit is contained in:
1 parent
98d4b715df
commit
6ba8928791
4 files changed
+580
-342
No files matched your search
@@ -1,29 +1,541 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
applyPreset,
|
||||||
|
bulkImportPermissions,
|
||||||
|
clearAllPermissions,
|
||||||
|
deletePermission,
|
||||||
|
exportPermissions,
|
||||||
|
testRankPermission,
|
||||||
|
upsertPermission,
|
||||||
|
} from "@/actions/admin-housekeeping";
|
||||||
|
|
||||||
export function HousekeepingClient() {
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
interface HKPerm {
|
||||||
|
id: bigint;
|
||||||
|
permission: string;
|
||||||
|
minRank: number;
|
||||||
|
description: string | null;
|
||||||
|
groupName: string | null;
|
||||||
|
dependsOn: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManageClientProps {
|
||||||
|
permissions: HKPerm[];
|
||||||
|
groups: string[];
|
||||||
|
groupCounts: Record<string, number>;
|
||||||
|
permSet: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManageClient({
|
||||||
|
permissions,
|
||||||
|
groups,
|
||||||
|
groupCounts,
|
||||||
|
permSet,
|
||||||
|
}: ManageClientProps) {
|
||||||
const t = useTranslations("pages.admin.housekeeping");
|
const t = useTranslations("pages.admin.housekeeping");
|
||||||
const [exportData, setExportData] = useState<string | null>(null);
|
const router = useRouter();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [groupFilter, setGroupFilter] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pendingDel, setPendingDel] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return 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]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const paginated = filtered.slice(
|
||||||
|
(safePage - 1) * PAGE_SIZE,
|
||||||
|
safePage * PAGE_SIZE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpsert = useCallback(
|
||||||
|
async (formData: FormData) => {
|
||||||
|
try {
|
||||||
|
await upsertPermission(formData);
|
||||||
|
toast.success(t("saved"));
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error(t("errorOccurred"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (!confirm(t("confirmDelete"))) return;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("id", id);
|
||||||
|
await deletePermission(fd);
|
||||||
|
toast.success(t("deleted"));
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error(t("errorOccurred"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder={t("searchPermission")}
|
||||||
|
className="flex-1 min-w-[200px]"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={groupFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setGroupFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="w-[180px]"
|
||||||
|
>
|
||||||
|
<option value="">{t("allGroups")}</option>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<option key={g} value={g}>
|
||||||
|
{g} ({groupCounts[g] ?? 0})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="admin-section-title">{t("addPermission")}</h2>
|
||||||
|
<form
|
||||||
|
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>
|
||||||
|
{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>
|
||||||
|
<h2 className="admin-section-title">
|
||||||
|
{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>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="admin-empty">{t("noPermissions")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="w-[40px]">#</th>
|
||||||
|
<th>{t("colPermission")}</th>
|
||||||
|
<th>{t("colDescription")}</th>
|
||||||
|
<th className="w-[120px]">{t("colGroup")}</th>
|
||||||
|
<th className="w-[100px]">{t("colMinRank")}</th>
|
||||||
|
<th className="w-[180px]">{t("colDepends")}</th>
|
||||||
|
<th className="w-[80px]" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginated.map((p, i) => (
|
||||||
|
<tr key={String(p.id)}>
|
||||||
|
<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>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUpsert(new FormData(e.currentTarget));
|
||||||
|
}}
|
||||||
|
style={{ display: "flex", gap: "0.5rem" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="permission"
|
||||||
|
value={p.permission}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="description"
|
||||||
|
value={p.description ?? ""}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="groupName"
|
||||||
|
value={p.groupName ?? ""}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="dependsOn"
|
||||||
|
value={p.dependsOn ?? ""}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="minRank"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
defaultValue={p.minRank}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
>
|
||||||
|
{t("save")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td className="text-xs">
|
||||||
|
{p.dependsOn ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
permSet.has(p.dependsOn)
|
||||||
|
? "opacity-70"
|
||||||
|
: "opacity-100 font-semibold"
|
||||||
|
}
|
||||||
|
style={
|
||||||
|
!permSet.has(p.dependsOn)
|
||||||
|
? { color: "var(--color-danger)" }
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p.dependsOn}
|
||||||
|
{!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={() => handleDelete(String(p.id))}
|
||||||
|
disabled={pendingDel === String(p.id)}
|
||||||
|
>
|
||||||
|
{pendingDel === String(p.id) ? "..." : "\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>
|
||||||
|
|
||||||
|
{/* ── 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() {
|
||||||
|
const t = useTranslations("pages.admin.housekeeping");
|
||||||
|
const [data, setData] = useState<string | null>(null);
|
||||||
const [copying, setCopying] = useState(false);
|
const [copying, setCopying] = useState(false);
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
const { exportPermissions } = await import("@/actions/admin-housekeeping");
|
const json = await exportPermissions();
|
||||||
const data = await exportPermissions();
|
setData(json);
|
||||||
setExportData(data);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
if (!exportData) return;
|
if (!data) return;
|
||||||
setCopying(true);
|
setCopying(true);
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(exportData);
|
await navigator.clipboard.writeText(data);
|
||||||
} catch {
|
} catch {
|
||||||
// fallback
|
/* fallback */
|
||||||
}
|
}
|
||||||
setTimeout(() => setCopying(false), 1500);
|
setTimeout(() => setCopying(false), 1500);
|
||||||
}, [exportData]);
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -34,10 +546,10 @@ export function HousekeepingClient() {
|
|||||||
>
|
>
|
||||||
{t("export")}
|
{t("export")}
|
||||||
</button>
|
</button>
|
||||||
{exportData && (
|
{data && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<pre className="admin-card font-mono text-xs overflow-auto max-h-[300px] p-3">
|
<pre className="admin-card font-mono text-xs overflow-auto max-h-[300px] p-3">
|
||||||
{exportData}
|
{data}
|
||||||
</pre>
|
</pre>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
|
||||||
applyPreset,
|
|
||||||
bulkImportPermissions,
|
|
||||||
clearAllPermissions,
|
|
||||||
deletePermission,
|
|
||||||
testRankPermission,
|
|
||||||
upsertPermission,
|
|
||||||
} from "@/actions/admin-housekeeping";
|
|
||||||
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 { HousekeepingClient } from "./client";
|
import { ManageClient } from "./client";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -46,8 +38,6 @@ export default async function AdminHousekeeping({
|
|||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
q?: string;
|
|
||||||
mode?: string;
|
|
||||||
tab?:
|
tab?:
|
||||||
| "overview"
|
| "overview"
|
||||||
| "manage"
|
| "manage"
|
||||||
@@ -66,9 +56,7 @@ export default async function AdminHousekeeping({
|
|||||||
|
|
||||||
const t = await getTranslations("pages.admin.housekeeping");
|
const t = await getTranslations("pages.admin.housekeeping");
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const q = (sp.q ?? "").trim();
|
|
||||||
const tab = sp.tab ?? "overview";
|
const tab = sp.tab ?? "overview";
|
||||||
const groupFilter = (sp.group ?? "").trim();
|
|
||||||
|
|
||||||
type HKPerm = Awaited<
|
type HKPerm = Awaited<
|
||||||
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
|
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
|
||||||
@@ -77,13 +65,6 @@ export default async function AdminHousekeeping({
|
|||||||
.findMany({ orderBy: [{ groupName: "asc" }, { permission: "asc" }] })
|
.findMany({ orderBy: [{ groupName: "asc" }, { permission: "asc" }] })
|
||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
|
|
||||||
const filtered = allPermissions.filter((p) => {
|
|
||||||
if (q && !p.permission.toLowerCase().includes(q.toLowerCase()))
|
|
||||||
return false;
|
|
||||||
if (groupFilter && (p.groupName ?? "") !== groupFilter) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const ranks = allPermissions.map((p) => p.minRank);
|
const ranks = allPermissions.map((p) => p.minRank);
|
||||||
const maxRank = ranks.length ? Math.max(...ranks) : 0;
|
const maxRank = ranks.length ? Math.max(...ranks) : 0;
|
||||||
const totalMin = ranks.reduce((sum, r) => sum + r, 0);
|
const totalMin = ranks.reduce((sum, r) => sum + r, 0);
|
||||||
@@ -108,6 +89,8 @@ export default async function AdminHousekeeping({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allGroups = [...new Set([...GROUPS, ...Object.keys(groupCounts)])];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<div className="admin-tabs mb-4">
|
<div className="admin-tabs mb-4">
|
||||||
@@ -258,242 +241,39 @@ export default async function AdminHousekeeping({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "manage" && (
|
{tab === "manage" && (
|
||||||
<>
|
<ManageClient
|
||||||
<div className="admin-filter-bar">
|
permissions={allPermissions}
|
||||||
<form className="flex gap-2 flex-1 flex-wrap">
|
groups={allGroups}
|
||||||
<input
|
groupCounts={groupCounts}
|
||||||
name="q"
|
permSet={permSet}
|
||||||
defaultValue={q}
|
/>
|
||||||
placeholder={t("searchPermission")}
|
|
||||||
className="flex-1 min-w-[200px]"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
name="group"
|
|
||||||
defaultValue={groupFilter}
|
|
||||||
className="w-[180px]"
|
|
||||||
>
|
|
||||||
<option value="">{t("allGroups")}</option>
|
|
||||||
{GROUPS.map((g) => (
|
|
||||||
<option key={g} value={g}>
|
|
||||||
{g} ({groupCounts[g] ?? 0})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
{Object.keys(groupCounts)
|
|
||||||
.filter((g) => !GROUPS.includes(g))
|
|
||||||
.map((g) => (
|
|
||||||
<option key={g} value={g}>
|
|
||||||
{g} ({groupCounts[g]})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input type="hidden" name="tab" value="manage" />
|
|
||||||
<button type="submit" className="btn btn-primary">
|
|
||||||
{t("search")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="admin-section-title">{t("addPermission")}</h2>
|
|
||||||
<form action={upsertPermission} 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>
|
|
||||||
{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>
|
|
||||||
<h2 className="admin-section-title">
|
|
||||||
{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>
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<div className="admin-empty">{t("noPermissions")}</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="w-[40px]">#</th>
|
|
||||||
<th>{t("colPermission")}</th>
|
|
||||||
<th>{t("colDescription")}</th>
|
|
||||||
<th className="w-[120px]">{t("colGroup")}</th>
|
|
||||||
<th className="w-[100px]">{t("colMinRank")}</th>
|
|
||||||
<th className="w-[180px]">{t("colDepends")}</th>
|
|
||||||
<th className="w-[80px]" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filtered.map((p, i) => (
|
|
||||||
<tr key={String(p.id)}>
|
|
||||||
<td className="muted w-[40px]">{i + 1}</td>
|
|
||||||
<td>
|
|
||||||
<strong>{p.permission}</strong>
|
|
||||||
</td>
|
|
||||||
<td className="muted">{p.description ?? "—"}</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">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<form
|
|
||||||
action={upsertPermission}
|
|
||||||
style={{ display: "flex", gap: "0.5rem" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="permission"
|
|
||||||
value={p.permission}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="description"
|
|
||||||
value={p.description ?? ""}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="groupName"
|
|
||||||
value={p.groupName ?? ""}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="dependsOn"
|
|
||||||
value={p.dependsOn ?? ""}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
name="minRank"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
defaultValue={p.minRank}
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
>
|
|
||||||
{t("save")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
<td className="text-xs">
|
|
||||||
{p.dependsOn ? (
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
permSet.has(p.dependsOn)
|
|
||||||
? "opacity-70"
|
|
||||||
: "opacity-100 font-semibold"
|
|
||||||
}
|
|
||||||
style={
|
|
||||||
!permSet.has(p.dependsOn)
|
|
||||||
? { color: "var(--color-danger)" }
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{p.dependsOn}
|
|
||||||
{!permSet.has(p.dependsOn)
|
|
||||||
? " " + t("missing")
|
|
||||||
: ""}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="opacity-50">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<form action={deletePermission}>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="id"
|
|
||||||
value={String(p.id)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-danger btn-sm"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "import" && (
|
{tab === "import" && (
|
||||||
<>
|
<ManageClient
|
||||||
<section>
|
permissions={allPermissions}
|
||||||
<h2 className="admin-section-title">{t("exportTitle")}</h2>
|
groups={allGroups}
|
||||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
groupCounts={groupCounts}
|
||||||
{t("exportHint")}
|
permSet={permSet}
|
||||||
</p>
|
/>
|
||||||
<HousekeepingClient />
|
)}
|
||||||
</section>
|
|
||||||
<section className="mt-8">
|
{tab === "presets" && (
|
||||||
<h2 className="admin-section-title">{t("importTitle")}</h2>
|
<ManageClient
|
||||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
permissions={allPermissions}
|
||||||
{t("importHint")}
|
groups={allGroups}
|
||||||
</p>
|
groupCounts={groupCounts}
|
||||||
<form action={bulkImportPermissions} className="admin-card">
|
permSet={permSet}
|
||||||
<div className="flex flex-col gap-2">
|
/>
|
||||||
<textarea
|
)}
|
||||||
name="json"
|
|
||||||
placeholder={t("importPlaceholder")}
|
{tab === "test" && (
|
||||||
required
|
<ManageClient
|
||||||
rows={8}
|
permissions={allPermissions}
|
||||||
className="w-full font-mono text-sm"
|
groups={allGroups}
|
||||||
/>
|
groupCounts={groupCounts}
|
||||||
<button type="submit" className="btn btn-primary self-start">
|
permSet={permSet}
|
||||||
{t("import")}
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "ranks" && (
|
{tab === "ranks" && (
|
||||||
@@ -539,7 +319,7 @@ export default async function AdminHousekeeping({
|
|||||||
{p.permission}
|
{p.permission}
|
||||||
{p.dependsOn ? (
|
{p.dependsOn ? (
|
||||||
<span className="opacity-50 ml-1">
|
<span className="opacity-50 ml-1">
|
||||||
→ {p.dependsOn}
|
→ {p.dependsOn}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
@@ -556,80 +336,6 @@ export default async function AdminHousekeeping({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "presets" && (
|
|
||||||
<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"),
|
|
||||||
};
|
|
||||||
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>
|
|
||||||
<form action={applyPreset}>
|
|
||||||
<input type="hidden" name="preset" value={preset} />
|
|
||||||
<button type="submit" className="btn btn-primary w-full">
|
|
||||||
{t("applyPreset")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="mt-6">
|
|
||||||
<form
|
|
||||||
action={clearAllPermissions}
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (confirm(t("confirmClearAll")))
|
|
||||||
(e.target as HTMLFormElement).requestSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button type="submit" className="btn btn-danger">
|
|
||||||
{t("clearAll")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "test" && (
|
|
||||||
<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>
|
|
||||||
<form
|
|
||||||
action={testRankPermission}
|
|
||||||
className="admin-card flex gap-2 flex-wrap"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="testRank"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
placeholder={t("rank")}
|
|
||||||
required
|
|
||||||
className="w-[150px]"
|
|
||||||
/>
|
|
||||||
<button type="submit" className="btn btn-primary">
|
|
||||||
{t("test")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "audit" && (
|
{tab === "audit" && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="admin-section-title">{t("auditTitle")}</h2>
|
<h2 className="admin-section-title">{t("auditTitle")}</h2>
|
||||||
|
|||||||
+11
-1
@@ -2117,7 +2117,17 @@
|
|||||||
"subtitle": "User-facing strings from emulator_settings (commands, wordfilter, pet/matchmaking messages).",
|
"subtitle": "User-facing strings from emulator_settings (commands, wordfilter, pet/matchmaking messages).",
|
||||||
"infoBox": "Configuration settings (ports, timeouts, IDs) do not appear here — use the full settings link. Saved changes are synced to the emulator via RCON.",
|
"infoBox": "Configuration settings (ports, timeouts, IDs) do not appear here — use the full settings link. Saved changes are synced to the emulator via RCON.",
|
||||||
"fullSettings": "Full settings"
|
"fullSettings": "Full settings"
|
||||||
}
|
},
|
||||||
|
"errorOccurred": "An error occurred",
|
||||||
|
"emptyInput": "Empty input",
|
||||||
|
"invalidJson": "Invalid JSON",
|
||||||
|
"imported": "Imported!",
|
||||||
|
"cleared": "All permissions cleared!",
|
||||||
|
"invalidRank": "Invalid rank",
|
||||||
|
"testDone": "Test done: {count} permissions found",
|
||||||
|
"testing": "Testing...",
|
||||||
|
"prev": "Previous",
|
||||||
|
"next": "Next"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "CMS Settings",
|
"title": "CMS Settings",
|
||||||
|
|||||||
+11
-1
@@ -2086,7 +2086,17 @@
|
|||||||
"textColor": "Tekstkleur",
|
"textColor": "Tekstkleur",
|
||||||
"rounded": "Afgeronde hoeken",
|
"rounded": "Afgeronde hoeken",
|
||||||
"save": "Opslaan als favicon"
|
"save": "Opslaan als favicon"
|
||||||
}
|
},
|
||||||
|
"errorOccurred": "Er is een fout opgetreden",
|
||||||
|
"emptyInput": "Lege invoer",
|
||||||
|
"invalidJson": "Ongeldige JSON",
|
||||||
|
"imported": "Ge\u00efmporteerd!",
|
||||||
|
"cleared": "Alle permissies verwijderd!",
|
||||||
|
"invalidRank": "Ongeldige rang",
|
||||||
|
"testDone": "Test voltooid: {count} permissies gevonden",
|
||||||
|
"testing": "Testen...",
|
||||||
|
"prev": "Vorige",
|
||||||
|
"next": "Volgende"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "CMS-instellingen",
|
"title": "CMS-instellingen",
|
||||||
|
|||||||
Reference in new issue
Block a user