diff --git a/docs/cms-upgrade-2026-09.md b/docs/cms-upgrade-2026-09.md index 4b9d0b4c..f91f845e 100644 --- a/docs/cms-upgrade-2026-09.md +++ b/docs/cms-upgrade-2026-09.md @@ -101,3 +101,16 @@ other locales receive English fallback strings. This is a focused editorial pass not a full translation of every CMS page. No database migration or dependency change is required. Browser checks use real components with simulated data at 1280 and 390 pixels; production database behavior still needs deployment validation. + +## Operational reliability and recovery + +- Audit history now records category settings (normal/Club), individual/bulk offer prices, update-mode package publication and news edits inside the write transaction. The audit screen previews and restores individual changes after locking and comparing the current recorded fields. Deleted records, hierarchy changes, LTD counters, imports and historical entries without complete snapshots cannot be restored. Restores create their own history entry. Apply migration `0028_history_snapshots.sql` before running this version: it widens audit snapshots to MEDIUMTEXT without deleting existing data. +- `/admin/operations` reuses durable import jobs, stable history pagination and owner-scoped failed-item retries. A deterministic child ID prevents duplicate retries. The shared Git export queue displays actual pending/running state and latest result; synchronous/SSE synchronization remains linked rather than represented as a durable job history. +- Catalog maintenance includes a read-only integrity report for normal/Club categories, offers, furniture references and local icons. Known sentinel IDs are preserved. Only categories pointing to a missing positive parent have an automated repair: preview lists every affected category and apply compares the locked graph before reattaching those categories at the root. No records are deleted. Other issues require an explicit manual edit. Reports display up to 200 issues with complete counts; unavailable icon storage is distinguished from missing assets. +- CMS errors support exact release and time filters plus frequency sorting. Counts refer to retained matching events, while group resolution remains current across releases. +- User/settings forms now protect unsaved edits and preserve failed submissions. User/news validation errors appear at the affected fields; settings show returned validation errors inline. Existing submission locking is retained and tested in a browser. +- `/admin/permissions/preview` shows one role's section access and known CMS grants using live ACL and the existing highest-rank policy. It never changes sessions. Additional user roles, navigation customization and record-specific authorization remain explicit limits of the preview. + +New UI copy is supplied in English, Italian and Dutch; other locales receive English fallback strings. No new runtime dependencies. Browser fixtures use real UI components with simulated server responses; the database migration and production behavior have not been exercised on the live hotel. + +Validation for this increment: 1,589 tests passed, six skipped; TypeScript, Biome, translation contracts and fixture production build passed. Browser checks covered user/settings/news forms, permission preview, CMS errors, integrity preview and history restore at 1280 and 390 pixels. Double submission, stale preview, blocked navigation, field focus and horizontal overflow were checked with simulated server actions. No live database writes or deployment were performed. diff --git a/drizzle/migrations/0028_history_snapshots.sql b/drizzle/migrations/0028_history_snapshots.sql new file mode 100644 index 00000000..0c930a4e --- /dev/null +++ b/drizzle/migrations/0028_history_snapshots.sql @@ -0,0 +1,2 @@ +-- Preserve complete before/after snapshots for long news articles. +ALTER TABLE admin_audit_log MODIFY COLUMN `before` MEDIUMTEXT NULL, MODIFY COLUMN `after` MEDIUMTEXT NULL; diff --git a/src/actions/admin-articles.test.ts b/src/actions/admin-articles.test.ts index 499db9c4..427e50f7 100644 --- a/src/actions/admin-articles.test.ts +++ b/src/actions/admin-articles.test.ts @@ -8,6 +8,10 @@ const state = vi.hoisted(() => ({ log: vi.fn(() => "news-error-1"), notify: vi.fn(), })); +vi.mock("@/features/history/server", () => ({ + historySnapshot: async () => ({}), + recordHistory: vi.fn(), +})); vi.mock("@/lib/admin/guard", () => ({ requirePermission: async () => ({ id: 1, username: "staff" }), })); diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 1c3dde36..df826468 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -5,11 +5,13 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor"; +import { historySnapshot, recordHistory } from "@/features/history/server"; import { requirePermission } from "@/lib/admin/guard"; import { articleEditToken } from "@/lib/article-edit-token"; import { ArticleInputError, type ArticleSaveResult, + articleInputField, readArticleInput, } from "@/lib/article-input"; import { @@ -53,8 +55,14 @@ async function saveFailure( operation: string, ): Promise { const t = await getTranslations("pages.admin.articles.form"); - if (error instanceof ArticleInputError) - return { ok: false, error: t(error.code) }; + if (error instanceof ArticleInputError) { + const field = articleInputField(error.code); + return { + ok: false, + error: t(error.code), + ...(field ? { fieldErrors: { [field]: [t(error.code)] } } : {}), + }; + } const reference = logger.error("News save failed", { module: "news", operation, @@ -124,6 +132,8 @@ export async function updateArticle( if (!existing) throw new ArticleInputError("articleNotFound"); if (formData.get("baseToken") !== articleEditToken(existing)) throw new ArticleInputError("editConflict"); + const historyId = String(id); + const historyBefore = await historySnapshot(tx, "news", historyId); await tx.insert(ArticleRevisions).values({ articleId: id, userId: staff.id, @@ -158,6 +168,7 @@ export async function updateArticle( updatedAt: new Date(), }) .where(eq(WebsiteArticles.id, id)); + await recordHistory(tx, "news", historyId, staff.id, historyBefore); }); } catch (error) { return saveFailure(error, "update"); diff --git a/src/actions/catalog-bc.ts b/src/actions/catalog-bc.ts index 690ad0bc..85db33c6 100644 --- a/src/actions/catalog-bc.ts +++ b/src/actions/catalog-bc.ts @@ -75,7 +75,7 @@ export async function updateBcPage({ return { ok: false as const, error: "No valid fields to update" }; } try { - await updatePageCommand("bc", id, data, expected); + await updatePageCommand("bc", id, data, expected, staff.id); } catch (error) { return { ok: false as const, error: catalogFailure(error).message }; } @@ -176,9 +176,9 @@ export async function toggleBcPage({ id: number; field: "enabled" | "visible"; }) { - await requirePermission(PERMS.CATALOG_EDIT); + const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - await togglePageCommand("bc", id, field); + await togglePageCommand("bc", id, field, staff.id); await sendCatalogUpdate(); revalidatePath("/admin/catalog/builder-club"); return { ok: true as const }; diff --git a/src/actions/catalog-bulk.ts b/src/actions/catalog-bulk.ts index 803cf304..89b738e6 100644 --- a/src/actions/catalog-bulk.ts +++ b/src/actions/catalog-bulk.ts @@ -28,7 +28,7 @@ export async function applyBulkOffers( const staff = await requirePermission(PERMS.CATALOG_EDIT); try { return await withCatalogExport(async () => { - const data = await applyBulkOffersCommand(input, fingerprint); + const data = await applyBulkOffersCommand(input, fingerprint, staff.id); if (data.changedCount > 0) { await logAudit({ userId: staff.id, diff --git a/src/actions/catalog-integrity.test.ts b/src/actions/catalog-integrity.test.ts new file mode 100644 index 00000000..574295f0 --- /dev/null +++ b/src/actions/catalog-integrity.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + guard: vi.fn(), + inspect: vi.fn(), + preview: vi.fn(), + apply: vi.fn(), +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/admin/guard", () => ({ + requirePermissionRateLimited: mocks.guard, +})); +vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs")); +vi.mock("@/lib/safe-action-shared", () => ({ + actionOk: (data: unknown) => ({ ok: true, data }), + actionError: (error: string) => ({ ok: false, error }), + handleActionError: () => ({ ok: false, error: "denied" }), +})); +vi.mock("@/lib/services/catalog-audit", () => ({ + inspectLiveCatalogIntegrity: mocks.inspect, +})); +vi.mock("@/lib/services/catalog-repair", () => ({ + previewCatalogParentRepair: mocks.preview, + applyCatalogParentRepair: mocks.apply, +})); +vi.mock("@/lib/services/catalog-git-queue", () => ({ + withCatalogExport: (fn: () => unknown) => fn(), +})); + +import { PERMS } from "@/lib/permission-slugs"; +import { + applyCatalogIntegrityRepairAction, + inspectCatalogIntegrityAction, + previewCatalogIntegrityRepairAction, +} from "./catalog-integrity"; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.guard.mockResolvedValue({ id: 7 }); + mocks.apply.mockResolvedValue({ applied: 1 }); +}); +describe("integrity action permissions", () => { + it("allows inspection only after catalog view and preview only after catalog edit", async () => { + await inspectCatalogIntegrityAction("normal"); + expect(mocks.guard).toHaveBeenLastCalledWith(PERMS.CATALOG_VIEW); + await previewCatalogIntegrityRepairAction("bc"); + expect(mocks.guard).toHaveBeenLastCalledWith(PERMS.CATALOG_EDIT); + expect(mocks.preview).toHaveBeenCalledWith("bc"); + }); + it("denies apply before reading or mutating data without edit permission", async () => { + mocks.guard.mockRejectedValue(new Error("denied")); + expect(await applyCatalogIntegrityRepairAction("normal", "abc")).toEqual({ + ok: false, + error: "denied", + }); + expect(mocks.guard).toHaveBeenCalledWith(PERMS.CATALOG_EDIT); + expect(mocks.apply).not.toHaveBeenCalled(); + }); + it("forwards actor identity and translates changed state to a recoverable conflict", async () => { + const error = new Error("changed"); + error.name = "CatalogPreviewConflict"; + mocks.apply.mockRejectedValue(error); + expect( + await applyCatalogIntegrityRepairAction("bc", "fingerprint"), + ).toEqual({ ok: false, error: "stalePreview" }); + expect(mocks.apply).toHaveBeenCalledWith("bc", "fingerprint", 7); + }); + it("rejects unknown catalog names before executing queries", async () => { + await inspectCatalogIntegrityAction("catalog_pages; DELETE"); + expect(mocks.inspect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/actions/catalog-integrity.ts b/src/actions/catalog-integrity.ts new file mode 100644 index 00000000..f8f58294 --- /dev/null +++ b/src/actions/catalog-integrity.ts @@ -0,0 +1,60 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requirePermissionRateLimited } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; +import { + actionError, + actionOk, + handleActionError, +} from "@/lib/safe-action-shared"; +import { inspectLiveCatalogIntegrity } from "@/lib/services/catalog-audit"; +import { withCatalogExport } from "@/lib/services/catalog-git-queue"; +import { integrityCatalog } from "@/lib/services/catalog-integrity"; +import { + applyCatalogParentRepair, + previewCatalogParentRepair, +} from "@/lib/services/catalog-repair"; + +export async function inspectCatalogIntegrityAction(catalog: unknown) { + try { + await requirePermissionRateLimited(PERMS.CATALOG_VIEW); + return actionOk( + await inspectLiveCatalogIntegrity(integrityCatalog(catalog)), + ); + } catch (error) { + return handleActionError(error); + } +} + +export async function previewCatalogIntegrityRepairAction(catalog: unknown) { + try { + await requirePermissionRateLimited(PERMS.CATALOG_EDIT); + return actionOk( + await previewCatalogParentRepair(integrityCatalog(catalog)), + ); + } catch (error) { + return handleActionError(error); + } +} + +export async function applyCatalogIntegrityRepairAction( + catalog: unknown, + fingerprint: unknown, +) { + try { + const staff = await requirePermissionRateLimited(PERMS.CATALOG_EDIT); + const kind = integrityCatalog(catalog); + if (typeof fingerprint !== "string") return actionError("Invalid preview"); + const result = await withCatalogExport(() => + applyCatalogParentRepair(kind, fingerprint, staff.id), + ); + revalidatePath("/admin/catalog"); + revalidatePath("/admin/catalog/builder-club"); + return actionOk(result); + } catch (error) { + if (error instanceof Error && error.name === "CatalogPreviewConflict") + return { ok: false as const, error: "stalePreview" as const }; + return handleActionError(error); + } +} diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 2c9c8d3b..885c9b47 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -251,7 +251,7 @@ export async function updateCatalogItem({ const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { try { - await updateOfferCommand({ id, catalogFields, baseItem }); + await updateOfferCommand({ id, catalogFields, baseItem }, staff.id); } catch (error) { return { ok: false as const, diff --git a/src/actions/catalog-packages.ts b/src/actions/catalog-packages.ts index 6ad2d6a1..4b06cb6f 100644 --- a/src/actions/catalog-packages.ts +++ b/src/actions/catalog-packages.ts @@ -65,7 +65,7 @@ export async function publishCatalogPackage(input: PublishCatalogPackageInput) { const warnings: string[] = []; try { await withCatalogExport(async () => { - committed = await publishCatalogPackageCommand(input); + committed = await publishCatalogPackageCommand(input, staff.id); return committed; }); } catch (error) { diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index d4eb697a..ed67ce5e 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -67,7 +67,7 @@ export async function updateCatalogPage({ data.captionSave = data.caption.slice(0, 25); } try { - await updatePageCommand("normal", id, data, expected); + await updatePageCommand("normal", id, data, expected, staff.id); } catch (error) { return { ok: false as const, error: catalogFailure(error).message }; } @@ -108,12 +108,13 @@ export async function toggleCatalogPage({ id: number; action: "toggleEnabled" | "toggleVisible"; }) { - await requirePermission(PERMS.CATALOG_EDIT); + const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { await togglePageCommand( "normal", id, action === "toggleEnabled" ? "enabled" : "visible", + staff.id, ); await sendCatalogUpdate(); revalidatePath("/admin/catalog"); diff --git a/src/actions/history.test.ts b/src/actions/history.test.ts new file mode 100644 index 00000000..789c565a --- /dev/null +++ b/src/actions/history.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + guard: vi.fn(), + apply: vi.fn(), + snapshot: vi.fn(), + entry: { + kind: "prices", + targetId: 1, + before: { costCredits: 1 }, + after: { costCredits: 2 }, + }, +})); +vi.mock("@/lib/db", () => ({ + db: { + transaction: async (callback: (tx: object) => unknown) => callback({}), + }, +})); +vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.guard })); +vi.mock("@/lib/permissions", () => ({ + PERMS: { LOGS_VIEW: "logs", CATALOG_EDIT: "catalog", NEWS_EDIT: "news" }, +})); +vi.mock("@/features/history/server", () => ({ + readHistory: async () => state.entry, + historySnapshot: state.snapshot, + applyHistory: state.apply, +})); +vi.mock("@/lib/services/catalog-git-queue", () => ({ + withCatalogExport: async (fn: () => unknown) => fn(), +})); +vi.mock("@/lib/services/news-cache", () => ({ invalidateNewsCache: vi.fn() })); +vi.mock("@/features/catalog/server/sync-status", () => ({ + sendCatalogUpdate: vi.fn(), +})); +vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +import { previewHistoryRestore, restoreHistory } from "./history"; + +beforeEach(() => { + vi.clearAllMocks(); + state.guard.mockResolvedValue({ id: 7 }); + state.snapshot.mockResolvedValue(state.entry.after); + state.apply.mockResolvedValue(undefined); + state.entry.kind = "prices"; +}); +it("requires logs access and catalog edit on preview and restore", async () => { + await previewHistoryRestore(1); + await restoreHistory(1); + expect(state.guard.mock.calls.map((call) => call[0])).toEqual([ + "logs", + "catalog", + "logs", + "catalog", + ]); +}); +it("requires news edit for news history", async () => { + state.entry.kind = "news"; + await restoreHistory(1); + expect(state.guard).toHaveBeenCalledWith("news"); +}); +it("rejects forbidden restore before any mutation", async () => { + state.guard.mockRejectedValue(Error("Forbidden")); + await expect(restoreHistory(1)).rejects.toThrow("Forbidden"); + expect(state.apply).not.toHaveBeenCalled(); +}); +it("returns conflict when current data changed after preview", async () => { + await previewHistoryRestore(1); + state.apply.mockRejectedValue(Error("conflict")); + await expect(restoreHistory(1)).resolves.toEqual({ + ok: false, + code: "conflict", + }); +}); diff --git a/src/actions/history.ts b/src/actions/history.ts new file mode 100644 index 00000000..35b51dbf --- /dev/null +++ b/src/actions/history.ts @@ -0,0 +1,69 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; +import { historyChanges, sameSnapshot } from "@/features/history/model"; +import { + applyHistory, + historySnapshot, + readHistory, +} from "@/features/history/server"; +import { requirePermission } from "@/lib/admin/guard"; +import { db } from "@/lib/db"; +import { logger } from "@/lib/logger"; +import { PERMS } from "@/lib/permissions"; +import { withCatalogExport } from "@/lib/services/catalog-git-queue"; +import { invalidateNewsCache } from "@/lib/services/news-cache"; + +async function authorized(id: number) { + await requirePermission(PERMS.LOGS_VIEW); + if (!Number.isSafeInteger(id) || id < 1) throw Error("unavailable"); + const entry = await db.transaction((tx) => readHistory(tx, id)); + const staff = await requirePermission( + entry.kind === "news" ? PERMS.NEWS_EDIT : PERMS.CATALOG_EDIT, + ); + return { entry, staff }; +} +export async function previewHistoryRestore(id: number) { + const { entry } = await authorized(id); + return db.transaction(async (tx) => { + const current = await historySnapshot(tx, entry.kind, entry.targetId); + return { + canRestore: sameSnapshot(current, entry.after), + changes: historyChanges(current, entry.before), + }; + }); +} +export async function restoreHistory(id: number) { + const { entry, staff } = await authorized(id); + try { + const restore = () => + db.transaction((tx) => applyHistory(tx, entry, staff.id)); + if (entry.kind === "news") await restore(); + else + await withCatalogExport(async () => { + await restore(); + return { ok: true as const, data: {} }; + }); + } catch (error) { + if (error instanceof Error && error.message === "conflict") + return { ok: false as const, code: "conflict" as const }; + logger.error("History restore failed", { module: "history", error }); + return { ok: false as const, code: "failed" as const }; + } + try { + if (entry.kind === "news") { + await invalidateNewsCache(); + revalidatePath("/news", "layout"); + revalidatePath("/admin/articles", "layout"); + revalidatePath("/"); + revalidatePath("/me"); + } else { + await sendCatalogUpdate(); + revalidatePath("/admin/catalog", "layout"); + } + } catch (error) { + logger.error("History refresh failed", { module: "history", error }); + } + revalidatePath("/admin/logs/audit"); + return { ok: true as const }; +} diff --git a/src/app/admin/catalog/maintenance/page.tsx b/src/app/admin/catalog/maintenance/page.tsx index fc05533b..0c7cc225 100644 --- a/src/app/admin/catalog/maintenance/page.tsx +++ b/src/app/admin/catalog/maintenance/page.tsx @@ -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 ; + const canRepair = canAccess( + permissions, + PERMS.CATALOG_EDIT, + session.user.rank, + ); + const t = await getTranslations("pages.admin.catalog.integrity"); + return ( +
+ + {canRepair && ( +
+ + {t("legacyTitle")} + +

+ {t("legacyHint")} +

+ +
+ )} +
+ ); } diff --git a/src/app/admin/devops/cms-errors/page.tsx b/src/app/admin/devops/cms-errors/page.tsx index ef45e737..8f1581f3 100644 --- a/src/app/admin/devops/cms-errors/page.tsx +++ b/src/app/admin/devops/cms-errors/page.tsx @@ -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>; try { @@ -40,30 +39,13 @@ export default async function CmsErrorsPage({ ); } - const allGroups = new Map(); - - 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 (
@@ -126,13 +108,46 @@ export default async function CmsErrorsPage({ + + +

{t("counts", { count: filtered.length, groups: groups.size })}{" "} - {result.truncated ? t("truncated") : ""} {t("retention")} + {result.truncated ? t("truncated") : ""} {t("retention")}{" "} + {t("filterScope")}

{groups.size === 0 ? (
{t("empty")}
@@ -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; diff --git a/src/app/admin/logs/audit/audit-table.tsx b/src/app/admin/logs/audit/audit-table.tsx index 40fe6372..2e05e316 100644 --- a/src/app/admin/logs/audit/audit-table.tsx +++ b/src/app/admin/logs/audit/audit-table.tsx @@ -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) => , + render: (_value, row) => ( +
+ + {["history_update", "history_restore"].includes(row.action) && ( + + )} +
+ ), }, { key: "createdAt", diff --git a/src/app/admin/operations/page.tsx b/src/app/admin/operations/page.tsx new file mode 100644 index 00000000..37cee0f9 --- /dev/null +++ b/src/app/admin/operations/page.tsx @@ -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 ; +} diff --git a/src/app/admin/permissions/page.tsx b/src/app/admin/permissions/page.tsx index 55647d3b..e1106c9a 100644 --- a/src/app/admin/permissions/page.tsx +++ b/src/app/admin/permissions/page.tsx @@ -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 ( - +
+ + {t("title")} + + +
); } diff --git a/src/app/admin/permissions/preview/page.test.tsx b/src/app/admin/permissions/preview/page.test.tsx new file mode 100644 index 00000000..96afe93a --- /dev/null +++ b/src/app/admin/permissions/preview/page.test.tsx @@ -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 }) => ( + {children} + ), +})); + +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"); +}); diff --git a/src/app/admin/permissions/preview/page.tsx b/src/app/admin/permissions/preview/page.tsx new file mode 100644 index 00000000..5f9c83d2 --- /dev/null +++ b/src/app/admin/permissions/preview/page.tsx @@ -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 | undefined; + let superAdmin = false; + let permissionTitles: Record = {}; + 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 ( +
+
+

{t("title")}

+

{t("description")}

+
+ + {t("back")} + +
+ + +
+ {!preview ? ( +

{t("missing")}

+ ) : ( + <> +
+

+ {t(preview.canEnter ? "entryAllowed" : "entryDenied")} +

+ {superAdmin &&

{t("superAdmin")}

} +

{t("scope")}

+
+
+

{t("sections")}

+
+ {preview.sections.map((item) => ( +
+
+ {nav(item.labelKey)} + + {t(item.allowed ? "allowed" : "denied")} + +
+

+ {item.href} +

+
+ ))} +
+
+
+ + {t("actions")} + +

+ {t("actionScope")} +

+
    + {preview.actions.map((item) => ( +
  • +
    +

    + {permissionTitles[item.slug] || item.slug} +

    + + {item.slug} + +
    + + {t(item.granted ? "granted" : "notGranted")} + +
  • + ))} +
+
+ + )} +
+ ); +} diff --git a/src/app/admin/settings/cms-settings-form.tsx b/src/app/admin/settings/cms-settings-form.tsx index f2bcfdd9..e7b3761a 100644 --- a/src/app/admin/settings/cms-settings-form.tsx +++ b/src/app/admin/settings/cms-settings-form.tsx @@ -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 (
+ {Object.keys(fieldErrors).length > 0 && ( +
+ {Object.values(fieldErrors).flat().join(" · ")} +
+ )} {SETTINGS_GROUPS.map((group) => ( ))} {canEdit ? (
- + {!monitorOnly && ( + + )} ); } diff --git a/src/app/admin/users/[id]/user-edit-form.tsx b/src/app/admin/users/[id]/user-edit-form.tsx index fe8aa306..02fffc1d 100644 --- a/src/app/admin/users/[id]/user-edit-form.tsx +++ b/src/app/admin/users/[id]/user-edit-form.tsx @@ -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(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({ 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) { Edit User - -
-
- - - {errors.username && ( -

- {errors.username.message} -

- )} + +
+
+
+ + + {errors.username && ( +

+ {errors.username.message} +

+ )} +
+
+ + + {errors.mail && ( +

+ {errors.mail.message} +

+ )} +
-
- - - {errors.mail && ( -

- {errors.mail.message} -

- )} -
-
-
-
- - +
+
+ + +
+
+ + + {errors.motto && ( +

+ {errors.motto.message} +

+ )} +
-
- - -
-
-
-
- - +
+
+ + + {errors.credits && ( +

+ {errors.credits.message} +

+ )} +
+
+ + + {errors.pixels && ( +

+ {errors.pixels.message} +

+ )} +
+
+ + + {errors.diamonds && ( +

+ {errors.diamonds.message} +

+ )} +
+
+ + + {errors.duckets && ( +

+ {errors.duckets.message} +

+ )} +
-
- - -
-
- - -
-
- - -
-
- + + diff --git a/src/app/api/admin/studio/import-jobs/route.test.ts b/src/app/api/admin/studio/import-jobs/route.test.ts index 9b1d15f0..b071aee7 100644 --- a/src/app/api/admin/studio/import-jobs/route.test.ts +++ b/src/app/api/admin/studio/import-jobs/route.test.ts @@ -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); +}); diff --git a/src/app/api/admin/studio/import-jobs/route.ts b/src/app/api/admin/studio/import-jobs/route.ts index 7b28b459..f68fe674 100644 --- a/src/app/api/admin/studio/import-jobs/route.ts +++ b/src/app/api/admin/studio/import-jobs/route.ts @@ -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, diff --git a/src/components/admin/article-form.tsx b/src/components/admin/article-form.tsx index 459a711d..b2690890 100644 --- a/src/components/admin/article-form.tsx +++ b/src/components/admin/article-form.tsx @@ -9,6 +9,7 @@ import type { ArticleSaveResult } from "@/lib/article-input"; import { slugify } from "@/lib/format"; import { ArticlePreview, type ArticlePreviewData } from "./article-preview"; import { useArticleRecovery } from "./article-recovery"; +import { FormFieldError, useFormErrorFocus } from "./form-feedback"; import { MediaPicker } from "./media-picker"; import { RichText } from "./rich-text"; @@ -36,6 +37,7 @@ export function ArticleForm({ const tAction = useTranslations("pages.admin.actions"); const [dirty, setDirty] = useState(false); const [pending, startTransition] = useTransition(); + const [fieldErrors, setFieldErrors] = useState>({}); const [saveError, setSaveError] = useState(null); const router = useRouter(); const saving = useRef(false); @@ -86,6 +88,7 @@ export function ArticleForm({ dirty, restoreDraft, ); + useFormErrorFocus(formRef, fieldErrors, pending); const [preview, setPreview] = useState(null); const suggestedSlug = useMemo(() => slugify(title), [title]); @@ -105,12 +108,14 @@ export function ArticleForm({ const submittedVersion = editVersion.current; saving.current = true; setSaveError(null); + setFieldErrors({}); startTransition(async () => { try { await recovery.wait(); const result = await action(data); if (result && !result.ok) { setSaveError(result.error); + setFieldErrors(result.fieldErrors ?? {}); return; } if (editVersion.current === submittedVersion) setDirty(false); @@ -151,6 +156,10 @@ export function ArticleForm({ setTitle(e.target.value)} @@ -158,6 +167,7 @@ export function ArticleForm({ required className="input input-bordered mt-1 w-full" /> +