refactor: full codebase overhaul — dead code removal, env validation, logger migration, date consolidation, Prisma schema cleanup, button consistency, useEffect deps, test coverage
- env.ts: added 10 missing Zod-validated env vars (imager, paypal currency, argon2/bcrypt params) - Migrated 6 modules from process.env to validated env.* (auth, proxy-auth, paypal, password, redis, imager, moderation, alert, logger) - Replaced console.warn/error with pino logger in 9 server-side modules - Removed 50+ dead exports (SWF wrappers, coalesceHotelName, signIn, isStaff re-export, formatTimestamp, Skeleton/SkeletonCard, 4 unused housekeeping sections) - Consolidated date formatting: 28 files migrated to shared formatDate() from @/lib/format-date - Wired 4 radio/settings API routes through cached siteSettings service instead of raw Prisma queries - Added getMany()/getAll() helpers to SiteSettings service - Removed 88 dead Prisma model definitions (schema 2763→1846 lines) - Created admin action-helper.ts with wrapAction() for standardized error handling - Fixed useEffect dependency arrays in 4 data-heavy components - Replaced raw btn CSS classes with shadcn Button component across admin pages - Stripped dead i18n namespaces (common, pages.client) from all 22 translation files - Removed 2 dead scripts (create-release.sh, check-local-imports.ts) - Fixed knip.json configuration - Added 7 new test suites: format-date, paypal, moderation, alert, webhook, action-helper, and fixed password.test.ts for env mocking - All 358 tests passing across 72 test files - TypeScript: 0 errors
This commit is contained in:
1 parent
12e4a07e48
commit
1acace49d0
150 files changed
+10210
-12272
No files matched your search
@@ -1,811 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } 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>
|
||||
);
|
||||
}
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { exportPermissions } from "@/actions/admin-housekeeping";
|
||||
|
||||
/* ────────── Export Section ────────── */
|
||||
|
||||
@@ -843,26 +42,29 @@ export function ExportSection() {
|
||||
{t("exportHint")}
|
||||
</p>
|
||||
<div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="btn btn-outline mb-3"
|
||||
variant="outline"
|
||||
className="mb-3"
|
||||
>
|
||||
{exporting ? t("loading") : t("export")}
|
||||
</button>
|
||||
</Button>
|
||||
{data && (
|
||||
<div className="relative">
|
||||
<pre className="admin-card font-mono text-xs overflow-auto max-h-[300px] p-3">
|
||||
{data}
|
||||
</pre>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="btn btn-outline btn-sm absolute top-2 right-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
>
|
||||
{copying ? t("copied") : t("copy")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -916,13 +118,14 @@ export function AuditSection({ initialLogs }: AuditSectionProps) {
|
||||
</p>
|
||||
<div className="admin-card">
|
||||
<p className="text-sm opacity-70">{t("auditLogsAt")}</p>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push("/admin/logs/audit")}
|
||||
className="btn btn-outline mt-2"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
>
|
||||
{t("viewAuditLogs")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -31,10 +31,7 @@ export default async function AdminHousekeeping({
|
||||
|
||||
const t = await getTranslations("pages.admin.housekeeping");
|
||||
const sp = await searchParams;
|
||||
let tab = sp.tab ?? "overview";
|
||||
if (tab === "manage" || tab === "presets" || tab === "test") {
|
||||
tab = "compare";
|
||||
}
|
||||
const tab = sp.tab ?? "overview";
|
||||
|
||||
type HKPerm = Awaited<
|
||||
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
|
||||
|
||||
Reference in new issue
Block a user