feat(cms): add recovery history and operational reliability tools

This commit is contained in:
Simo committed 2026-09-09 21:57:56 +02:00
1 parent 89526af344
commit 1ef405be0a
92 files changed
+6384 -267

No files matched your search

+24 -1
View File
@@ -1,4 +1,6 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
@@ -8,5 +10,26 @@ export default async function CatalogMaintenancePage() {
redirect("/admin");
}
return <CatalogMaintenancePanel />;
const canRepair = canAccess(
permissions,
PERMS.CATALOG_EDIT,
session.user.rank,
);
const t = await getTranslations("pages.admin.catalog.integrity");
return (
<div className="space-y-6">
<CatalogIntegrityPanel canRepair={canRepair} />
{canRepair && (
<details className="rounded-xl border p-4">
<summary className="cursor-pointer font-medium">
{t("legacyTitle")}
</summary>
<p className="mt-3 text-sm text-muted-foreground">
{t("legacyHint")}
</p>
<CatalogMaintenancePanel />
</details>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+9 -1
View File
@@ -4,6 +4,7 @@ import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import { HistoryRestore } from "@/features/history/history-restore";
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
import { AuditFilters } from "./audit-filters";
@@ -102,7 +103,14 @@ export function AuditTable({ data }: AuditTableProps) {
{
key: "diff",
label: t("audit.colDetails"),
render: (_value, row) => <DiffCell row={row} />,
render: (_value, row) => (
<div className="space-y-2">
<DiffCell row={row} />
{["history_update", "history_restore"].includes(row.action) && (
<HistoryRestore id={row.id} />
)}
</div>
),
},
{
key: "createdAt",
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from "next/navigation";
import { OperationsCenter } from "@/components/admin/operations-center";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function OperationsPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank))
redirect("/admin");
return <OperationsCenter />;
}
+12 -4
View File
@@ -1,5 +1,7 @@
import { sql } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
@@ -16,6 +18,7 @@ export default async function PermissionsPage({
redirect("/admin");
}
const t = await getTranslations("pages.admin.permissionPreview");
const sp = (await searchParams) ?? {};
const fromHousekeeping = sp.from === "housekeeping";
@@ -36,9 +39,14 @@ export default async function PermissionsPage({
}));
return (
<PermissionsList
ranks={ranksWithCounts}
fromHousekeeping={fromHousekeeping}
/>
<div className="space-y-4">
<Link href="/admin/permissions/preview" className="btn btn-outline">
{t("title")}
</Link>
<PermissionsList
ranks={ranksWithCounts}
fromHousekeeping={fromHousekeeping}
/>
</div>
);
}
@@ -0,0 +1,74 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ access: vi.fn(), execute: vi.fn() }));
vi.mock("@/lib/db", () => ({ db: { execute: state.execute } }));
vi.mock("@/lib/permissions", () => ({
PERMS: { PERMISSIONS_MANAGE: "admin.permissions.manage" },
getAdminContext: async () => ({
session: { user: { rank: 7 } },
permissions: {},
}),
canAccess: state.access,
}));
vi.mock("next/navigation", () => ({
redirect: () => {
throw Error("redirect");
},
}));
vi.mock("next-intl/server", () => ({
getTranslations: async () => (key: string) => key,
}));
vi.mock("@/components/link", () => ({
default: ({ children }: { children: React.ReactNode }) => (
<span>{children}</span>
),
}));
import Page from "./page";
beforeEach(() => {
state.access.mockReset().mockReturnValue(true);
state.execute.mockReset();
});
it("rejects before querying role details", async () => {
state.access.mockReturnValue(false);
await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow(
"redirect",
);
expect(state.execute).not.toHaveBeenCalled();
});
it("does not silently substitute an invalid requested role", async () => {
state.execute.mockResolvedValueOnce([
[{ id: 1, slug: "rank_1", title: "User" }],
]);
const html = renderToStaticMarkup(
await Page({ searchParams: Promise.resolve({ role: "99" }) }),
);
expect(html).toContain("missing");
expect(state.execute).toHaveBeenCalledTimes(1);
});
it("simulates highest-rank override from live rank data", async () => {
state.execute
.mockResolvedValueOnce([[{ id: 10, slug: "rank_8", title: "Owner" }]])
.mockResolvedValueOnce([[]])
.mockResolvedValueOnce([[{ highest_rank: 8 }]])
.mockResolvedValueOnce([[]]);
const html = renderToStaticMarkup(
await Page({ searchParams: Promise.resolve({ role: "10" }) }),
);
expect(html).toContain("superAdmin");
expect(html).toContain("entryAllowed");
});
it("does not infer owner privileges from a title", async () => {
state.execute
.mockResolvedValueOnce([[{ id: 10, slug: "owner", title: "Owner" }]])
.mockResolvedValueOnce([[]])
.mockResolvedValueOnce([[{ highest_rank: 8 }]])
.mockResolvedValueOnce([[]]);
const html = renderToStaticMarkup(
await Page({ searchParams: Promise.resolve({ role: "10" }) }),
);
expect(html).not.toContain("superAdmin");
expect(html).toContain("entryDenied");
});
File diff suppressed because it is too large. Load diff
+21 -3
View File
@@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import {
BOOLEAN_KEYS,
type ManagedField,
@@ -45,7 +46,13 @@ export function CmsSettingsForm({
canEdit: boolean;
}) {
const [values, setValues] = useState(initialValues);
const { isPending, run } = useServerAction();
const [savedValues, setSavedValues] = useState(initialValues);
const tAction = useTranslations("pages.admin.actions");
const dirty = Object.keys({ ...savedValues, ...values }).some(
(key) => savedValues[key] !== values[key],
);
useUnsavedChanges(canEdit && dirty, tAction("leaveUnsaved"));
const { isPending, run, fieldErrors } = useServerAction();
const t = useTranslations("pages.admin.settings");
function handleChange(key: string, value: string) {
@@ -55,6 +62,7 @@ export function CmsSettingsForm({
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canEdit) return;
const submitted = { ...values };
const normalized = { ...values };
for (const key of BOOLEAN_KEYS) {
normalized[key] = values[key] === "1" ? "1" : "0";
@@ -62,24 +70,34 @@ export function CmsSettingsForm({
run(() => saveManagedSettings({ settings: normalized }), {
successMessage: t("saved"),
errorMessage: t("saveFailed"),
onSuccess: () => setSavedValues(submitted),
});
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
{Object.keys(fieldErrors).length > 0 && (
<div role="alert" className="admin-card text-sm text-destructive">
{Object.values(fieldErrors).flat().join(" · ")}
</div>
)}
{SETTINGS_GROUPS.map((group) => (
<GroupSection
key={group.id}
group={group}
values={values}
canEdit={canEdit}
canEdit={canEdit && !isPending}
onChange={handleChange}
/>
))}
{canEdit ? (
<div className="sticky bottom-3 z-10 flex justify-end">
<Button type="submit" disabled={isPending} className="shadow-lg">
<Button
type="submit"
disabled={isPending || !dirty}
className="shadow-lg"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff