feat(cms): add recovery history and operational reliability tools
This commit is contained in:
1 parent
89526af344
commit
1ef405be0a
92 files changed
+6384
-267
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server";
|
||||
|
||||
import Link from "@/components/link";
|
||||
|
||||
import { queryErrorGroups } from "@/lib/error-group-query";
|
||||
import { summarizeErrorGroup } from "@/lib/error-groups";
|
||||
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
@@ -26,8 +27,6 @@ export default async function CmsErrorsPage({
|
||||
|
||||
const params = await searchParams;
|
||||
|
||||
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
|
||||
|
||||
let result: Awaited<ReturnType<ErrorStore["read"]>>;
|
||||
|
||||
try {
|
||||
@@ -40,30 +39,13 @@ export default async function CmsErrorsPage({
|
||||
);
|
||||
}
|
||||
|
||||
const allGroups = new Map<string, typeof result.records>();
|
||||
|
||||
for (const record of result.records) {
|
||||
const events = allGroups.get(record.fingerprint) ?? [];
|
||||
|
||||
events.push(record);
|
||||
allGroups.set(record.fingerprint, events);
|
||||
}
|
||||
|
||||
const releases = [...new Set(result.records.map((r) => r.release))].sort();
|
||||
const queried = queryErrorGroups(result.records, params);
|
||||
const groups = new Map(
|
||||
[...allGroups.entries()].filter(([, events]) => {
|
||||
const latest = summarizeErrorGroup(events).latest;
|
||||
|
||||
return (
|
||||
latest &&
|
||||
(!params.source || latest.source === params.source) &&
|
||||
(params.status !== "open" || !latest.resolved) &&
|
||||
(params.status !== "resolved" || latest.resolved) &&
|
||||
(!query ||
|
||||
events.some((record) =>
|
||||
JSON.stringify(record).toLowerCase().includes(query),
|
||||
))
|
||||
);
|
||||
}),
|
||||
queried.map((group) => [group.fingerprint, group.events]),
|
||||
);
|
||||
const latestByGroup = new Map(
|
||||
queried.map((group) => [group.fingerprint, group.latest]),
|
||||
);
|
||||
|
||||
const filtered = [...groups.values()].flat();
|
||||
@@ -72,14 +54,14 @@ export default async function CmsErrorsPage({
|
||||
1,
|
||||
|
||||
Math.min(
|
||||
Number(params.page) || 1,
|
||||
Math.floor(Number(params.page)) || 1,
|
||||
|
||||
Math.max(1, Math.ceil(groups.size / 25)),
|
||||
),
|
||||
);
|
||||
|
||||
const pageHref = (next: number) =>
|
||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`;
|
||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", release: params.release ?? "", period: params.period ?? "", sort: params.sort ?? "", page: String(next) })}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -126,13 +108,46 @@ export default async function CmsErrorsPage({
|
||||
<option value="open">{t("open")}</option>
|
||||
<option value="resolved">{t("resolved")}</option>
|
||||
</select>
|
||||
<select
|
||||
name="release"
|
||||
aria-label={t("releaseFilter")}
|
||||
defaultValue={params.release ?? ""}
|
||||
className="input max-w-full"
|
||||
>
|
||||
<option value="">{t("allReleases")}</option>
|
||||
{releases.map((release) => (
|
||||
<option key={release} value={release}>
|
||||
{release}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="period"
|
||||
aria-label={t("period")}
|
||||
defaultValue={params.period ?? ""}
|
||||
className="input"
|
||||
>
|
||||
<option value="">{t("allRetained")}</option>
|
||||
<option value="24h">{t("last24h")}</option>
|
||||
<option value="7d">{t("last7d")}</option>
|
||||
</select>
|
||||
<select
|
||||
name="sort"
|
||||
aria-label={t("sort")}
|
||||
defaultValue={params.sort ?? ""}
|
||||
className="input"
|
||||
>
|
||||
<option value="">{t("recentFirst")}</option>
|
||||
<option value="frequency">{t("frequentFirst")}</option>
|
||||
</select>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("search")}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("counts", { count: filtered.length, groups: groups.size })}{" "}
|
||||
{result.truncated ? t("truncated") : ""} {t("retention")}
|
||||
{result.truncated ? t("truncated") : ""} {t("retention")}{" "}
|
||||
{t("filterScope")}
|
||||
</p>
|
||||
{groups.size === 0 ? (
|
||||
<div className="admin-card">{t("empty")}</div>
|
||||
@@ -144,7 +159,7 @@ export default async function CmsErrorsPage({
|
||||
.map(([fingerprint, events]) => {
|
||||
const metrics = summarizeErrorGroup(events);
|
||||
|
||||
const r = metrics.latest;
|
||||
const r = latestByGroup.get(fingerprint);
|
||||
|
||||
if (!r) return null;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
|
||||
import { previewRoleAccess } from "@/lib/admin/permission-preview";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function PermissionPreview({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ role?: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank))
|
||||
redirect("/admin");
|
||||
const t = await getTranslations("pages.admin.permissionPreview");
|
||||
const nav = await getTranslations("pages.admin.nav");
|
||||
const [roleRows] = await db.execute(
|
||||
sql`SELECT id, slug, title FROM acl_roles ORDER BY title, id`,
|
||||
);
|
||||
const roles = roleRows as unknown as {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
}[];
|
||||
const params = await searchParams;
|
||||
const selected =
|
||||
roles.find((r) => String(r.id) === params.role) ??
|
||||
(params.role ? undefined : roles[0]);
|
||||
let preview: ReturnType<typeof previewRoleAccess> | undefined;
|
||||
let superAdmin = false;
|
||||
let permissionTitles: Record<string, string> = {};
|
||||
if (selected) {
|
||||
const [grantRows] = await db.execute(
|
||||
sql`SELECT p.slug, p.title FROM acl_model_permissions mp JOIN acl_permissions p ON p.id = mp.permission_id WHERE mp.model_type = 'Role' AND mp.model_id = ${selected.id}`,
|
||||
);
|
||||
const grants = grantRows as unknown as { slug: string; title: string }[];
|
||||
const [highestRows] = await db.execute(
|
||||
sql`SELECT COALESCE((SELECT MAX(u.\`rank\`) FROM users u INNER JOIN permission_ranks pr ON pr.id = u.\`rank\`), (SELECT MAX(id) FROM permission_ranks)) AS highest_rank`,
|
||||
);
|
||||
const highest = (
|
||||
highestRows as unknown as { highest_rank: number | null }[]
|
||||
)[0]?.highest_rank;
|
||||
const rankMatch = /^rank_([1-9]\d*)$/.exec(selected.slug);
|
||||
superAdmin =
|
||||
!!rankMatch &&
|
||||
isDynamicSuperAdmin(
|
||||
Number(rankMatch[1]),
|
||||
highest == null ? null : Number(highest),
|
||||
);
|
||||
preview = previewRoleAccess(
|
||||
grants.map((g) => g.slug),
|
||||
superAdmin,
|
||||
);
|
||||
const [allRows] = await db.execute(
|
||||
sql`SELECT slug, title FROM acl_permissions ORDER BY slug`,
|
||||
);
|
||||
permissionTitles = Object.fromEntries(
|
||||
(allRows as unknown as { slug: string; title: string }[]).map((p) => [
|
||||
p.slug,
|
||||
p.title,
|
||||
]),
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<Link className="btn btn-outline" href="/admin/permissions">
|
||||
{t("back")}
|
||||
</Link>
|
||||
<form className="admin-card flex flex-wrap items-end gap-3">
|
||||
<label className="grid min-w-0 max-w-full flex-1 gap-1 text-sm">
|
||||
{t("role")}
|
||||
<select
|
||||
name="role"
|
||||
defaultValue={selected?.id ?? ""}
|
||||
className="input w-full min-w-0 max-w-full"
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.title} ({role.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="submit"
|
||||
disabled={!roles.length}
|
||||
>
|
||||
{t("show")}
|
||||
</button>
|
||||
</form>
|
||||
{!preview ? (
|
||||
<p role="status">{t("missing")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-card space-y-2">
|
||||
<p className="font-semibold">
|
||||
{t(preview.canEnter ? "entryAllowed" : "entryDenied")}
|
||||
</p>
|
||||
{superAdmin && <p>{t("superAdmin")}</p>}
|
||||
<p className="text-sm text-muted-foreground">{t("scope")}</p>
|
||||
</div>
|
||||
<section className="admin-card">
|
||||
<h2 className="mb-3 font-semibold">{t("sections")}</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{preview.sections.map((item) => (
|
||||
<div key={item.href} className="min-w-0 rounded border p-3">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>{nav(item.labelKey)}</span>
|
||||
<span className="text-xs">
|
||||
{t(item.allowed ? "allowed" : "denied")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{item.href}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<details className="admin-card">
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
{t("actions")}
|
||||
</summary>
|
||||
<p className="my-3 text-sm text-muted-foreground">
|
||||
{t("actionScope")}
|
||||
</p>
|
||||
<ul className="divide-y">
|
||||
{preview.actions.map((item) => (
|
||||
<li
|
||||
key={item.slug}
|
||||
className="flex items-start justify-between gap-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="break-words">
|
||||
{permissionTitles[item.slug] || item.slug}
|
||||
</p>
|
||||
<code className="break-all text-xs text-muted-foreground">
|
||||
{item.slug}
|
||||
</code>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs">
|
||||
{t(item.granted ? "granted" : "notGranted")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
@@ -20,9 +21,13 @@ type ExportStatus = {
|
||||
|
||||
export function CatalogExportClient({
|
||||
canConfigure = false,
|
||||
monitorOnly = false,
|
||||
}: {
|
||||
canConfigure?: boolean;
|
||||
monitorOnly?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.operationsExport");
|
||||
const submitting = useRef(false);
|
||||
const [status, setStatus] = useState<ExportStatus | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -34,37 +39,37 @@ export function CatalogExportClient({
|
||||
const response = await adminFetch("/api/admin/catalog-export", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("Unable to load export status");
|
||||
if (!response.ok) throw new Error(t("loadError"));
|
||||
setStatus(await response.json());
|
||||
setError("");
|
||||
} catch {
|
||||
setError("Unable to load export status");
|
||||
setError(t("loadError"));
|
||||
} finally {
|
||||
refreshing.current = false;
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
useVisiblePolling(refresh, 15000);
|
||||
async function retry() {
|
||||
if (submitting.current) return;
|
||||
submitting.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/catalog-export", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) throw new Error("Unable to queue export");
|
||||
if (!response.ok) throw new Error(t("queueError"));
|
||||
await refresh();
|
||||
} catch {
|
||||
setError("Unable to queue export");
|
||||
setError(t("queueError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
submitting.current = false;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
|
||||
<h2 className="text-lg font-semibold">Catalog repository export</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically publishes bundles, icons, gamedata, translations and
|
||||
catalog SQL to your configured Gitea repository.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("hint")}</p>
|
||||
{canConfigure && (
|
||||
<CatalogGitConfiguration onSaved={() => void refresh()} />
|
||||
)}
|
||||
@@ -75,19 +80,19 @@ export function CatalogExportClient({
|
||||
)}
|
||||
<div role="status" className="text-sm" aria-live="polite">
|
||||
{!status
|
||||
? "Loading…"
|
||||
? t("loading")
|
||||
: !status.enabled
|
||||
? "Export disabled. Configure the Gitea connection below."
|
||||
? t("disabled")
|
||||
: status.active
|
||||
? `Waiting for ${status.active} operation(s) to finish.`
|
||||
? t("active", { count: status.active })
|
||||
: status.running
|
||||
? "Export in progress…"
|
||||
? t("running")
|
||||
: status.pending
|
||||
? `${status.pending} update(s) queued for export.`
|
||||
: "No pending updates."}
|
||||
? t("pending", { count: status.pending })
|
||||
: t("idle")}
|
||||
{status?.commit && (
|
||||
<p>
|
||||
Last published commit:{" "}
|
||||
{t("lastCommit")}{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href={`${status.remote?.replace(/\.git$/, "")}/commit/${status.commit}`}
|
||||
@@ -98,21 +103,30 @@ export function CatalogExportClient({
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{status?.finishedAt && <p>Last attempt: {status.finishedAt}</p>}
|
||||
{status?.finishedAt && (
|
||||
<p>{t("lastAttempt", { date: status.finishedAt })}</p>
|
||||
)}
|
||||
</div>
|
||||
{(error || status?.error) && (
|
||||
<p role="alert" className="text-sm">
|
||||
{error || status?.error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!status?.enabled || busy}
|
||||
onClick={retry}
|
||||
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Queuing…" : "Export now / retry"}
|
||||
</button>
|
||||
{!monitorOnly && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!status?.enabled ||
|
||||
busy ||
|
||||
Boolean(status.running) ||
|
||||
status.active > 0
|
||||
}
|
||||
onClick={retry}
|
||||
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Queuing…" : "Export now / retry"}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { updateUser } from "@/actions/users";
|
||||
import { useFormErrorFocus } from "@/components/admin/form-feedback";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import { type UpdateUserInput, updateUserSchema } from "@/lib/validators/user";
|
||||
|
||||
interface UserEditFormProps {
|
||||
@@ -46,13 +51,18 @@ const FALLBACK_RANKS: Array<{ id: number; name: string }> = [
|
||||
|
||||
export function UserEditForm({ user, ranks }: UserEditFormProps) {
|
||||
const rankOptions = ranks && ranks.length > 0 ? ranks : FALLBACK_RANKS;
|
||||
const { isPending, run } = useServerAction();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const tAction = useTranslations("pages.admin.actions");
|
||||
const router = useRouter();
|
||||
const { isPending, run, fieldErrors } = useServerAction();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
setError,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<UpdateUserInput>({
|
||||
resolver: zodResolver(updateUserSchema),
|
||||
defaultValues: {
|
||||
@@ -67,9 +77,36 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
|
||||
},
|
||||
});
|
||||
|
||||
useUnsavedChanges(isDirty, tAction("leaveUnsaved"));
|
||||
useFormErrorFocus(formRef, fieldErrors, isPending);
|
||||
useEffect(() => {
|
||||
for (const [field, messages] of Object.entries(fieldErrors)) {
|
||||
if (
|
||||
[
|
||||
"username",
|
||||
"mail",
|
||||
"rank",
|
||||
"motto",
|
||||
"credits",
|
||||
"pixels",
|
||||
"diamonds",
|
||||
"duckets",
|
||||
].includes(field)
|
||||
)
|
||||
setError(
|
||||
field as keyof UpdateUserInput,
|
||||
{ type: "server", message: messages.join(" · ") },
|
||||
{ shouldFocus: true },
|
||||
);
|
||||
}
|
||||
}, [fieldErrors, setError]);
|
||||
function onSubmit(data: UpdateUserInput) {
|
||||
run(() => updateUser({ id: user.id, ...data }), {
|
||||
successMessage: "User updated successfully.",
|
||||
successMessage: tAction("saved"),
|
||||
onSuccess: () => {
|
||||
reset(data);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,91 +116,127 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
|
||||
<CardTitle>Edit User</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" {...register("username")} />
|
||||
{errors.username && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.username.message}
|
||||
</p>
|
||||
)}
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<fieldset disabled={isPending} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" {...register("username")} />
|
||||
{errors.username && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.username.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mail">Email</Label>
|
||||
<Input id="mail" type="email" {...register("mail")} />
|
||||
{errors.mail && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.mail.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mail">Email</Label>
|
||||
<Input id="mail" type="email" {...register("mail")} />
|
||||
{errors.mail && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.mail.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank">Rank</Label>
|
||||
<Select
|
||||
defaultValue={String(user.rank)}
|
||||
onValueChange={(v) => setValue("rank", parseInt(v, 10))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select rank" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rankOptions.map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.id} — {r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank">Rank</Label>
|
||||
<Select
|
||||
defaultValue={String(user.rank)}
|
||||
onValueChange={(v) =>
|
||||
setValue("rank", parseInt(v, 10), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select rank" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rankOptions.map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.id} — {r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motto">Motto</Label>
|
||||
<Input id="motto" {...register("motto")} />
|
||||
{errors.motto && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.motto.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motto">Motto</Label>
|
||||
<Input id="motto" {...register("motto")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="credits">Credits</Label>
|
||||
<Input
|
||||
id="credits"
|
||||
type="number"
|
||||
{...register("credits", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="credits">Credits</Label>
|
||||
<Input
|
||||
id="credits"
|
||||
type="number"
|
||||
{...register("credits", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.credits && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.credits.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pixels">Pixels</Label>
|
||||
<Input
|
||||
id="pixels"
|
||||
type="number"
|
||||
{...register("pixels", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.pixels && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.pixels.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="diamonds">Diamonds</Label>
|
||||
<Input
|
||||
id="diamonds"
|
||||
type="number"
|
||||
{...register("diamonds", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.diamonds && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.diamonds.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duckets">Duckets</Label>
|
||||
<Input
|
||||
id="duckets"
|
||||
type="number"
|
||||
{...register("duckets", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.duckets && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.duckets.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pixels">Pixels</Label>
|
||||
<Input
|
||||
id="pixels"
|
||||
type="number"
|
||||
{...register("pixels", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="diamonds">Diamonds</Label>
|
||||
<Input
|
||||
id="diamonds"
|
||||
type="number"
|
||||
{...register("diamonds", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duckets">Duckets</Label>
|
||||
<Input
|
||||
id="duckets"
|
||||
type="number"
|
||||
{...register("duckets", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
create: vi.fn(),
|
||||
list: vi.fn(),
|
||||
page: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
after: vi.fn(),
|
||||
ping: vi.fn(),
|
||||
@@ -31,6 +33,8 @@ vi.mock("@/lib/services/furni-job-store", async (original) => ({
|
||||
ImportJobStore: class {
|
||||
create = mocks.create;
|
||||
list = mocks.list;
|
||||
page = mocks.page;
|
||||
retry = mocks.retry;
|
||||
requestCancellation = mocks.cancel;
|
||||
},
|
||||
}));
|
||||
@@ -129,3 +133,49 @@ it("does not reveal other owners' jobs", async () => {
|
||||
it("rejects unsafe cancellation IDs", async () => {
|
||||
expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400);
|
||||
});
|
||||
|
||||
it("delegates retry to owner-scoped server selection, ignoring client items", async () => {
|
||||
const id = randomUUID();
|
||||
mocks.retry.mockResolvedValue({ id });
|
||||
const response = await PATCH(
|
||||
request({ id, action: "retry", items: [item] }),
|
||||
ctx,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.retry).toHaveBeenCalledWith(id, 7);
|
||||
});
|
||||
it("rejects retries while the queue is offline", async () => {
|
||||
mocks.ping.mockResolvedValue(null);
|
||||
mocks.retry.mockClear();
|
||||
expect(
|
||||
(await PATCH(request({ id: randomUUID(), action: "retry" }), ctx)).status,
|
||||
).toBe(503);
|
||||
expect(mocks.retry).not.toHaveBeenCalled();
|
||||
});
|
||||
it("loads a page only for the authenticated operator", async () => {
|
||||
mocks.page.mockResolvedValue({ jobs: [], nextCursor: null });
|
||||
const response = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/studio/import-jobs?page=1&userId=99",
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.page).toHaveBeenCalledWith({
|
||||
userId: 7,
|
||||
limit: 20,
|
||||
before: undefined,
|
||||
});
|
||||
});
|
||||
it("rejects malformed history cursors", async () => {
|
||||
expect(
|
||||
(
|
||||
await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/studio/import-jobs?page=1&before=broken",
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
});
|
||||
@@ -36,7 +36,26 @@ const schema = z.object({
|
||||
});
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (_request, ctx) => {
|
||||
async (request, ctx) => {
|
||||
const query = new URL(request.url).searchParams;
|
||||
if (query.has("page")) {
|
||||
const before = query.get("before") || undefined;
|
||||
if (
|
||||
before &&
|
||||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i.test(
|
||||
before,
|
||||
)
|
||||
)
|
||||
return apiError("Invalid history cursor", 400);
|
||||
after(drainFurnitureImports);
|
||||
return apiOk(
|
||||
await new ImportJobStore().page({
|
||||
userId: ctx.session.user.id,
|
||||
limit: 20,
|
||||
before,
|
||||
}),
|
||||
);
|
||||
}
|
||||
after(drainFurnitureImports);
|
||||
const jobs = await new ImportJobStore().list({
|
||||
userId: ctx.session.user.id,
|
||||
@@ -88,6 +107,26 @@ export const PATCH = withAdmin(
|
||||
async (request, ctx) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!validJobId(body?.id)) return apiError("Invalid import ID", 400);
|
||||
if (body.action === "retry") {
|
||||
if (!redis || (await redis.ping().catch(() => null)) !== "PONG")
|
||||
return apiError(
|
||||
"Background import queue is temporarily unavailable",
|
||||
503,
|
||||
);
|
||||
const job = await new ImportJobStore().retry(
|
||||
body.id,
|
||||
ctx.session.user.id,
|
||||
);
|
||||
if (!job)
|
||||
return apiError(
|
||||
"No safe remaining items to retry, or import job not found",
|
||||
404,
|
||||
);
|
||||
after(drainFurnitureImports);
|
||||
return apiOk({ job });
|
||||
}
|
||||
if (body.action && body.action !== "cancel")
|
||||
return apiError("Invalid job action", 400);
|
||||
const job = await new ImportJobStore().requestCancellation(
|
||||
body.id,
|
||||
ctx.session.user.id,
|
||||
|
||||
Reference in new issue
Block a user