From 33a6d9db62336c3daf9f9a9d20a9fda26a8870c2 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 10:21:17 +0100 Subject: [PATCH 01/12] fix(admin): retain navigation selections in URLs and close mobile drawer --- .../components/admin/AdminSidebar.test.tsx | 20 ++++- .../web/src/components/admin/AdminSidebar.tsx | 34 +++++++- .../activity/ActivityTabs.navigation.test.tsx | 69 ++++++++++++++++ .../admin/activity/ActivityTabs.tsx | 18 +++-- .../extensions/ExtensionStorePage.test.tsx | 5 ++ .../admin/extensions/ExtensionStorePage.tsx | 21 ++--- .../admin/integrations/IntegrationDetail.tsx | 34 ++++++-- .../IntegrationAdminConsumers.test.tsx | 5 ++ .../services/ServiceAdministration.test.tsx | 5 ++ .../admin/services/ServiceDetail.test.tsx | 5 ++ .../admin/services/ServiceDetail.tsx | 81 +++++++++++-------- .../admin/users/UserActions.test.tsx | 12 ++- .../src/components/admin/users/UserDetail.tsx | 41 +++++----- .../lib/admin/adminNavigationState.test.ts | 35 ++++++++ .../web/src/lib/admin/adminNavigationState.ts | 61 ++++++++++++++ apps/web/src/lib/admin/useAdminQueryState.ts | 22 +++++ apps/web/src/test/adminNavigation.tsx | 45 +++++++++++ 17 files changed, 430 insertions(+), 83 deletions(-) create mode 100644 apps/web/src/components/admin/activity/ActivityTabs.navigation.test.tsx create mode 100644 apps/web/src/lib/admin/adminNavigationState.test.ts create mode 100644 apps/web/src/lib/admin/adminNavigationState.ts create mode 100644 apps/web/src/lib/admin/useAdminQueryState.ts create mode 100644 apps/web/src/test/adminNavigation.tsx diff --git a/apps/web/src/components/admin/AdminSidebar.test.tsx b/apps/web/src/components/admin/AdminSidebar.test.tsx index 64de279eb..9d0601695 100644 --- a/apps/web/src/components/admin/AdminSidebar.test.tsx +++ b/apps/web/src/components/admin/AdminSidebar.test.tsx @@ -1,11 +1,15 @@ import { en } from "@openmapx/i18n"; import { NextIntlClientProvider } from "next-intl"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { cleanup, render, screen } from "@/test"; import { AdminSidebar } from "./AdminSidebar"; vi.mock("next/navigation", () => ({ usePathname: () => "/admin/services" })); -afterEach(cleanup); +beforeEach(() => vi.stubGlobal("matchMedia", () => ({ matches: false }))); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); it.each([true, null])( "preserves troubleshooting navigation for admin deployment %s", (selfHosted) => { @@ -64,3 +68,15 @@ it("does not offer privacy setup to privacy administrators", () => { ); expect(screen.queryByRole("link", { name: "Privacy setup" })).toBeNull(); }); + +it("closes mobile navigation on an ordinary destination selection", () => { + const close = vi.fn(); + render( + + + , + ); + document.addEventListener("click", (event) => event.preventDefault(), { once: true }); + screen.getAllByRole("link", { name: "Catalog" })[0].click(); + expect(close).toHaveBeenCalledTimes(1); +}); diff --git a/apps/web/src/components/admin/AdminSidebar.tsx b/apps/web/src/components/admin/AdminSidebar.tsx index 5bbf52093..9d737d4c2 100644 --- a/apps/web/src/components/admin/AdminSidebar.tsx +++ b/apps/web/src/components/admin/AdminSidebar.tsx @@ -184,6 +184,7 @@ function NavLink({ active, sectionActive = false, label = item.label, + onNavigate, }: { item: NavItem; active: boolean; @@ -195,6 +196,7 @@ function NavLink({ */ sectionActive?: boolean; label?: string; + onNavigate: (event: React.MouseEvent) => void; }) { // The leaf (Mui-selected) styling and the section-active styling are // mutually exclusive — `selected` flips MUI's filled background, which is @@ -205,6 +207,7 @@ function NavLink({ void; +}) { return ( { + if ( + !event.defaultPrevented && + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey && + !window.matchMedia("(min-width:900px)").matches + ) + onClose(); + }; const privacySetup = useTranslations("privacySetup"); const navItems = BASE_NAV_ITEMS.filter( @@ -359,6 +383,7 @@ export function AdminSidebar({ {SERVICES_SUB_ITEMS.map((sub) => ( - + ))} diff --git a/apps/web/src/components/admin/activity/ActivityTabs.navigation.test.tsx b/apps/web/src/components/admin/activity/ActivityTabs.navigation.test.tsx new file mode 100644 index 000000000..dc401a214 --- /dev/null +++ b/apps/web/src/components/admin/activity/ActivityTabs.navigation.test.tsx @@ -0,0 +1,69 @@ +import { useSyncExternalStore } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@/test"; +import { ActivityTabs } from "./ActivityTabs"; + +const history = vi.hoisted(() => { + let entries = ["/admin/activity?tab=audit&status=failed"], + index = 0; + const listeners = new Set<() => void>(); + return { + read: () => entries[index], + subscribe: (fn: () => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + push: (url: string) => { + entries = [...entries.slice(0, index + 1), url]; + index++; + listeners.forEach((fn) => { + fn(); + }); + }, + replace: (url: string) => { + entries[index] = url; + listeners.forEach((fn) => { + fn(); + }); + }, + back: () => { + index = Math.max(0, index - 1); + listeners.forEach((fn) => { + fn(); + }); + }, + reset: (url: string) => { + entries = [url]; + index = 0; + }, + }; +}); +vi.mock("next/navigation", () => ({ + usePathname: () => new URL(history.read(), "http://test").pathname, + useSearchParams: () => + new URLSearchParams(useSyncExternalStore(history.subscribe, history.read).split("?")[1]), + useRouter: () => ({ push: history.push, replace: history.replace }), +})); +vi.mock("./JobList", () => ({ JobList: () =>
Job inventory
})); +vi.mock("./AuditLog", () => ({ AuditLog: () =>
Audit inventory
})); +vi.mock("./AppLogViewer", () => ({ AppLogViewer: () =>
Application stream
})); +afterEach(cleanup); +it("restores the selected view from its URL and Back, keeping the job filter", () => { + history.reset("/admin/activity?tab=audit&status=failed"); + render(); + expect(screen.getByText("Audit inventory")).toBeInTheDocument(); + expect(screen.getByRole("tabpanel", { name: "Audit Log" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "Application Logs" })); + expect(screen.getByText("Application stream")).toBeInTheDocument(); + expect(history.read()).toBe("/admin/activity?tab=logs&status=failed"); + act(history.back); + expect(screen.getByText("Audit inventory")).toBeInTheDocument(); + expect(screen.getByRole("tabpanel", { name: "Audit Log" })).toBeInTheDocument(); +}); +it("defaults an unknown view to Jobs", () => { + history.reset("/admin/activity?tab=unknown"); + render(); + expect(screen.getByText("Job inventory")).toBeInTheDocument(); +}); diff --git a/apps/web/src/components/admin/activity/ActivityTabs.tsx b/apps/web/src/components/admin/activity/ActivityTabs.tsx index 698249dfb..31b88fb5d 100644 --- a/apps/web/src/components/admin/activity/ActivityTabs.tsx +++ b/apps/web/src/components/admin/activity/ActivityTabs.tsx @@ -3,26 +3,28 @@ import Stack from "@mui/material/Stack"; import Tab from "@mui/material/Tab"; import Tabs from "@mui/material/Tabs"; -import { useState } from "react"; +import { useAdminTab } from "@/lib/admin/useAdminQueryState"; import { AdminPageHeader } from "../shared/AdminPageHeader"; import { AppLogViewer } from "./AppLogViewer"; import { AuditLog } from "./AuditLog"; import { JobList } from "./JobList"; export function ActivityTabs() { - const [tab, setTab] = useState(0); + const [tab, setTab] = useAdminTab(["jobs", "audit", "logs"]); return ( setTab(v as number)} aria-label="Activity views"> - - - + + + - {tab === 0 && } - {tab === 1 && } - {tab === 2 && } +
+ {tab === 0 && } + {tab === 1 && } + {tab === 2 && } +
); } diff --git a/apps/web/src/components/admin/extensions/ExtensionStorePage.test.tsx b/apps/web/src/components/admin/extensions/ExtensionStorePage.test.tsx index 7d3f16869..d9e11a101 100644 --- a/apps/web/src/components/admin/extensions/ExtensionStorePage.test.tsx +++ b/apps/web/src/components/admin/extensions/ExtensionStorePage.test.tsx @@ -567,3 +567,8 @@ describe("destructive target exclusion survives tab disposal", () => { }, ); }); + +vi.mock( + "next/navigation", + async () => (await import("@/test/adminNavigation")).adminNavigationMock, +); diff --git a/apps/web/src/components/admin/extensions/ExtensionStorePage.tsx b/apps/web/src/components/admin/extensions/ExtensionStorePage.tsx index d8853e6d3..77d963ce2 100644 --- a/apps/web/src/components/admin/extensions/ExtensionStorePage.tsx +++ b/apps/web/src/components/admin/extensions/ExtensionStorePage.tsx @@ -60,6 +60,7 @@ import { useUpdateExtension, } from "@/hooks/useExtensions"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; +import { useAdminTab } from "@/lib/admin/useAdminQueryState"; import { AdminPageHeader } from "../shared/AdminPageHeader"; import { AdminTablePagination } from "../shared/AdminTablePagination"; import { AdminTableSurface } from "../shared/AdminTableSurface"; @@ -918,7 +919,7 @@ function SourcesTab() { } function ExtensionStoreContent() { - const [tab, setTab] = useState(0); + const [tab, setTab] = useAdminTab(["browse", "installed", "sources", "local"]); return (
setTab(v)} aria-label="Extension management"> - - - - + + + + - {tab === 0 && } - {tab === 1 && } - {tab === 2 && } - {tab === 3 && } +
+ {tab === 0 && } + {tab === 1 && } + {tab === 2 && } + {tab === 3 && } +
); } diff --git a/apps/web/src/components/admin/integrations/IntegrationDetail.tsx b/apps/web/src/components/admin/integrations/IntegrationDetail.tsx index 7e55d69ea..a0c8172a0 100644 --- a/apps/web/src/components/admin/integrations/IntegrationDetail.tsx +++ b/apps/web/src/components/admin/integrations/IntegrationDetail.tsx @@ -40,6 +40,7 @@ import { useIntegrationReload, } from "@/lib/admin/integrationAdmin"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; +import { useAdminTab } from "@/lib/admin/useAdminQueryState"; import { AdminTablePagination } from "../shared/AdminTablePagination"; import { useAdminToast } from "../shared/AdminToast"; import { MetaRow } from "../shared/MetaRow"; @@ -1060,7 +1061,13 @@ interface IntegrationDetailProps { export function IntegrationDetail({ id }: IntegrationDetailProps) { const admin = useIntegrationAdmin(); - const [tab, setTab] = useState(0); + const [tab, setTab] = useAdminTab([ + "overview", + "configuration", + "credentials", + "health", + "sources", + ]); const showToast = useAdminToast(); const { data, isLoading, isError } = useQuery(admin.queries.detail(id)); @@ -1211,16 +1218,27 @@ export function IntegrationDetail({ id }: IntegrationDetailProps) { variant="scrollable" scrollButtons="auto" > - {TABS.map((label) => ( - + {TABS.map((label, index) => ( + ))} - {tab === 0 && } - {tab === 1 && } - {tab === 2 && } - {tab === 3 && } - {tab === 4 && } +
+ {tab === 0 && } + {tab === 1 && } + {tab === 2 && } + {tab === 3 && } + {tab === 4 && } +
); diff --git a/apps/web/src/components/admin/integrations/__tests__/IntegrationAdminConsumers.test.tsx b/apps/web/src/components/admin/integrations/__tests__/IntegrationAdminConsumers.test.tsx index e2029c5d2..0fa0069a0 100644 --- a/apps/web/src/components/admin/integrations/__tests__/IntegrationAdminConsumers.test.tsx +++ b/apps/web/src/components/admin/integrations/__tests__/IntegrationAdminConsumers.test.tsx @@ -965,3 +965,8 @@ describe("integration administration browser behavior", () => { }, ); }); + +vi.mock( + "next/navigation", + async () => (await import("@/test/adminNavigation")).adminNavigationMock, +); diff --git a/apps/web/src/components/admin/services/ServiceAdministration.test.tsx b/apps/web/src/components/admin/services/ServiceAdministration.test.tsx index 09c554584..46901fa05 100644 --- a/apps/web/src/components/admin/services/ServiceAdministration.test.tsx +++ b/apps/web/src/components/admin/services/ServiceAdministration.test.tsx @@ -1007,3 +1007,8 @@ it.each(["file", "env"])( } }, ); + +vi.mock( + "next/navigation", + async () => (await import("@/test/adminNavigation")).adminNavigationMock, +); diff --git a/apps/web/src/components/admin/services/ServiceDetail.test.tsx b/apps/web/src/components/admin/services/ServiceDetail.test.tsx index 7fcdde242..0faad7bef 100644 --- a/apps/web/src/components/admin/services/ServiceDetail.test.tsx +++ b/apps/web/src/components/admin/services/ServiceDetail.test.tsx @@ -58,3 +58,8 @@ describe("ServiceDetail managed Dawarich setup", () => { ); }); }); + +vi.mock( + "next/navigation", + async () => (await import("@/test/adminNavigation")).adminNavigationMock, +); diff --git a/apps/web/src/components/admin/services/ServiceDetail.tsx b/apps/web/src/components/admin/services/ServiceDetail.tsx index 9635fb76f..1e97dcf78 100644 --- a/apps/web/src/components/admin/services/ServiceDetail.tsx +++ b/apps/web/src/components/admin/services/ServiceDetail.tsx @@ -33,6 +33,7 @@ import { } from "@/hooks/useServices"; import { serviceActionReason } from "@/lib/admin/serviceAdmin"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; +import { useAdminTab } from "@/lib/admin/useAdminQueryState"; import { useActivityClient } from "../activity/activityQueries"; import { StatusBadge } from "../integrations/StatusBadge"; import { useAdminToast } from "../shared/AdminToast"; @@ -501,7 +502,13 @@ export function ServiceDetail({ id }: ServiceDetailProps) { function ServiceDetailView({ id }: ServiceDetailProps) { const { client } = useActivityClient(); const owner = useAdminMutationOwner(client, id); - const [tab, setTab] = useState(0); + const [tab, setTab] = useAdminTab([ + "overview", + "configuration", + "credentials", + "logs", + "manifest", + ]); const [logsOpen, setLogsOpen] = useState(false); const showToast = useAdminToast(); @@ -739,44 +746,52 @@ function ServiceDetailView({ id }: ServiceDetailProps) { variant="scrollable" scrollButtons="auto" > - {TABS.map((label) => ( - + {TABS.map((label, index) => ( + ))} - {tab === 0 && } - {tab === 1 && } - {tab === 2 && } - {tab === 3 && ( - - + {tab === 0 && } + {tab === 1 && } + {tab === 2 && } + {tab === 3 && ( + - Click "Logs" or switch to this tab to open the live log stream. - - - - )} - {tab === 4 && } + + Click "Logs" or switch to this tab to open the live log stream. + + + + )} + {tab === 4 && } + ({ vi.mock("@/integration-api/runtime/EnvProvider", () => ({ useEnv: () => ({ apiUrl: "http://admin-a.test" }), })); -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: mocks.push, refresh: mocks.refresh }), +vi.mock("next/navigation", async () => ({ + ...(await import("@/test/adminNavigation")).adminNavigationMock, + useRouter: () => ({ + push: (url: string) => { + mocks.push(url); + adminTestHistory.push(url); + }, + refresh: mocks.refresh, + }), })); vi.mock("../shared/AdminToast", () => ({ useAdminToast: () => mocks.toast })); const caches: ReturnType[] = []; diff --git a/apps/web/src/components/admin/users/UserDetail.tsx b/apps/web/src/components/admin/users/UserDetail.tsx index 58a68c9ae..1932284e0 100644 --- a/apps/web/src/components/admin/users/UserDetail.tsx +++ b/apps/web/src/components/admin/users/UserDetail.tsx @@ -35,6 +35,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useAccountAction, useAccountAdmin } from "@/lib/admin/accountAdmin"; +import { useAdminTab } from "@/lib/admin/useAdminQueryState"; import { AdminPageHeader } from "../shared/AdminPageHeader"; import { AdminTablePagination } from "../shared/AdminTablePagination"; import { ConfirmDialog } from "../shared/ConfirmDialog"; @@ -435,7 +436,7 @@ function AccountsTab() { } export function UserDetail({ userId }: { userId: string }) { - const [tab, setTab] = useState(0); + const [tab, setTab] = useAdminTab(["profile", "sessions", "accounts"]); const account = useAccountAdmin(); const currentUserId = account.actorId; const router = useRouter(); @@ -512,27 +513,29 @@ export function UserDetail({ userId }: { userId: string }) { /> setTab(v)} aria-label="User detail sections"> - - - + + + - {tab === 0 && ( - impersonate.mutate((client) => client.impersonate(userId)), - }} - /> - )} - {tab === 1 && ( - - )} - {tab === 2 && } +
+ {tab === 0 && ( + impersonate.mutate((client) => client.impersonate(userId)), + }} + /> + )} + {tab === 1 && ( + + )} + {tab === 2 && } +
); diff --git a/apps/web/src/lib/admin/adminNavigationState.test.ts b/apps/web/src/lib/admin/adminNavigationState.test.ts new file mode 100644 index 000000000..aafb504e1 --- /dev/null +++ b/apps/web/src/lib/admin/adminNavigationState.test.ts @@ -0,0 +1,35 @@ +import { expect, it } from "vitest"; +import { adminQueryHref, parseAdminCaseSelection } from "./adminNavigationState"; + +it("encodes selection, drops arbitrary query values and preserves supported filters", () => { + expect( + adminQueryHref("/admin/activity", new URLSearchParams("status=failed&page=2&password=never"), { + job: "a /b", + source: "data-manager", + tab: "jobs", + }), + ).toBe("/admin/activity?status=failed&page=2&job=a+%2Fb&source=data-manager&tab=jobs"); +}); +it("clears only the named selection keys", () => { + expect( + adminQueryHref( + "/admin/activity", + new URLSearchParams("tab=jobs&job=a&source=application&status=failed"), + { job: null, source: null }, + ), + ).toBe("/admin/activity?tab=jobs&status=failed"); +}); +it.each(["", " ", "\n", "x".repeat(257)])( + "rejects an empty or unsafe privacy case identifier", + (id) => { + expect(parseAdminCaseSelection(new URLSearchParams({ case: id, section: "unknown" }))).toEqual({ + caseId: null, + section: "identity", + }); + }, +); +it("keeps a valid case and section for direct navigation", () => { + expect(parseAdminCaseSelection(new URLSearchParams("case=case-123§ion=attachments"))).toEqual( + { caseId: "case-123", section: "attachments" }, + ); +}); diff --git a/apps/web/src/lib/admin/adminNavigationState.ts b/apps/web/src/lib/admin/adminNavigationState.ts new file mode 100644 index 000000000..8f6a3a3df --- /dev/null +++ b/apps/web/src/lib/admin/adminNavigationState.ts @@ -0,0 +1,61 @@ +/** Only public navigation state belongs in URLs. Never add form values or proofs. */ +const QUERY_KEYS = new Set([ + "tab", + "status", + "page", + "job", + "source", + "returnTo", + "case", + "section", + "q", + "field", + "filter", + "region", + "service", +]); +export function adminQueryHref( + pathname: string, + current: URLSearchParams, + changes: Record, +): string { + const params = new URLSearchParams(); + for (const [key, value] of current) + if (QUERY_KEYS.has(key) && value.length <= 2048) params.set(key, value); + for (const [key, value] of Object.entries(changes)) { + if (!QUERY_KEYS.has(key)) continue; + if (value === null) params.delete(key); + else if (value.length <= 2048) params.set(key, value); + } + const query = params.toString(); + return `${pathname}${query ? `?${query}` : ""}`; +} +export const PRIVACY_CASE_SECTIONS = [ + "identity", + "tasks", + "decisions", + "attachments", + "backups", + "delivery", + "events", +] as const; +export type PrivacyCaseSection = (typeof PRIVACY_CASE_SECTIONS)[number]; +export function parseAdminCaseSelection(params: Pick): { + caseId: string | null; + section: PrivacyCaseSection; +} { + const id = params.get("case"); + const section = params.get("section"); + return { + caseId: + id && + id.trim().length > 0 && + id.length <= 256 && + !Array.from(id).some( + (character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127, + ) + ? id + : null, + section: PRIVACY_CASE_SECTIONS.find((value) => value === section) ?? "identity", + }; +} diff --git a/apps/web/src/lib/admin/useAdminQueryState.ts b/apps/web/src/lib/admin/useAdminQueryState.ts new file mode 100644 index 000000000..e78b43165 --- /dev/null +++ b/apps/web/src/lib/admin/useAdminQueryState.ts @@ -0,0 +1,22 @@ +"use client"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { adminQueryHref } from "./adminNavigationState"; + +export function useAdminQueryState() { + const router = useRouter(); + const pathname = usePathname(); + const params = useSearchParams(); + return { + params, + update(changes: Record, mode: "push" | "replace" = "push") { + router[mode](adminQueryHref(pathname, new URLSearchParams(params.toString()), changes), { + scroll: false, + }); + }, + }; +} +export function useAdminTab(tabs: readonly string[]) { + const { params, update } = useAdminQueryState(); + const index = tabs.indexOf(params.get("tab") ?? ""); + return [Math.max(0, index), (value: number) => update({ tab: tabs[value] ?? null })] as const; +} diff --git a/apps/web/src/test/adminNavigation.tsx b/apps/web/src/test/adminNavigation.tsx new file mode 100644 index 000000000..3ac7d264c --- /dev/null +++ b/apps/web/src/test/adminNavigation.tsx @@ -0,0 +1,45 @@ +/** Unit-only Next navigation adapter. Browser history acceptance is verified separately. */ +import { useSyncExternalStore } from "react"; +import { afterEach } from "vitest"; + +let entries = ["/admin"], + index = 0; +const listeners = new Set<() => void>(); +const emit = () => + listeners.forEach((fn) => { + fn(); + }); +const read = () => entries[index]; +const subscribe = (fn: () => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +}; +export const adminTestHistory = { + push(url: string) { + entries = [...entries.slice(0, index + 1), url]; + index++; + emit(); + }, + replace(url: string) { + entries[index] = url; + emit(); + }, + back() { + index = Math.max(0, index - 1); + emit(); + }, + reset(url = "/admin") { + entries = [url]; + index = 0; + emit(); + }, +}; +afterEach(() => adminTestHistory.reset()); +export const adminNavigationMock = { + useRouter: () => adminTestHistory, + usePathname: () => new URL(useSyncExternalStore(subscribe, read, read), "http://test").pathname, + useSearchParams: () => + new URLSearchParams(useSyncExternalStore(subscribe, read, read).split("?")[1]), +}; From d0dd09c98411206be9ab0c9501d5dd95c21113f8 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 10:34:45 +0100 Subject: [PATCH 02/12] fix(admin): preserve ordinary drafts across navigation and authority refreshes --- apps/web/src/app/admin/layout.tsx | 2 +- apps/web/src/app/providers.tsx | 27 +-- apps/web/src/components/admin/AdminLayout.tsx | 5 + .../admin/integrations/ConfigSchemaForm.tsx | 1 + .../admin/privacy/PrivacyArtifactDelivery.tsx | 9 +- .../admin/privacy/PrivacyAttachments.tsx | 4 +- .../services/ArtifactTransfersSection.tsx | 3 +- .../admin/services/ServiceConfigForm.tsx | 1 + .../admin/settings/SystemSettings.tsx | 164 ++++++++++----- .../__tests__/SystemSettings.test.tsx | 179 +++++++++++++++-- .../admin/shared/SchemaConfigForm.tsx | 92 ++++----- .../SchemaConfigForm.ownership.test.tsx | 39 ++++ .../users/AdminReauthenticationDialog.tsx | 13 +- .../admin/users/UserActions.test.tsx | 40 ++++ .../src/components/admin/users/UserDetail.tsx | 7 +- .../src/components/admin/users/UserList.tsx | 9 +- .../components/auth/PrivacyDataSection.tsx | 4 +- .../src/lib/admin/AdminDraftProvider.test.tsx | 92 +++++++++ apps/web/src/lib/admin/AdminDraftProvider.tsx | 117 +++++++++++ apps/web/src/lib/admin/adminDocumentExit.ts | 25 +++ .../web/src/lib/admin/adminDraftStore.test.ts | 112 +++++++++++ apps/web/src/lib/admin/adminDraftStore.ts | 188 ++++++++++++++++++ apps/web/src/lib/admin/useAdminDraft.ts | 56 ++++++ 23 files changed, 1045 insertions(+), 144 deletions(-) create mode 100644 apps/web/src/lib/admin/AdminDraftProvider.test.tsx create mode 100644 apps/web/src/lib/admin/AdminDraftProvider.tsx create mode 100644 apps/web/src/lib/admin/adminDocumentExit.ts create mode 100644 apps/web/src/lib/admin/adminDraftStore.test.ts create mode 100644 apps/web/src/lib/admin/adminDraftStore.ts create mode 100644 apps/web/src/lib/admin/useAdminDraft.ts diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx index a12a85223..4f4522db5 100644 --- a/apps/web/src/app/admin/layout.tsx +++ b/apps/web/src/app/admin/layout.tsx @@ -14,7 +14,7 @@ async function getAdminSession() { if (!res.ok) return null; return res.json() as Promise<{ - user: { name: string; email: string; image?: string; role?: string }; + user: { id: string; name: string; email: string; image?: string; role?: string }; }>; } diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index c1939f614..dd4a7f62e 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -12,6 +12,7 @@ import { registerBuiltinIdSchemeViews } from "@openmapx/place-ids"; import { QueryClientProvider } from "@tanstack/react-query"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; +import { AdminDraftRoot } from "@/lib/admin/AdminDraftProvider"; import { ImpersonationBanner } from "../components/admin/ImpersonationBanner"; import { SavedPlacesMirror } from "../components/pwa/SavedPlacesMirror"; import { ThemeColorSync } from "../components/pwa/ThemeColorSync"; @@ -97,22 +98,24 @@ function PhoneProviders({ children }: { children: React.ReactNode }) { */} - - - - - - {/* + + + + + + + {/* Mounted outside `IntegrationProvider` on purpose: the installed shell forbids executing administrator-installed frontend bundles, and that guard has to be answerable before the provider that would append them. */} - - - {children} - - - + + + {children} + + + + ); diff --git a/apps/web/src/components/admin/AdminLayout.tsx b/apps/web/src/components/admin/AdminLayout.tsx index 52c363999..f7128b789 100644 --- a/apps/web/src/components/admin/AdminLayout.tsx +++ b/apps/web/src/components/admin/AdminLayout.tsx @@ -3,6 +3,8 @@ import Box from "@mui/material/Box"; import CircularProgress from "@mui/material/CircularProgress"; import { useCallback, useState } from "react"; +import { useEnv } from "@/integration-api/runtime/EnvProvider"; +import { useBindAdminDraftOwner } from "@/lib/admin/AdminDraftProvider"; import { AdminJobEffectsProvider } from "@/lib/admin/adminJobEffects"; import { useHydrated } from "@/lib/useHydrated"; import { AdminSidebar } from "./AdminSidebar"; @@ -12,6 +14,7 @@ import { AdminThemeProvider } from "./shared/AdminThemeProvider"; import { AdminToastProvider } from "./shared/AdminToast"; interface AdminUser { + id: string; name: string; email: string; image?: string; @@ -25,6 +28,8 @@ interface AdminLayoutProps { } export function AdminLayout({ children, user, selfHosted = false }: AdminLayoutProps) { + const { apiUrl } = useEnv(); + useBindAdminDraftOwner({ apiUrl, actorId: user.id, role: user.role ?? "admin" }); const hydrated = useHydrated(); const [sidebarOverride, setSidebarOpen] = useState(null); const sidebarOpen = diff --git a/apps/web/src/components/admin/integrations/ConfigSchemaForm.tsx b/apps/web/src/components/admin/integrations/ConfigSchemaForm.tsx index ebbd0804b..1050e9184 100644 --- a/apps/web/src/components/admin/integrations/ConfigSchemaForm.tsx +++ b/apps/web/src/components/admin/integrations/ConfigSchemaForm.tsx @@ -51,6 +51,7 @@ export function ConfigSchemaForm({ return ( ("assisted"), [reason, setReason] = useState(""), @@ -103,7 +107,8 @@ function Artifact({ expiresAt: result.expiresAt, channel, }); - window.location.assign("/auth/privacy/admin"); + if (await requestExit("Administrator reauthentication leaves this page.")) + navigateAdminDocument("/auth/privacy/admin", "reauthentication"); } catch { setNotice("resumeUnavailable"); } @@ -159,7 +164,7 @@ function Artifact({ setCompleted(false); clearPendingPrivacyAdminDelivery(); if (target.kind === "native") { - window.location.assign(target.target.url); + navigateAdminDocument(target.target.url, "download"); setNotice("browserOwnsDownload"); } else if (sink) { await consumePrivacyDownload({ diff --git a/apps/web/src/components/admin/privacy/PrivacyAttachments.tsx b/apps/web/src/components/admin/privacy/PrivacyAttachments.tsx index fa325ecf0..ecb2dfa47 100644 --- a/apps/web/src/components/admin/privacy/PrivacyAttachments.tsx +++ b/apps/web/src/components/admin/privacy/PrivacyAttachments.tsx @@ -1,4 +1,5 @@ "use client"; + import { Alert, Button, MenuItem, Stack, TextField, Typography } from "@mui/material"; import * as sdk from "@openmapx/core/api-admin-sdk"; import type { PrivacyAdminDetail } from "@openmapx/core/api-contracts"; @@ -9,6 +10,7 @@ import { } from "@openmapx/core/privacy"; import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; +import { navigateAdminDocument } from "@/lib/admin/adminDocumentExit"; import { type PrivacyCaseClient, privacyResult } from "@/lib/admin/privacyAdmin"; import { consumePrivacyDownload, @@ -101,7 +103,7 @@ export function PrivacyAttachments({ client.client, ); if (!executing.active) return; - if (capability.kind === "native") window.location.assign(capability.target.url); + if (capability.kind === "native") navigateAdminDocument(capability.target.url, "download"); else if (capability.kind === "sink") { const sink = await createPrivacyFileSink(filename); if (!executing.active) { diff --git a/apps/web/src/components/admin/services/ArtifactTransfersSection.tsx b/apps/web/src/components/admin/services/ArtifactTransfersSection.tsx index f6a24f16e..e69a7eb9e 100644 --- a/apps/web/src/components/admin/services/ArtifactTransfersSection.tsx +++ b/apps/web/src/components/admin/services/ArtifactTransfersSection.tsx @@ -18,6 +18,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import Dialog from "@/components/ui/Dialog"; +import { navigateAdminDocument } from "@/lib/admin/adminDocumentExit"; import { useAdminJobRegistration } from "@/lib/admin/adminJobEffects"; import { artifactFileSinkSupported, @@ -138,7 +139,7 @@ function ArtifactTransfersView({ apiUrl }: { apiUrl: string }) { client, }); if (owner.active) { - window.location.assign(target.url); + navigateAdminDocument(target.url, "download"); setMessage( "Download handed to your browser. Use browser controls to monitor or cancel it.", ); diff --git a/apps/web/src/components/admin/services/ServiceConfigForm.tsx b/apps/web/src/components/admin/services/ServiceConfigForm.tsx index a6fe0b291..c72ea56f6 100644 --- a/apps/web/src/components/admin/services/ServiceConfigForm.tsx +++ b/apps/web/src/components/admin/services/ServiceConfigForm.tsx @@ -49,6 +49,7 @@ export function ServiceConfigForm({ ); return ( JSON.stringify(value ?? null, null, 2)); const [invalid, setInvalid] = useState(false); + const serialized = JSON.stringify(value ?? null); + const previousValue = useRef(serialized); + useEffect(() => { + if (previousValue.current === serialized) return; + previousValue.current = serialized; + setDraft((current) => { + try { + if (JSON.stringify(JSON.parse(current)) === serialized) return current; + } catch { + /* New server values replace an invalid clean baseline. */ + } + return JSON.stringify(JSON.parse(serialized), null, 2); + }); + setInvalid(false); + onValidityChange(true); + }, [serialized, onValidityChange]); return ( ; onSaved: (msg: string) => void; extra?: ReactNode; description?: string; @@ -400,59 +422,44 @@ function SettingsGroupPanel({ text: SettingsEditorText; }) { const env = useEnv(); + const { client } = useActivityClient(); + const owner = useAdminMutationOwner(client, group.id); const qc = useQueryClient(); - const serverSnapshot = useMemo( - () => ({ - signature: JSON.stringify( - group.settings.map((setting) => ({ - key: setting.key, - value: setting.value, - source: setting.source, - envOverride: setting.envOverride, - envVar: setting.envVar, - type: setting.type, - options: setting.options, - showWhen: setting.showWhen, - })), - ), - values: Object.fromEntries(group.settings.map((setting) => [setting.key, setting.value])), - }), - [group.settings], + const serverValues = Object.fromEntries( + group.settings.map((setting) => [setting.key, setting.value]), ); - const [localValues, setLocalValues] = useState>(serverSnapshot.values); + const [secretValues, setSecretValues] = useState>({}); const [invalidKeys, setInvalidKeys] = useState>(() => new Set()); - const appliedServerSignature = useRef(serverSnapshot.signature); - const serverRevision = useRef({ signature: serverSnapshot.signature, count: 0 }); - const serverChanged = serverRevision.current.signature !== serverSnapshot.signature; - if (serverChanged) { - serverRevision.current = { - signature: serverSnapshot.signature, - count: serverRevision.current.count + 1, - }; - } - const renderedValues = serverChanged ? serverSnapshot.values : localValues; - - // A parent rerender must not discard unsaved edits. Re-sync only when the - // effective values or server-enforced editing rules actually changed. - useEffect(() => { - if (appliedServerSignature.current === serverSnapshot.signature) return; - appliedServerSignature.current = serverSnapshot.signature; - setLocalValues(serverSnapshot.values); - setInvalidKeys(new Set()); - }, [serverSnapshot]); + const renderedValues = { ...serverValues, ...draft.values, ...secretValues }; + const dirtyKeys = group.settings + .filter( + (setting) => + !setting.envOverride && + (setting.secret + ? Object.hasOwn(secretValues, setting.key) && secretValues[setting.key] !== setting.value + : draft.dirtyKeys.includes(setting.key)), + ) + .map((setting) => setting.key); + const conflicts = draft.conflicts.filter((key) => + group.settings.some((setting) => setting.key === key), + ); const save = useMutation({ + retry: false, mutationFn: async () => { + const acknowledge = draft.acknowledge; + const execution = owner; + const executingApiUrl = env.apiUrl; const body: Record = {}; for (const s of group.settings) { - if (s.envOverride) continue; + if (s.envOverride || !dirtyKeys.includes(s.key)) continue; // Keep configured credentials unless the operator replaces or explicitly clears them. - if (s.secret && localValues[s.key] === "***") continue; + if (s.secret && renderedValues[s.key] === "***") continue; // Don't write fields hidden by their showWhen predicate — saving while // a field is hidden would clobber the stored value of the option that // isn't currently active. - if (!isVisible(s, localValues)) continue; - body[s.key] = localValues[s.key]; + if (!isVisible(s, renderedValues)) continue; + body[s.key] = renderedValues[s.key]; } const res = await fetch(`${env.apiUrl}/api/admin/settings`, { method: "PATCH", @@ -461,10 +468,22 @@ function SettingsGroupPanel({ body: JSON.stringify(body), }); if (!res.ok) throw new Error("Failed to save settings"); - return res.json(); + await res.json(); + acknowledge( + Object.fromEntries( + Object.entries(body).filter(([key]) => + group.settings.some((setting) => setting.key === key && !setting.secret), + ), + ), + ); + return { body, execution, executingApiUrl }; }, - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["admin", "settings"] }); + onSuccess: ({ body, execution, executingApiUrl }) => { + void qc.invalidateQueries({ queryKey: ["admin", "settings", executingApiUrl] }); + if (!execution.active) return; + setSecretValues((current) => + Object.fromEntries(Object.entries(current).filter(([key, value]) => body[key] !== value)), + ); onSaved(text.saved(group.label)); }, }); @@ -523,6 +542,22 @@ function SettingsGroupPanel({ gap: 2.5, }} > + {conflicts.length > 0 && ( + draft.keepEdits(group.settings.map((setting) => setting.key))} + disabled={save.isPending} + > + Keep my edits + + } + > + Saved values changed for {conflicts.join(", ")}. Review or discard your edits before + saving. + + )} {groupBySubgroup(visibleSettings).map((section) => ( setLocalValues((prev) => ({ ...prev, [s.key]: v }))} + onChange={(v) => + s.secret + ? setSecretValues((previous) => ({ ...previous, [s.key]: v })) + : draft.setValues((previous) => ({ ...previous, [s.key]: v })) + } onValidityChange={(valid) => setInvalidKeys((current) => { const next = new Set(current); @@ -628,12 +667,27 @@ function SettingsGroupPanel({ onClick={() => save.mutate()} disabled={ save.isPending || + dirtyKeys.length === 0 || + conflicts.length > 0 || invalidKeys.size > 0 || visibleSettings.every((s) => s.envOverride) } > {text.save(group.label)} + {dirtyKeys.length > 0 && ( + + )} {extra} {save.isError && ( @@ -883,10 +937,12 @@ export function SystemSettings({ onSettingsSaved?: () => void | Promise; } = {}) { const env = useEnv(); + const draftStore = useContext(AdminDraftContext); const [toast, setToast] = useState<{ msg: string; sev: "success" | "error" } | null>(null); const { data, isLoading } = useQuery<{ groups: SettingsGroup[] }>({ - queryKey: ["admin", "settings"], + queryKey: ["admin", "settings", env.apiUrl, draftStore?.scopeKey ?? "isolated"], + enabled: !draftStore || draftStore.scopeKey !== null, queryFn: async () => { const res = await fetch(`${env.apiUrl}/api/admin/settings`, { credentials: "include" }); if (!res.ok) throw new Error("Failed to load settings"); @@ -894,6 +950,17 @@ export function SystemSettings({ }, }); + const ordinarySettings = (data?.groups ?? []) + .flatMap((group) => group.settings) + .filter((setting) => !setting.secret && !setting.envOverride && setting.source !== "env"); + const draft = useAdminDraft({ + id: "settings", + baseline: Object.fromEntries(ordinarySettings.map((setting) => [setting.key, setting.value])), + editableKeys: ordinarySettings.map((setting) => setting.key), + signature: "settings-v1", + ready: !!data, + }); + const showToast = (msg: string, sev: "success" | "error" = "success") => setToast({ msg, sev }); const handleSaved = (msg: string) => { showToast(msg); @@ -968,6 +1035,7 @@ export function SystemSettings({ { await screen.findByLabelText("Privacy sources"); act(() => { - client.setQueryData(["admin", "settings"], { - groups: [ - { - id: "general", - label: "Legal", - settings: [ - { - group: "legal", - key: "legalPrivacySources", - label: "Privacy sources", - type: "object", - secret: false, - value: [{ title: "Updated source" }], - source: "database", - envOverride: false, - }, - ], - }, - ], - }); + client.setQueriesData( + { queryKey: ["admin", "settings"] }, + { + groups: [ + { + id: "general", + label: "Legal", + settings: [ + { + group: "legal", + key: "legalPrivacySources", + label: "Privacy sources", + type: "object", + secret: false, + value: [{ title: "Updated source" }], + source: "database", + envOverride: false, + }, + ], + }, + ], + }, + ); }); await waitFor(() => @@ -372,6 +376,8 @@ describe("configured secret preservation", () => { ]); render(, { wrapper: createQueryWrapper() }); await screen.findByLabelText("MapTiler API Key"); + fireEvent.mouseDown(screen.getByRole("combobox")); + fireEvent.click(await screen.findByRole("option", { name: "maptiler" })); fireEvent.click(screen.getByRole("button", { name: "Save Map" })); await waitFor(() => { const patch = fetchMock.mock.calls.find( @@ -379,8 +385,139 @@ describe("configured secret preservation", () => { ); expect(patch).toBeDefined(); expect(JSON.parse(String((patch?.[1] as RequestInit).body))).toEqual({ - hostedBasemapProvider: "openfreemap", + hostedBasemapProvider: "maptiler", }); }); }); }); + +it("keeps another administrator's clean field while saving only the dirty key", async () => { + const settings = [ + { + group: "general", + key: "instanceName", + label: "Instance Name", + type: "string", + secret: false, + value: "old name", + source: "database" as const, + envOverride: false, + }, + { + group: "general", + key: "instanceUrl", + label: "Instance URL", + type: "string", + secret: false, + value: "https://old.test", + source: "database" as const, + envOverride: false, + }, + ]; + mockGroup("General", settings); + const client = createTestQueryClient(); + render(, { wrapper: createQueryWrapper(client) }); + fireEvent.change(await screen.findByLabelText("Instance Name"), { target: { value: "mine" } }); + act(() => + client.setQueriesData( + { queryKey: ["admin", "settings"] }, + { + groups: [ + { + id: "general", + label: "General", + settings: [settings[0], { ...settings[1], value: "https://other.test" }], + }, + ], + }, + ), + ); + expect(screen.getByLabelText("Instance Name")).toHaveValue("mine"); + await waitFor(() => + expect(screen.getByLabelText("Instance URL")).toHaveValue("https://other.test"), + ); + fireEvent.click(screen.getByRole("button", { name: "Save General" })); + await waitFor(() => { + const call = fetchMock.mock.calls.find( + ([, init]) => (init as RequestInit | undefined)?.method === "PATCH", + ); + expect(call).toBeDefined(); + expect(JSON.parse(String((call?.[1] as RequestInit | undefined)?.body))).toEqual({ + instanceName: "mine", + }); + }); +}); + +it("shares legal edits with another settings destination while disposing typed secrets", async () => { + const settings = [ + { + group: "general", + key: "controller", + label: "Controller", + type: "string", + secret: false, + value: "saved controller", + source: "database" as const, + envOverride: false, + }, + { + group: "general", + key: "password", + label: "Password", + type: "string", + secret: true, + value: "***", + source: "database" as const, + envOverride: false, + }, + { + group: "general", + key: "instanceName", + label: "Instance Name", + type: "string", + secret: false, + value: "saved name", + source: "database" as const, + envOverride: false, + }, + ]; + mockGroup("General", settings); + const view = (topic: string) => ( + + + + ); + const { rerender } = render(view("privacy"), { wrapper: createQueryWrapper() }); + fireEvent.change(await screen.findByLabelText("Controller"), { + target: { value: "draft controller" }, + }); + fireEvent.change(screen.getByLabelText("Password"), { + target: { value: "synthetic typed secret" }, + }); + rerender(view("settings")); + await waitFor(() => expect(screen.getByLabelText("Controller")).toHaveValue("draft controller")); + expect(screen.getByLabelText("Password")).not.toHaveValue("synthetic typed secret"); + expect(screen.getByLabelText("Instance Name")).toHaveValue("saved name"); +}); +function BoundSettings({ topic }: { topic: string }) { + useBindAdminDraftOwner({ apiUrl: "http://test.local", actorId: "admin", role: "admin" }); + return ( + + ); +} diff --git a/apps/web/src/components/admin/shared/SchemaConfigForm.tsx b/apps/web/src/components/admin/shared/SchemaConfigForm.tsx index e05cdb3e2..a8f7ae7ea 100644 --- a/apps/web/src/components/admin/shared/SchemaConfigForm.tsx +++ b/apps/web/src/components/admin/shared/SchemaConfigForm.tsx @@ -18,7 +18,8 @@ import Typography from "@mui/material/Typography"; import type { ConfigurationOwnership } from "@openmapx/core/api-contracts"; import { OPERATOR_STRING_FORMATS, validOperatorString } from "@openmapx/core/configuration-values"; import { type CredentialSetup, readCredentialSetup } from "@openmapx/integration-framework"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useId, useMemo, useState } from "react"; +import { useAdminDraft } from "@/lib/admin/useAdminDraft"; import { CredentialSetupGuide } from "../integrations/CredentialSetupGuide"; // The shared, schema-driven config form used by BOTH the integration and the @@ -85,10 +86,6 @@ function toFormValue(field: SchemaConfigField, value: unknown): unknown { return JSON.stringify(value, null, 2); } -function comparable(value: unknown): string { - return typeof value === "string" ? value : JSON.stringify(value); -} - export function buildConfigDiff( fields: SchemaConfigField[], values: Record, @@ -154,6 +151,8 @@ export function extractConfigFields( } export interface SchemaConfigFormProps { + /** Stable ordinary draft identity, scoped by the root administrator authority. */ + draftId?: string; /** The manifest `configSchema` object. */ schema: Record | undefined; /** Resolved per-field values with source annotation. */ @@ -178,6 +177,7 @@ export interface SchemaConfigFormProps { } export function SchemaConfigForm({ + draftId, schema, resolvedConfig, excludeKeys, @@ -187,7 +187,7 @@ export function SchemaConfigForm({ onSaveAndApply, applyLabel = "Save & apply", applySuccessMessage = "Configuration saved and applied.", - preserveDirtyValuesOnRefresh = false, + preserveDirtyValuesOnRefresh: _preserveDirtyValuesOnRefresh = false, emptyMessage = "No editable configuration fields. Credentials live on the Credentials tab.", }: SchemaConfigFormProps) { const fields = useMemo(() => extractConfigFields(schema, excludeKeys), [schema, excludeKeys]); @@ -209,53 +209,19 @@ export function SchemaConfigForm({ return initial; }, [fields, resolvedConfig, ownership]); - const [values, setValues] = useState>(computeInitialValues); + const localId = useId(); + const originalValues = useMemo(computeInitialValues, [computeInitialValues]); + const draft = useAdminDraft({ + id: draftId ?? localId, + baseline: originalValues, + editableKeys: Object.keys(originalValues), + signature: "schema-config-v1", + }); + const { values, setValues } = draft; const [pending, setPending] = useState<"save" | "apply" | null>(null); const [error, setError] = useState(null); const [saved, setSaved] = useState<"saved" | "applied" | null>(null); - - // A disposed service form's cache refresh may reach its replacement form. - // Update its saved baseline without overwriting newly edited fields. - const previousInitial = useRef(values); - useEffect(() => { - const next = computeInitialValues(); - const previous = previousInitial.current; - previousInitial.current = next; - setValues((current) => { - if (!preserveDirtyValuesOnRefresh) return next; - const merged = { ...next }; - for (const key of Object.keys(next)) { - if (comparable(current[key]) !== comparable(previous[key])) merged[key] = current[key]; - } - return merged; - }); - }, [computeInitialValues, preserveDirtyValuesOnRefresh]); - - const originalValues = useMemo(() => { - const orig: Record = {}; - for (const field of fields) { - const entry = resolvedConfig[field.key]; - if ( - entry?.source !== "env" && - entry?.source !== "config.json" && - ownership?.[field.key]?.writable !== false - ) { - orig[field.key] = toFormValue(field, entry?.value ?? field.default ?? ""); - } - } - return orig; - }, [fields, resolvedConfig, ownership]); - - const changedKeys = useMemo( - () => - Object.keys(values).filter( - (k) => - !["env", "config.json"].includes(resolvedConfig[k]?.source ?? "") && - ownership?.[k]?.writable !== false && - comparable(values[k]) !== comparable(originalValues[k] ?? ""), - ), - [values, originalValues, resolvedConfig, ownership], - ); + const changedKeys = draft.dirtyKeys.filter((key) => Object.hasOwn(originalValues, key)); const saving = pending !== null; @@ -264,8 +230,10 @@ export function SchemaConfigForm({ setSaved(null); setPending(kind); try { + const submitted = Object.fromEntries(changedKeys.map((key) => [key, values[key]])); const diff = buildConfigDiff(fields, values, changedKeys); await fn(diff); + draft.acknowledge(submitted); setSaved(kind === "apply" ? "applied" : "saved"); } catch (err) { setError(err instanceof Error ? err.message : "Save failed"); @@ -284,6 +252,19 @@ export function SchemaConfigForm({ return ( + {draft.conflicts.length > 0 && ( + draft.keepEdits()}> + Keep my edits + + } + > + Saved values changed for {draft.conflicts.join(", ")}. Review your edits or discard them + before saving. + + )} {error && ( setError(null)}> {error} @@ -457,13 +438,18 @@ export function SchemaConfigForm({ ); })} - + + {draft.dirty && ( + + )} @@ -473,7 +459,7 @@ export function SchemaConfigForm({ size="small" startIcon={pending === "apply" ? : } onClick={() => run("apply", onSaveAndApply)} - disabled={saving || changedKeys.length === 0} + disabled={saving || changedKeys.length === 0 || draft.conflicts.length > 0} > {applyLabel} diff --git a/apps/web/src/components/admin/shared/__tests__/SchemaConfigForm.ownership.test.tsx b/apps/web/src/components/admin/shared/__tests__/SchemaConfigForm.ownership.test.tsx index f1287ceaf..d35c712e4 100644 --- a/apps/web/src/components/admin/shared/__tests__/SchemaConfigForm.ownership.test.tsx +++ b/apps/web/src/components/admin/shared/__tests__/SchemaConfigForm.ownership.test.tsx @@ -1,4 +1,5 @@ import { afterEach, expect, it, vi } from "vitest"; +import { AdminDraftProvider } from "@/lib/admin/AdminDraftProvider"; import { cleanup, render, screen, userEvent } from "@/test"; import { SchemaConfigForm } from "../SchemaConfigForm"; @@ -46,3 +47,41 @@ it("shows legacy host memory units and false booleans honestly in locked control expect(screen.getByRole("switch")).not.toBeChecked(); expect(screen.getByRole("switch")).toBeDisabled(); }); + +it("retains an ordinary draft across tab disposal and requires review of a server conflict", async () => { + const schema = { + properties: { + region: { type: "string" }, + password: { type: "string", "x-openmapx-secret": true }, + }, + }; + const view = (show: boolean, region = "old") => ( + + {show ? ( + {}} + /> + ) : ( +
Credentials
+ )} +
+ ); + const { rerender } = render(view(true)); + await userEvent.clear(screen.getByRole("textbox")); + await userEvent.type(screen.getByRole("textbox"), "mine"); + rerender(view(false)); + rerender(view(true, "theirs")); + expect(screen.getByRole("textbox")).toHaveValue("mine"); + expect(screen.getByRole("button", { name: /^Save/ })).toBeDisabled(); + await userEvent.click(screen.getByRole("button", { name: "Keep my edits" })); + expect(screen.getByRole("button", { name: /^Save/ })).not.toBeDisabled(); + await userEvent.click(screen.getByRole("button", { name: "Discard" })); + expect(screen.getByRole("textbox")).toHaveValue("theirs"); + expect(screen.queryByDisplayValue("never")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/admin/users/AdminReauthenticationDialog.tsx b/apps/web/src/components/admin/users/AdminReauthenticationDialog.tsx index 2ecaf2a68..d0f1460f1 100644 --- a/apps/web/src/components/admin/users/AdminReauthenticationDialog.tsx +++ b/apps/web/src/components/admin/users/AdminReauthenticationDialog.tsx @@ -8,7 +8,9 @@ import DialogTitle from "@mui/material/DialogTitle"; import type { AdminReauthProofRef } from "@openmapx/core/api-contracts"; import { useState } from "react"; import Dialog from "@/components/ui/Dialog"; +import { useAdminDraftExit } from "@/lib/admin/AdminDraftProvider"; import { useAccountAction, useAccountAdmin } from "@/lib/admin/accountAdmin"; +import { navigateAdminDocument } from "@/lib/admin/adminDocumentExit"; import { type AdminReauthIntent, adminReauthStorageKey, @@ -36,6 +38,7 @@ type Props = ( } ) & { onCompleted: (proofs: AdminReauthProofRef[]) => void; onClose: () => void }; export function AdminReauthenticationDialog(props: Props) { + const requestExit = useAdminDraftExit(); const { apiUrl, actorId } = useAccountAdmin(), requests: AdminReauthIntent[] = props.purposes ? [...props.purposes] @@ -120,7 +123,15 @@ export function AdminReauthenticationDialog(props: Props) { Continue with verified sign-in ) : stored ? ( - ) : ( diff --git a/apps/web/src/components/admin/users/UserActions.test.tsx b/apps/web/src/components/admin/users/UserActions.test.tsx index 52b1a8aa6..3631686c9 100644 --- a/apps/web/src/components/admin/users/UserActions.test.tsx +++ b/apps/web/src/components/admin/users/UserActions.test.tsx @@ -1,5 +1,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { AdminDraftProvider } from "@/lib/admin/AdminDraftProvider"; +import { useAdminDraft } from "@/lib/admin/useAdminDraft"; import { act, cleanup, createTestQueryClient, fireEvent, render, screen, waitFor } from "@/test"; import { adminTestHistory } from "@/test/adminNavigation"; import { BanUserDialog } from "./BanUserDialog"; @@ -285,3 +287,41 @@ it("offers the privacy role for an existing administrator through the same canon fireEvent.mouseDown(screen.getByRole("combobox", { name: "Role" })); expect(screen.getByRole("option", { name: "Privacy admin" })).toBeInTheDocument(); }); + +function DirtyControl() { + const draft = useAdminDraft({ + id: "authority-test", + baseline: { label: "saved" }, + editableKeys: ["label"], + signature: "v1", + }); + return ( + + ); +} +it("requires Stay or Discard before issuing impersonation with a dirty administration draft", async () => { + goodList(); + mount( + + + + , + ); + await screen.findByText(subject.email); + fireEvent.click(screen.getByText("Mark admin draft")); + fireEvent.click(screen.getByLabelText("User actions")); + fireEvent.click(screen.getByRole("menuitem", { name: "Impersonate" })); + await screen.findByText("Discard unsaved administration changes?"); + expect(mocks.impersonate).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Stay" })); + await waitFor(() => + expect(screen.queryByText("Discard unsaved administration changes?")).not.toBeInTheDocument(), + ); + expect(mocks.impersonate).not.toHaveBeenCalled(); + fireEvent.click(screen.getByLabelText("User actions")); + fireEvent.click(screen.getByRole("menuitem", { name: "Impersonate" })); + fireEvent.click(await screen.findByRole("button", { name: "Discard and continue" })); + await waitFor(() => expect(mocks.impersonate).toHaveBeenCalledTimes(1)); +}); diff --git a/apps/web/src/components/admin/users/UserDetail.tsx b/apps/web/src/components/admin/users/UserDetail.tsx index 1932284e0..aa4d9afd9 100644 --- a/apps/web/src/components/admin/users/UserDetail.tsx +++ b/apps/web/src/components/admin/users/UserDetail.tsx @@ -34,6 +34,7 @@ import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { useAdminDraftExit } from "@/lib/admin/AdminDraftProvider"; import { useAccountAction, useAccountAdmin } from "@/lib/admin/accountAdmin"; import { useAdminTab } from "@/lib/admin/useAdminQueryState"; import { AdminPageHeader } from "../shared/AdminPageHeader"; @@ -436,6 +437,7 @@ function AccountsTab() { } export function UserDetail({ userId }: { userId: string }) { + const requestExit = useAdminDraftExit(); const [tab, setTab] = useAdminTab(["profile", "sessions", "accounts"]); const account = useAccountAdmin(); const currentUserId = account.actorId; @@ -527,7 +529,10 @@ export function UserDetail({ userId }: { userId: string }) { isSelf={isSelf} impersonate={{ ...impersonate, - mutate: () => impersonate.mutate((client) => client.impersonate(userId)), + mutate: async () => { + if (await requestExit("Impersonation changes the active account.")) + impersonate.mutate((client) => client.impersonate(userId)); + }, }} /> )} diff --git a/apps/web/src/components/admin/users/UserList.tsx b/apps/web/src/components/admin/users/UserList.tsx index 136dfebf1..21ae89675 100644 --- a/apps/web/src/components/admin/users/UserList.tsx +++ b/apps/web/src/components/admin/users/UserList.tsx @@ -38,6 +38,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import Menu from "@/components/ui/Menu"; +import { useAdminDraftExit } from "@/lib/admin/AdminDraftProvider"; import { useAccountAction, useAccountAdmin } from "@/lib/admin/accountAdmin"; import { AdminPageHeader } from "../shared/AdminPageHeader"; import { AdminTablePagination } from "../shared/AdminTablePagination"; @@ -540,6 +541,7 @@ export function UserList() { } function ImpersonationAction({ targetId, onDone }: { targetId: string; onDone: () => void }) { + const requestExit = useAdminDraftExit(); const router = useRouter(), showToast = useAdminToast(); const action = useAccountAction(targetId, { @@ -558,8 +560,11 @@ function ImpersonationAction({ targetId, onDone }: { targetId: string; onDone: ( useEffect(() => { if (!started.current) { started.current = true; - action.mutate((client) => client.impersonate(targetId)); + void requestExit("Impersonation changes the active account.").then((confirmed) => { + if (confirmed) action.mutate((client) => client.impersonate(targetId)); + else onDone(); + }); } - }, [action, targetId]); + }, [action, targetId, requestExit, onDone]); return null; } diff --git a/apps/web/src/components/auth/PrivacyDataSection.tsx b/apps/web/src/components/auth/PrivacyDataSection.tsx index d27f4d19b..c8d9d869a 100644 --- a/apps/web/src/components/auth/PrivacyDataSection.tsx +++ b/apps/web/src/components/auth/PrivacyDataSection.tsx @@ -30,6 +30,7 @@ import { } from "@openmapx/core"; import { useFormatter, useTranslations } from "next-intl"; import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { navigateAdminDocument } from "@/lib/admin/adminDocumentExit"; import { downloadPrivacyDeviceData } from "@/lib/privacyDeviceData"; const PENDING_REAUTH_KEY = "openmapx:privacy:pending-reauth"; @@ -127,8 +128,9 @@ export const PrivacyDataSection = forwardRef( .then(() => { if (cancelled) return; window.sessionStorage.removeItem(PENDING_REAUTH_KEY); - window.location.assign( + navigateAdminDocument( privacyArtifactDownloadUrl(challenge.requestId, challenge.artifactId), + "download", ); }) .catch(() => { diff --git a/apps/web/src/lib/admin/AdminDraftProvider.test.tsx b/apps/web/src/lib/admin/AdminDraftProvider.test.tsx new file mode 100644 index 000000000..545e84bd4 --- /dev/null +++ b/apps/web/src/lib/admin/AdminDraftProvider.test.tsx @@ -0,0 +1,92 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@/test"; +import { AdminDraftProvider } from "./AdminDraftProvider"; +import { allowAdminDocumentExit } from "./adminDocumentExit"; +import { useAdminDraft } from "./useAdminDraft"; + +const baseline = { label: "saved", password: "never" }; +function Form() { + const draft = useAdminDraft({ id: "sample", baseline, editableKeys: ["label"], signature: "v1" }); + return ( + <> + draft.setValues({ ...draft.values, label: e.target.value })} + /> + + + ); +} +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); +const exit = () => { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; +}; +it("retains only ordinary allowlisted values across tab and soft map navigation", () => { + const view = (show: boolean) => ( + {show ?
:
Map
} + ); + const { rerender } = render(view(true)); + fireEvent.change(screen.getByLabelText("Label"), { target: { value: "draft" } }); + rerender(view(false)); + expect(exit()).toBe(true); + rerender(view(true)); + expect(screen.getByLabelText("Label")).toHaveValue("draft"); + fireEvent.click(screen.getByText("Discard")); + expect(exit()).toBe(false); +}); +it("bypasses only the immediate download and restores warning for later real exits", () => { + render( + + + , + ); + fireEvent.change(screen.getByLabelText("Label"), { target: { value: "draft" } }); + allowAdminDocumentExit("download"); + expect(exit()).toBe(false); + expect(exit()).toBe(true); + const reset = allowAdminDocumentExit("download"); + reset(); + expect(exit()).toBe(true); + vi.useFakeTimers(); + allowAdminDocumentExit("download"); + vi.advanceTimersByTime(1100); + expect(exit()).toBe(true); +}); + +function ManyForm({ id }: { id: string }) { + const draft = useAdminDraft({ + id, + baseline: { label: "saved" }, + editableKeys: ["label"], + signature: "v1", + }); + return ( + + draft.setValues((previous) => ({ ...previous, label: event.target.value })) + } + /> + ); +} +it("still accepts an edit after an old clean entry is evicted from the cache", () => { + render( + + {Array.from({ length: 40 }, (_, id) => ( + + ))} + , + ); + fireEvent.change(screen.getByLabelText("form-0"), { target: { value: "edited after eviction" } }); + expect(screen.getByLabelText("form-0")).toHaveValue("edited after eviction"); + expect(exit()).toBe(true); +}); diff --git a/apps/web/src/lib/admin/AdminDraftProvider.tsx b/apps/web/src/lib/admin/AdminDraftProvider.tsx new file mode 100644 index 000000000..4f0535764 --- /dev/null +++ b/apps/web/src/lib/admin/AdminDraftProvider.tsx @@ -0,0 +1,117 @@ +"use client"; +import Button from "@mui/material/Button"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import { authClient } from "@openmapx/core"; +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import Dialog from "@/components/ui/Dialog"; +import { useEnv } from "@/integration-api/runtime/EnvProvider"; +import { consumeAdminDocumentExitAllowance } from "./adminDocumentExit"; +import { type AdminDraftOwner, AdminDraftStore } from "./adminDraftStore"; +export const AdminDraftContext = createContext(null); +const ExitContext = createContext<((reason: string) => Promise) | null>(null); +export function AdminDraftProvider({ children }: PropsWithChildren) { + const [store] = useState(() => new AdminDraftStore()); + const [reason, setReason] = useState(null); + const pending = useRef<((choice: boolean) => void) | null>(null); + useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot); + const requestExit = useCallback( + (message: string): Promise => { + if (!store.dirty) return Promise.resolve(true); + if (pending.current) return Promise.resolve(false); + setReason(message); + return new Promise((resolve) => { + pending.current = resolve; + }); + }, + [store], + ); + const choose = (discard: boolean) => { + if (discard) store.discard(); + pending.current?.(discard); + pending.current = null; + setReason(null); + }; + useEffect(() => { + const beforeUnload = (event: BeforeUnloadEvent) => { + if (consumeAdminDocumentExitAllowance() || !store.dirty) return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", beforeUnload); + return () => { + window.removeEventListener("beforeunload", beforeUnload); + pending.current?.(false); + }; + }, [store]); + return ( + + + {children} + choose(false)} + aria-labelledby="admin-draft-exit-title" + > + + Discard unsaved administration changes? + + + + {reason} Ordinary drafts will be discarded. Stay to save them first. + + + + + + + + + + ); +} +function AuthorityObserver() { + const store = useContext(AdminDraftContext); + const { apiUrl } = useEnv(); + const { data, isPending, error } = authClient.useSession(); + useLayoutEffect(() => { + store?.setOrigin(apiUrl); + store?.observeSession({ data, isPending, error }); + }, [store, apiUrl, data, isPending, error]); + return null; +} +/** Must stay outside SessionAuthorityBoundary, including its pending refresh gate. */ +export function AdminDraftRoot({ children }: PropsWithChildren) { + return ( + + + {children} + + ); +} +export function useBindAdminDraftOwner({ apiUrl, actorId, role }: AdminDraftOwner) { + const store = useContext(AdminDraftContext); + useLayoutEffect(() => { + store?.bind({ apiUrl, actorId, role }); + }, [store, apiUrl, actorId, role]); +} +export function useAdminDraftExit() { + const request = useContext(ExitContext); + return request ?? (() => Promise.resolve(true)); +} diff --git a/apps/web/src/lib/admin/adminDocumentExit.ts b/apps/web/src/lib/admin/adminDocumentExit.ts new file mode 100644 index 000000000..16230ddfe --- /dev/null +++ b/apps/web/src/lib/admin/adminDocumentExit.ts @@ -0,0 +1,25 @@ +// Contains no draft content. The root provider owns the actual beforeunload listener. +let bypass: { until: number } | null = null; +export function allowAdminDocumentExit(reason: "download" | "reauthentication"): () => void { + void reason; + const token = { until: Date.now() + 1000 }; + bypass = token; + return () => { + if (bypass === token) bypass = null; + }; +} +export function consumeAdminDocumentExitAllowance(): boolean { + const allowed = bypass !== null && Date.now() <= bypass.until; + bypass = null; + return allowed; +} + +export function navigateAdminDocument(url: string, reason: "download" | "reauthentication") { + const restore = allowAdminDocumentExit(reason); + try { + window.location.assign(url); + } catch (error) { + restore(); + throw error; + } +} diff --git a/apps/web/src/lib/admin/adminDraftStore.test.ts b/apps/web/src/lib/admin/adminDraftStore.test.ts new file mode 100644 index 000000000..3201e6754 --- /dev/null +++ b/apps/web/src/lib/admin/adminDraftStore.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { AdminDraftStore } from "./adminDraftStore"; + +const actor = { apiUrl: "https://a.test", actorId: "admin", role: "admin" }; +function fixture() { + const store = new AdminDraftStore(); + store.bind(actor); + store.reconcile("form", { x: "old", y: "clean" }, ["x", "y"]); + return store; +} +describe("ordinary admin drafts", () => { + it("keeps edits across remounts and refreshes only clean keys", () => { + const s = fixture(); + s.update("form", { x: "mine" }); + s.reconcile("form", { x: "old", y: "other admin" }, ["x", "y"]); + expect(s.read("form").values).toEqual({ x: "mine", y: "other admin" }); + expect(s.read("form").dirtyKeys).toEqual(["x"]); + }); + it("requires conflict review against the baseline at first edit", () => { + const s = fixture(); + s.update("form", { x: "mine" }); + s.reconcile("form", { x: "theirs", y: "clean" }, ["x", "y"]); + expect(s.read("form").conflicts).toEqual(["x"]); + s.keepEdits("form"); + expect(s.read("form").conflicts).toEqual([]); + s.discard("form"); + expect(s.read("form").values.x).toBe("theirs"); + }); + it("drops removed or host-owned keys and never admits a non-allowlisted value", () => { + const s = fixture(); + s.update("form", { x: "mine", password: "secret" }); + s.reconcile("form", { x: "host", y: "clean", password: "secret" }, ["y"]); + expect(s.read("form").values).toEqual({ y: "clean" }); + expect(s.dirty).toBe(false); + }); + it("acknowledges only submitted values still current and fences old authority callbacks", () => { + const s = fixture(), + generation = s.generation; + s.update("form", { x: "first" }); + s.update("form", { x: "second" }); + s.acknowledge("form", { x: "first" }, generation); + expect(s.read("form").dirtyKeys).toEqual(["x"]); + s.acknowledge("form", { x: "second" }, generation); + expect(s.dirty).toBe(false); + s.bind({ ...actor, apiUrl: "https://b.test" }); + s.bind(actor); + s.reconcile("form", { x: "fresh" }, ["x"]); + s.update("form", { x: "replacement" }); + s.acknowledge("form", { x: "replacement" }, generation); + expect(s.dirty).toBe(true); + }); + it("keeps all dirty entries while bounding only clean cache entries", () => { + const s = fixture(); + for (let i = 0; i < 40; i++) { + s.reconcile(`dirty-${i}`, { x: 0 }, ["x"]); + s.update(`dirty-${i}`, { x: 1 }); + } + for (let i = 0; i < 40; i++) s.reconcile(`clean-${i}`, { x: 0 }, ["x"]); + for (let i = 0; i < 40; i++) expect(s.read(`dirty-${i}`).values.x).toBe(1); + expect(s.cleanCount).toBeLessThanOrEqual(32); + }); + it("ignores failed/pending session reads but clears confirmed logout and replacement", () => { + const s = fixture(); + s.update("form", { x: "mine" }); + s.observeSession({ data: null, isPending: false, error: new Error("offline") }); + expect(s.dirty).toBe(true); + s.observeSession({ data: null, isPending: true, error: null }); + expect(s.dirty).toBe(true); + s.observeSession({ + data: { user: { id: "admin", role: "admin" }, session: { id: "one" } }, + isPending: false, + }); + expect(s.dirty).toBe(true); + s.observeSession({ + data: { user: { id: "admin", role: "admin" }, session: { id: "two" } }, + isPending: false, + }); + expect(s.dirty).toBe(false); + s.bind(actor); + s.reconcile("form", { x: "old" }, ["x"]); + s.update("form", { x: "mine" }); + s.observeSession({ data: null, isPending: false, error: null }); + expect(s.dirty).toBe(false); + }); +}); + +it("discards or reviews only the keys in the active settings panel", () => { + const s = fixture(); + s.update("form", { x: "mine", y: "also mine" }); + s.reconcile("form", { x: "their x", y: "their y" }, ["x", "y"]); + s.keepEdits("form", ["x"]); + expect(s.read("form").conflicts).toEqual(["y"]); + s.discard("form", ["x"]); + expect(s.read("form").values).toEqual({ x: "their x", y: "also mine" }); +}); + +it("does not flag its own acknowledged save as a conflict with a newer edit", () => { + const s = fixture(); + s.update("form", { x: "submitted" }); + s.update("form", { x: "newer" }); + s.acknowledge("form", { x: "submitted" }); + expect(s.read("form").values.x).toBe("newer"); + expect(s.read("form").conflicts).toEqual([]); +}); +it("retains a null baseline across repeated edits for conflict detection", () => { + const s = fixture(); + s.reconcile("form", { x: null }, ["x"]); + s.update("form", { x: "first" }); + s.reconcile("form", { x: "theirs" }, ["x"]); + s.update("form", { x: "second" }); + expect(s.read("form").conflicts).toEqual(["x"]); +}); diff --git a/apps/web/src/lib/admin/adminDraftStore.ts b/apps/web/src/lib/admin/adminDraftStore.ts new file mode 100644 index 000000000..07386388a --- /dev/null +++ b/apps/web/src/lib/admin/adminDraftStore.ts @@ -0,0 +1,188 @@ +export interface AdminDraftOwner { + apiUrl: string; + actorId: string; + role: string; +} +type Values = Record; +type Entry = { baseline: Values; edits: Map }; +type SessionRead = { + data?: { user?: { id?: string; role?: string | null }; session?: { id?: string } } | null; + isPending: boolean; + error?: unknown; +}; +const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); + +/** Ordinary allowlisted values only. Owned by one root React provider, never persisted. */ +export class AdminDraftStore { + private entries = new Map(); + private listeners = new Set<() => void>(); + private version = 0; + private owner: AdminDraftOwner | null = null; + private origin: string | undefined; + private session: SessionRead["data"] | undefined; + generation = 0; + subscribe = (fn: () => void) => { + this.listeners.add(fn); + return () => { + this.listeners.delete(fn); + }; + }; + snapshot = () => this.version; + private emit() { + this.version++; + this.listeners.forEach((fn) => { + fn(); + }); + } + get scopeKey() { + return this.owner ? JSON.stringify([this.owner.actorId, this.owner.role]) : null; + } + get dirty() { + return [...this.entries.values()].some((entry) => entry.edits.size > 0); + } + get cleanCount() { + return [...this.entries.values()].filter((entry) => entry.edits.size === 0).length; + } + private trim() { + let count = this.cleanCount; + for (const [id, entry] of this.entries) { + if (count <= 32) break; + if (!entry.edits.size) { + this.entries.delete(id); + count--; + } + } + } + clear() { + this.entries.clear(); + this.generation++; + this.emit(); + } + setOrigin(apiUrl: string) { + if (this.origin !== undefined && this.origin !== apiUrl) { + this.owner = null; + this.session = undefined; + this.clear(); + } + this.origin = apiUrl; + } + bind(owner: AdminDraftOwner) { + if ( + this.session === null || + (this.session?.user?.id && + (this.session.user.id !== owner.actorId || + (this.session.user.role && this.session.user.role !== owner.role))) + ) + return; + this.setOrigin(owner.apiUrl); + if (JSON.stringify(this.owner) !== JSON.stringify(owner)) { + this.clear(); + this.owner = owner; + } + } + observeSession(read: SessionRead) { + if (read.isPending || read.error || read.data === undefined) return; + const next = read.data; + if (next === null) { + if (this.session !== null || this.owner) this.clear(); + this.owner = null; + this.session = null; + return; + } + if (!next.user?.id || !next.session?.id) return; + const replaced = + (this.session?.session?.id && this.session.session.id !== next.session.id) || + (this.owner && + (this.owner.actorId !== next.user.id || + (next.user.role && this.owner.role !== next.user.role))); + if (replaced) { + this.clear(); + this.owner = null; + } + this.session = next; + } + has(id: string) { + return this.entries.has(id); + } + read(id: string) { + const entry = this.entries.get(id); + const values: Values = { ...entry?.baseline }; + const conflicts: string[] = []; + for (const [key, edit] of entry?.edits ?? []) { + values[key] = edit.value; + if (!equal(edit.baseline, entry?.baseline[key])) conflicts.push(key); + } + return { values, dirtyKeys: [...(entry?.edits.keys() ?? [])], conflicts }; + } + reconcile( + id: string, + baseline: Values, + editableKeys: readonly string[], + generation = this.generation, + ) { + if (generation !== this.generation) return; + const allowed = new Set(editableKeys); + const next = Object.fromEntries(Object.entries(baseline).filter(([key]) => allowed.has(key))); + let entry = this.entries.get(id); + if (!entry) { + entry = { baseline: next, edits: new Map() }; + this.entries.set(id, entry); + this.trim(); + this.emit(); + return; + } + let changed = !equal(entry.baseline, next); + entry.baseline = next; + for (const [key, edit] of entry.edits) + if (!Object.hasOwn(next, key) || equal(edit.value, next[key])) { + entry.edits.delete(key); + changed = true; + } + if (changed) this.emit(); + } + update(id: string, values: Values, generation = this.generation) { + if (generation !== this.generation) return; + const entry = this.entries.get(id); + if (!entry) return; + for (const [key, value] of Object.entries(values)) { + if (!Object.hasOwn(entry.baseline, key)) continue; + if (equal(value, entry.baseline[key])) entry.edits.delete(key); + else + entry.edits.set(key, { + value, + baseline: entry.edits.has(key) ? entry.edits.get(key)?.baseline : entry.baseline[key], + }); + } + this.emit(); + } + acknowledge(id: string, submitted: Values, generation = this.generation) { + if (generation !== this.generation) return; + const entry = this.entries.get(id); + if (!entry) return; + for (const [key, value] of Object.entries(submitted)) { + if (!Object.hasOwn(entry.baseline, key)) continue; + entry.baseline[key] = value; + const edit = entry.edits.get(key); + if (equal(edit?.value, value)) entry.edits.delete(key); + else if (edit) edit.baseline = value; + } + this.trim(); + this.emit(); + } + discard(id?: string, keys?: readonly string[]) { + for (const [key, entry] of this.entries) + if (!id || id === key) { + if (keys) for (const field of keys) entry.edits.delete(field); + else entry.edits.clear(); + } + this.trim(); + this.emit(); + } + keepEdits(id: string, keys?: readonly string[]) { + const entry = this.entries.get(id); + if (!entry) return; + for (const [key, edit] of entry.edits) + if (!keys || keys.includes(key)) edit.baseline = entry.baseline[key]; + this.emit(); + } +} diff --git a/apps/web/src/lib/admin/useAdminDraft.ts b/apps/web/src/lib/admin/useAdminDraft.ts new file mode 100644 index 000000000..1253864bf --- /dev/null +++ b/apps/web/src/lib/admin/useAdminDraft.ts @@ -0,0 +1,56 @@ +"use client"; +import { useContext, useLayoutEffect, useState, useSyncExternalStore } from "react"; +import { AdminDraftContext } from "./AdminDraftProvider"; +import { AdminDraftStore } from "./adminDraftStore"; + +type Values = Record; +export function useAdminDraft({ + id, + baseline, + editableKeys, + signature, + ready = true, +}: { + id: string; + baseline: Values; + editableKeys: readonly string[]; + signature: string; + ready?: boolean; +}) { + const shared = useContext(AdminDraftContext); + const [local] = useState(() => new AdminDraftStore()); + const store = shared ?? local; + useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot); + const generation = store.generation; + const entryId = JSON.stringify([id, signature]); + const baselineKey = JSON.stringify(baseline), + keys = JSON.stringify(editableKeys); + useLayoutEffect(() => { + if (!ready) return; + store.reconcile(entryId, JSON.parse(baselineKey), JSON.parse(keys), generation); + }, [store, entryId, baselineKey, keys, generation, ready]); + const snapshot = store.read(entryId); + // On the first render only, before the layout reconciliation, show the fresh allowlist. + const values = { + ...Object.fromEntries(Object.entries(baseline).filter(([key]) => editableKeys.includes(key))), + ...snapshot.values, + }; + return { + values, + dirty: snapshot.dirtyKeys.length > 0, + dirtyKeys: snapshot.dirtyKeys, + conflicts: snapshot.conflicts, + setValues(next: Values | ((previous: Values) => Values)) { + if (!store.has(entryId)) + store.reconcile(entryId, JSON.parse(baselineKey), JSON.parse(keys), generation); + store.update( + entryId, + typeof next === "function" ? next(store.read(entryId).values) : next, + generation, + ); + }, + discard: (keys?: readonly string[]) => store.discard(entryId, keys), + keepEdits: (keys?: readonly string[]) => store.keepEdits(entryId, keys), + acknowledge: (submitted: Values) => store.acknowledge(entryId, submitted, generation), + }; +} From 2d1607da15483959800bfe94fab8005720347894 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 11:15:02 +0100 Subject: [PATCH 03/12] feat(admin): link exact jobs and separate saved configuration application --- .../(full-admin)/transit/CurrentJobCard.tsx | 5 + .../(full-admin)/transit/LockfileCard.tsx | 5 + .../(full-admin)/transit/RecentJobsTable.tsx | 2 + .../admin/AdminLayout.navigation.test.tsx | 69 +++++ apps/web/src/components/admin/AdminLayout.tsx | 17 +- .../components/admin/AdminSidebar.test.tsx | 8 +- .../web/src/components/admin/AdminSidebar.tsx | 6 +- apps/web/src/components/admin/AdminTopBar.tsx | 5 +- .../admin/activity/AdminJobLink.tsx | 28 ++ .../admin/activity/AdminJobTray.tsx | 86 ++++++ .../src/components/admin/activity/JobList.tsx | 289 ++++++++++-------- .../__tests__/ActivityConsumers.test.tsx | 46 +++ .../admin/activity/activityQueries.ts | 5 +- .../admin/integrations/IntegrationDetail.tsx | 11 +- .../services/ArtifactTransfersSection.tsx | 13 +- .../components/admin/services/BackupsPage.tsx | 16 +- .../admin/services/ComposePreview.tsx | 13 +- .../admin/services/DataWorkflowsPage.tsx | 8 +- .../services/NotablePlacesMaintenance.tsx | 13 +- .../admin/services/OvertureMaintenance.tsx | 13 +- .../admin/services/SearchIndexMaintenance.tsx | 13 +- .../services/ServiceAdministration.test.tsx | 41 ++- .../admin/services/ServiceCatalog.tsx | 15 +- .../admin/services/ServiceConfigForm.tsx | 9 + .../admin/services/ServiceDetail.tsx | 48 ++- .../admin/services/TransitSourcesSection.tsx | 8 +- .../admin/settings/PlatformConfiguration.tsx | 24 +- .../admin/shared/SchemaConfigForm.tsx | 62 +++- .../SchemaConfigForm.ownership.test.tsx | 29 ++ .../admin/system/SystemMaintenance.tsx | 22 ++ .../src/lib/admin/AdminDraftProvider.test.tsx | 4 +- apps/web/src/lib/admin/AdminDraftProvider.tsx | 13 + apps/web/src/lib/admin/adminDraftStore.ts | 3 + .../src/lib/admin/adminJobEffects.test.tsx | 153 +++++++++- apps/web/src/lib/admin/adminJobEffects.tsx | 146 +++++++-- .../src/lib/admin/adminJobNavigation.test.ts | 34 +++ apps/web/src/lib/admin/adminJobNavigation.ts | 92 ++++++ apps/web/src/lib/admin/integrationAdmin.ts | 24 +- apps/web/src/lib/admin/serviceAdminJobs.tsx | 13 +- 39 files changed, 1196 insertions(+), 215 deletions(-) create mode 100644 apps/web/src/components/admin/AdminLayout.navigation.test.tsx create mode 100644 apps/web/src/components/admin/activity/AdminJobLink.tsx create mode 100644 apps/web/src/components/admin/activity/AdminJobTray.tsx create mode 100644 apps/web/src/lib/admin/adminJobNavigation.test.ts create mode 100644 apps/web/src/lib/admin/adminJobNavigation.ts diff --git a/apps/web/src/app/admin/(full-admin)/transit/CurrentJobCard.tsx b/apps/web/src/app/admin/(full-admin)/transit/CurrentJobCard.tsx index bffe0076e..3b7fb586e 100644 --- a/apps/web/src/app/admin/(full-admin)/transit/CurrentJobCard.tsx +++ b/apps/web/src/app/admin/(full-admin)/transit/CurrentJobCard.tsx @@ -15,6 +15,7 @@ import Typography from "@mui/material/Typography"; import { adminErrorMessage } from "@openmapx/core/api-admin-sdk"; import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useState } from "react"; +import { AdminJobLink } from "@/components/admin/activity/AdminJobLink"; import { TransitSyncDialog } from "@/components/admin/services/TransitSyncDialog"; import { useAdminToast } from "@/components/admin/shared/AdminToast"; import { formatTransitAdminTimestamp } from "@/components/admin/shared/adminTimestamp"; @@ -65,6 +66,7 @@ function RunningJobDetail({ jobId }: { jobId: string }) { return ( + (null); const { apiUrl } = useEnv(); const qc = useQueryClient(); const restartMutation = useRestartMotis(); @@ -122,6 +125,7 @@ export function CurrentJobCard({ state }: { state: TransitStateSummary }) { apiUrl={apiUrl} onClose={() => setSyncReview(false)} onQueued={(jobId) => { + setQueuedJobId(jobId); setSyncReview(false); showToast(`Sync queued (${jobId.slice(0, 8)}…)`, "success"); void qc.invalidateQueries({ queryKey: ["admin", "transit"] }); @@ -207,6 +211,7 @@ export function CurrentJobCard({ state }: { state: TransitStateSummary }) { )} + {queuedJobId && } {state.lastSyncStatus === "failed" && ( Last sync run failed. Inspect the job detail below for the failing stage. diff --git a/apps/web/src/app/admin/(full-admin)/transit/LockfileCard.tsx b/apps/web/src/app/admin/(full-admin)/transit/LockfileCard.tsx index 82811ff4d..af52da2a5 100644 --- a/apps/web/src/app/admin/(full-admin)/transit/LockfileCard.tsx +++ b/apps/web/src/app/admin/(full-admin)/transit/LockfileCard.tsx @@ -1,4 +1,5 @@ "use client"; + import RefreshIcon from "@mui/icons-material/Refresh"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; @@ -14,6 +15,7 @@ import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; import { adminErrorMessage } from "@openmapx/core/api-admin-sdk"; import { useEffect, useRef, useState } from "react"; +import { AdminJobLink } from "@/components/admin/activity/AdminJobLink"; import { TransitCredentialsSection } from "@/components/admin/services/TransitCredentialsSection"; import { useAdminToast } from "@/components/admin/shared/AdminToast"; import { formatTransitAdminTimestamp } from "@/components/admin/shared/adminTimestamp"; @@ -444,6 +446,9 @@ function CatalogPinView() { } > + {(ownedJobId ?? execution?.id) && ( + + )} Catalog job {ownedJobId ?? execution?.id}:{" "} {execution?.status ?? "connecting to retained execution"} diff --git a/apps/web/src/app/admin/(full-admin)/transit/RecentJobsTable.tsx b/apps/web/src/app/admin/(full-admin)/transit/RecentJobsTable.tsx index 241a9f591..1de4136f7 100644 --- a/apps/web/src/app/admin/(full-admin)/transit/RecentJobsTable.tsx +++ b/apps/web/src/app/admin/(full-admin)/transit/RecentJobsTable.tsx @@ -15,6 +15,7 @@ import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import Typography from "@mui/material/Typography"; import { useState } from "react"; +import { AdminJobLink } from "@/components/admin/activity/AdminJobLink"; import { AdminTableSurface } from "@/components/admin/shared/AdminTableSurface"; import { formatTransitAdminTimestamp } from "@/components/admin/shared/adminTimestamp"; import { DataManagerJobStages } from "@/components/admin/shared/DataManagerJobStages"; @@ -75,6 +76,7 @@ function JobDetailDrawer({ jobId, onClose }: { jobId: string; onClose: () => voi }} > {jobId} + {isLoading && ( diff --git a/apps/web/src/components/admin/AdminLayout.navigation.test.tsx b/apps/web/src/components/admin/AdminLayout.navigation.test.tsx new file mode 100644 index 000000000..f27e12775 --- /dev/null +++ b/apps/web/src/components/admin/AdminLayout.navigation.test.tsx @@ -0,0 +1,69 @@ +import { en } from "@openmapx/i18n"; +import { NextIntlClientProvider } from "next-intl"; +import { afterEach, expect, it, vi } from "vitest"; +import { act, cleanup, createQueryWrapper, fireEvent, render, screen } from "@/test"; +import { AdminLayout } from "./AdminLayout"; + +vi.mock("@/integration-api/runtime/EnvProvider", () => ({ + useEnv: () => ({ apiUrl: "http://admin.test" }), +})); +vi.mock( + "next/navigation", + async () => (await import("@/test/adminNavigation")).adminNavigationMock, +); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +it("keeps the desktop sidebar preference independent of mobile drawer navigation", () => { + let wide = true; + const listeners = new Set<() => void>(); + vi.stubGlobal("matchMedia", () => ({ + get matches() { + return wide; + }, + addEventListener: (_name: string, fn: () => void) => { + listeners.add(fn); + }, + removeEventListener: (_name: string, fn: () => void) => { + listeners.delete(fn); + }, + })); + render( + + +
Page
+
+
, + { wrapper: createQueryWrapper() }, + ); + const toggle = screen.getByRole("button", { name: "Toggle navigation" }); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + act(() => { + wide = false; + for (const listener of listeners) listener(); + }); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + const link = screen.getAllByRole("link", { name: "Overview" })[0]; + document.addEventListener("click", (event) => event.preventDefault(), { once: true }); + fireEvent.click(link); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + act(() => { + wide = true; + for (const listener of listeners) listener(); + }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + act(() => { + wide = false; + for (const listener of listeners) listener(); + }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); +}); diff --git a/apps/web/src/components/admin/AdminLayout.tsx b/apps/web/src/components/admin/AdminLayout.tsx index f7128b789..990b2fa22 100644 --- a/apps/web/src/components/admin/AdminLayout.tsx +++ b/apps/web/src/components/admin/AdminLayout.tsx @@ -2,6 +2,7 @@ import Box from "@mui/material/Box"; import CircularProgress from "@mui/material/CircularProgress"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { useCallback, useState } from "react"; import { useEnv } from "@/integration-api/runtime/EnvProvider"; import { useBindAdminDraftOwner } from "@/lib/admin/AdminDraftProvider"; @@ -31,22 +32,26 @@ export function AdminLayout({ children, user, selfHosted = false }: AdminLayoutP const { apiUrl } = useEnv(); useBindAdminDraftOwner({ apiUrl, actorId: user.id, role: user.role ?? "admin" }); const hydrated = useHydrated(); - const [sidebarOverride, setSidebarOpen] = useState(null); - const sidebarOpen = - sidebarOverride ?? (!hydrated || window.matchMedia("(min-width:900px)").matches); - const toggleSidebar = useCallback(() => setSidebarOpen(!sidebarOpen), [sidebarOpen]); + const desktop = useMediaQuery("(min-width:900px)"); + const [desktopOpen, setDesktopOpen] = useState(true); + const [mobileOpen, setMobileOpen] = useState(false); + const sidebarOpen = desktop ? desktopOpen : mobileOpen; + const toggleSidebar = useCallback(() => { + if (desktop) setDesktopOpen((value) => !value); + else setMobileOpen((value) => !value); + }, [desktop]); return ( - + setSidebarOpen(false)} + onClose={() => setMobileOpen(false)} selfHosted={selfHosted} role={user.role} /> diff --git a/apps/web/src/components/admin/AdminSidebar.test.tsx b/apps/web/src/components/admin/AdminSidebar.test.tsx index 9d0601695..7bef22d35 100644 --- a/apps/web/src/components/admin/AdminSidebar.test.tsx +++ b/apps/web/src/components/admin/AdminSidebar.test.tsx @@ -5,7 +5,13 @@ import { cleanup, render, screen } from "@/test"; import { AdminSidebar } from "./AdminSidebar"; vi.mock("next/navigation", () => ({ usePathname: () => "/admin/services" })); -beforeEach(() => vi.stubGlobal("matchMedia", () => ({ matches: false }))); +beforeEach(() => + vi.stubGlobal("matchMedia", () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + })), +); afterEach(() => { cleanup(); vi.unstubAllGlobals(); diff --git a/apps/web/src/components/admin/AdminSidebar.tsx b/apps/web/src/components/admin/AdminSidebar.tsx index 9d737d4c2..f1c4236a1 100644 --- a/apps/web/src/components/admin/AdminSidebar.tsx +++ b/apps/web/src/components/admin/AdminSidebar.tsx @@ -28,6 +28,7 @@ import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; import Toolbar from "@mui/material/Toolbar"; import Typography from "@mui/material/Typography"; +import useMediaQuery from "@mui/material/useMediaQuery"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -292,6 +293,7 @@ export function AdminSidebar({ role = "admin", }: AdminSidebarProps) { const pathname = usePathname(); + const desktop = useMediaQuery("(min-width:900px)"); const onNavigate = (event: React.MouseEvent) => { if ( !event.defaultPrevented && @@ -428,7 +430,7 @@ export function AdminSidebar({ {/* Mobile drawer */} = { @@ -42,7 +43,7 @@ function labelForSegment(seg: string): string { } interface AdminTopBarProps { - user: { name: string; email: string; image?: string }; + user: { name: string; email: string; image?: string; role?: string }; sidebarOpen: boolean; onToggleSidebar: () => void; } @@ -83,6 +84,7 @@ export function AdminTopBar({ user, sidebarOpen, onToggleSidebar }: AdminTopBarP onClick={onToggleSidebar} size="small" aria-label="Toggle navigation" + aria-expanded={sidebarOpen} > @@ -127,6 +129,7 @@ export function AdminTopBar({ user, sidebarOpen, onToggleSidebar }: AdminTopBarP })} + {user.role === "admin" && } + {children} + + ); +} diff --git a/apps/web/src/components/admin/activity/AdminJobTray.tsx b/apps/web/src/components/admin/activity/AdminJobTray.tsx new file mode 100644 index 000000000..3038ca096 --- /dev/null +++ b/apps/web/src/components/admin/activity/AdminJobTray.tsx @@ -0,0 +1,86 @@ +"use client"; +import WorkIcon from "@mui/icons-material/WorkHistory"; +import Badge from "@mui/material/Badge"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Link from "next/link"; +import { useState } from "react"; +import Popover from "@/components/ui/Popover"; +import { useAdminObservedJobs } from "@/lib/admin/adminJobEffects"; +import { adminJobHref } from "@/lib/admin/adminJobNavigation"; +import { JobStatusChip } from "../shared/JobStatusChip"; +export function AdminJobTray() { + const jobs = useAdminObservedJobs(); + const [anchor, setAnchor] = useState(null); + const active = jobs.filter((job) => !job.settled).length; + const recent = [...jobs].reverse().slice(0, 8); + return ( + <> + setAnchor(event.currentTarget)} + > + + + + + setAnchor(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + transformOrigin={{ vertical: "top", horizontal: "right" }} + > + + Administration jobs · {active} active + {recent.length === 0 && ( + + Accepted jobs appear here during this administration session. + + )} + {recent.map((job) => ( + + + {job.unresolved ? ( + + Outcome unknown + + ) : ( + + )} + + ))} + + + Refresh clears this session tray. Activity retains server history. + + + + + ); +} diff --git a/apps/web/src/components/admin/activity/JobList.tsx b/apps/web/src/components/admin/activity/JobList.tsx index cc12de702..c8b7ef245 100644 --- a/apps/web/src/components/admin/activity/JobList.tsx +++ b/apps/web/src/components/admin/activity/JobList.tsx @@ -1,11 +1,10 @@ "use client"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; import RefreshIcon from "@mui/icons-material/Refresh"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; import Chip from "@mui/material/Chip"; -import Collapse from "@mui/material/Collapse"; import IconButton from "@mui/material/IconButton"; import Stack from "@mui/material/Stack"; import Table from "@mui/material/Table"; @@ -21,13 +20,14 @@ import Typography from "@mui/material/Typography"; import { listAdminActivityJobs, unwrapAdminResponse } from "@openmapx/core/api-admin-sdk"; import type { AdminActivityJob, AdminActivityJobsResponse } from "@openmapx/core/api-contracts"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import Link from "next/link"; +import { parseAdminJobSelection } from "@/lib/admin/adminJobNavigation"; +import { useAdminQueryState } from "@/lib/admin/useAdminQueryState"; import { relativeTimeFromIso } from "@/lib/formatTime"; import { AdminTablePagination } from "../shared/AdminTablePagination"; import { AdminTableSurface } from "../shared/AdminTableSurface"; import { JobStatusChip } from "../shared/JobStatusChip"; import { TableSkeleton } from "../shared/TableSkeleton"; -import { useServerPagination } from "../shared/tableHooks"; import { ActorCell } from "./ActorCell"; import { ACTIVITY_READ_TIMEOUT_MS, useActivityClient } from "./activityQueries"; import { JobDetail } from "./JobDetail"; @@ -47,24 +47,22 @@ function formatPayload(payload: unknown): string | null { .join(", "); } -function JobRow({ job }: { job: AdminActivityJob }) { - const [expanded, setExpanded] = useState(false); +function JobRow({ job, onSelect }: { job: AdminActivityJob; onSelect: () => void }) { const payload = formatPayload(job.payload); return ( <> - setExpanded((v) => !v)} - sx={{ cursor: "pointer", "& td": { borderBottom: expanded ? "none" : undefined } }} - > + - - {expanded ? ( - - ) : ( - - )} + { + event.stopPropagation(); + onSelect(); + }} + > + @@ -112,29 +110,6 @@ function JobRow({ job }: { job: AdminActivityJob }) { - - ({ - py: 0, - px: 2, - // `theme.palette.mode` always reads "light" with our CSS-vars - // theme, so apply a dark-scope override instead of branching. - bgcolor: "grey.50", - ...theme.applyStyles("dark", { bgcolor: "grey.900" }), - })} - > - - - - - - - ); } @@ -142,8 +117,17 @@ function JobRow({ job }: { job: AdminActivityJob }) { export function JobList() { const { apiUrl, client } = useActivityClient(); const queryClient = useQueryClient(); - const [statusFilter, setStatusFilter] = useState("all"); - const { rowsPerPage, offset, setPage, paginationProps } = useServerPagination(25); + const { params, update } = useAdminQueryState(); + const selection = parseAdminJobSelection(params); + const rawStatus = params.get("status"); + const statusFilter: StatusFilter = + rawStatus === "active" || rawStatus === "completed" || rawStatus === "failed" + ? rawStatus + : "all"; + const rawPage = params.get("page") ?? "0"; + const page = /^\d{1,6}$/.test(rawPage) ? Math.min(100000, Number(rawPage)) : 0; + const rowsPerPage = 25, + offset = page * rowsPerPage; const { data, isLoading, isFetching, isError } = useQuery({ queryKey: ["admin", "jobs", "list", apiUrl, statusFilter, rowsPerPage, offset], @@ -158,104 +142,147 @@ export function JobList() { }); return ( - + {selection && ( + - { - if (v) { - setStatusFilter(v); - setPage(0); - } + + + Selected job · {selection.source} · {selection.id} + + {selection.returnTo && ( + + )} + + + + + )} + - All - Active - Completed - Failed - + { + if (v) { + update({ status: String(v), page: null }, "replace"); + } + }} + > + All + Active + Completed + Failed + - + - + - - - void queryClient.invalidateQueries({ queryKey: ["admin", "jobs", "list"] }) - } - disabled={isFetching} - > - - - -
- } - > - - - - - - Job - Status - Actor - When - - - {isLoading ? ( - - ) : isError ? ( - - - - - Failed to load jobs - - - - - ) : !data?.jobs.length ? ( - + + + void queryClient.invalidateQueries({ queryKey: ["admin", "jobs", "list"] }) + } + disabled={isFetching} + > + + + + + } + > + +
+ - - - No jobs found - - + + Job + Status + Actor + When - - ) : ( - - {data.jobs.map((job) => ( - - ))} - - )} -
-
- - + + {isLoading ? ( + + ) : isError ? ( + + + + + Failed to load jobs + + + + + ) : !data?.jobs.length ? ( + + + + + No jobs found + + + + + ) : ( + + {data.jobs.map((job) => ( + update({ tab: "jobs", job: job.id, source: job.source })} + /> + ))} + + )} + + + update({ page: String(next) }, "replace")} + onRowsPerPageChange={() => {}} + count={data?.total ?? 0} + rowsPerPageOptions={[25]} + hideSinglePage={false} + /> + + ); } diff --git a/apps/web/src/components/admin/activity/__tests__/ActivityConsumers.test.tsx b/apps/web/src/components/admin/activity/__tests__/ActivityConsumers.test.tsx index c459e9f0b..a7260ad9e 100644 --- a/apps/web/src/components/admin/activity/__tests__/ActivityConsumers.test.tsx +++ b/apps/web/src/components/admin/activity/__tests__/ActivityConsumers.test.tsx @@ -418,3 +418,49 @@ it("uses server cancellation capability for an interrupted retained execution", mount(); expect(await screen.findByRole("button", { name: "Cancel" })).not.toBeDisabled(); }); + +it("opens a direct source-qualified job even when the filtered list fails", async () => { + const { adminTestHistory } = await import("@/test/adminNavigation"); + adminTestHistory.reset( + "/admin/activity?tab=jobs&job=not-on-page&source=data-manager&status=failed&page=2", + ); + vi.stubGlobal("fetch", async (input: string) => + input.includes("?limit=") + ? Response.json({ error: "List unavailable" }, { status: 503 }) + : Response.json( + job({ + id: "not-on-page", + source: "data-manager", + status: "success", + cancelable: false, + result: { retained: "exact job evidence" }, + }), + ), + ); + mount(); + expect(await screen.findByText(/exact job evidence/)).toBeInTheDocument(); + expect(await screen.findByText("Failed to load jobs")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Close job details" })); + expect(screen.queryByText(/exact job evidence/)).not.toBeInTheDocument(); +}); +vi.mock( + "next/navigation", + async () => (await import("@/test/adminNavigation")).adminNavigationMock, +); + +it.each(["id", "source"])("rejects a direct detail response with the wrong %s", async (field) => { + vi.stubGlobal("fetch", async () => + Response.json( + job({ + id: field === "id" ? "wrong" : "selected", + source: field === "source" ? "data-manager" : "application", + status: "success", + cancelable: false, + result: { leak: "mismatched evidence" }, + }), + ), + ); + mount(); + expect(await screen.findByText("Failed to load job details.")).toBeInTheDocument(); + expect(screen.queryByText(/mismatched evidence/)).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/admin/activity/activityQueries.ts b/apps/web/src/components/admin/activity/activityQueries.ts index 717ce4d15..c77022a0d 100644 --- a/apps/web/src/components/admin/activity/activityQueries.ts +++ b/apps/web/src/components/admin/activity/activityQueries.ts @@ -35,13 +35,16 @@ export function activityJobQueryOptions( enabled: Boolean(id), queryFn: async ({ signal }): Promise => { if (!id) throw new Error("No job selected"); - return unwrapAdminResponse( + const job = unwrapAdminResponse( await getAdminActivityJob( id, { source }, { client, signal, timeoutMs: ACTIVITY_READ_TIMEOUT_MS }, ), ); + if (job.id !== id || job.source !== source) + throw new Error("Admin job response did not match the requested identity"); + return job; }, }); } diff --git a/apps/web/src/components/admin/integrations/IntegrationDetail.tsx b/apps/web/src/components/admin/integrations/IntegrationDetail.tsx index a0c8172a0..ba8c496c7 100644 --- a/apps/web/src/components/admin/integrations/IntegrationDetail.tsx +++ b/apps/web/src/components/admin/integrations/IntegrationDetail.tsx @@ -41,6 +41,7 @@ import { } from "@/lib/admin/integrationAdmin"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; import { useAdminTab } from "@/lib/admin/useAdminQueryState"; +import { AdminJobLink } from "../activity/AdminJobLink"; import { AdminTablePagination } from "../shared/AdminTablePagination"; import { useAdminToast } from "../shared/AdminToast"; import { MetaRow } from "../shared/MetaRow"; @@ -1090,6 +1091,7 @@ export function IntegrationDetail({ id }: IntegrationDetailProps) { }); const reloadMutation = useIntegrationReload(id); + const [reloadJobId, setReloadJobId] = useState(null); if (isLoading) { return ( @@ -1184,9 +1186,11 @@ export function IntegrationDetail({ id }: IntegrationDetailProps) { startIcon={isBusy ? : } onClick={() => reloadMutation.mutate(undefined, { - onSuccess: () => - owner.active && - showToast("Reload job queued — check Activity for status", "info"), + onSuccess: (result) => { + if (!owner.active) return; + setReloadJobId(result.jobId); + showToast("Reload job queued — check Activity for status", "info"); + }, onError: () => { if (owner.active) showToast("Failed to queue reload job", "error"); }, @@ -1209,6 +1213,7 @@ export function IntegrationDetail({ id }: IntegrationDetailProps) { )} + {reloadJobId && } qc.invalidateQueries({ queryKey: scope }); + const [lastAcceptedJobId, setLastAcceptedJobId] = useState(null); const queued = (jobId: string, label: string) => { register({ client, apiUrl, jobId, onSettled: refresh }); if (owner.active) { + setLastAcceptedJobId(jobId); setMessage(`Queued ${label}. Track its result in Activity (${jobId}).`); void refresh(); } @@ -388,7 +391,15 @@ function ArtifactTransfersView({ apiUrl }: { apiUrl: string }) { ))} - diff --git a/apps/web/src/components/admin/services/BackupsPage.tsx b/apps/web/src/components/admin/services/BackupsPage.tsx index 625fb7dc1..1b78e45be 100644 --- a/apps/web/src/components/admin/services/BackupsPage.tsx +++ b/apps/web/src/components/admin/services/BackupsPage.tsx @@ -34,6 +34,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useMemo, useState } from "react"; import Dialog from "@/components/ui/Dialog"; +import { adminJobHref } from "@/lib/admin/adminJobNavigation"; import { serviceAdminQueries, serviceOrThrow } from "@/lib/admin/serviceAdmin"; import { useServiceJobRegistration } from "@/lib/admin/serviceAdminJobs"; import { useAdminMutationKey, useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; @@ -63,6 +64,7 @@ function BackupsView() { const { client, apiUrl } = useActivityClient(); const register = useServiceJobRegistration(); const showToast = useAdminToast(); + const [lastAcceptedJobId, setLastAcceptedJobId] = useState(null); const createIntent = useAdminMutationKey(client), restoreIntent = useAdminMutationKey(client), deleteIntent = useAdminMutationKey(client); @@ -105,6 +107,7 @@ function BackupsView() { ), ); register({ client, apiUrl, jobId: result.jobId, effect }); + if (execution.active) setLastAcceptedJobId(result.jobId); if (execution.active) { showToast(`Backup create queued (${result.jobId})`); setCreateName(""); @@ -133,6 +136,7 @@ function BackupsView() { ), ); register({ client, apiUrl, jobId: result.jobId, effect }); + if (execution.active) setLastAcceptedJobId(result.jobId); if (execution.active) { showToast(`Restore queued for ${payload.name} (${result.jobId})`); setRestoreTarget(null); @@ -158,6 +162,7 @@ function BackupsView() { sdk.deleteAdminServiceBackup(name, { "idempotency-key": key }, undefined, { client }), ); register({ client, apiUrl, jobId: result.jobId, effect }); + if (execution.active) setLastAcceptedJobId(result.jobId); if (execution.active) { showToast(`Delete queued for ${name} (${result.jobId})`); setDeleteTarget(null); @@ -227,7 +232,16 @@ function BackupsView() { > {createMutation.isPending ? "Queueing…" : "Create backup"} - diff --git a/apps/web/src/components/admin/services/ComposePreview.tsx b/apps/web/src/components/admin/services/ComposePreview.tsx index bfe014dc9..d54dac4dc 100644 --- a/apps/web/src/components/admin/services/ComposePreview.tsx +++ b/apps/web/src/components/admin/services/ComposePreview.tsx @@ -12,6 +12,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRef, useState } from "react"; import { useActivityClient } from "@/components/admin/activity/activityQueries"; import { useComposePreview } from "@/hooks/useComposePreview"; +import { adminJobHref } from "@/lib/admin/adminJobNavigation"; import { invalidateServiceAdministration, serviceOrThrow } from "@/lib/admin/serviceAdmin"; import { useServiceJobRegistration } from "@/lib/admin/serviceAdminJobs"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; @@ -26,6 +27,7 @@ export function ComposePreview() { function ComposePreviewView({ client, apiUrl }: ReturnType) { const queryClient = useQueryClient(); const showToast = useAdminToast(); + const [lastAcceptedJobId, setLastAcceptedJobId] = useState(null); const { data, isLoading, error } = useComposePreview(); const owner = useAdminMutationOwner(client, apiUrl); const composeUpIntent = useRef(null); @@ -48,6 +50,7 @@ function ComposePreviewView({ client, apiUrl }: ReturnType Shutdown and recovery guidance - diff --git a/apps/web/src/components/admin/services/DataWorkflowsPage.tsx b/apps/web/src/components/admin/services/DataWorkflowsPage.tsx index 082e893a4..4162ca0bb 100644 --- a/apps/web/src/components/admin/services/DataWorkflowsPage.tsx +++ b/apps/web/src/components/admin/services/DataWorkflowsPage.tsx @@ -24,6 +24,7 @@ import type { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useState } from "react"; +import { adminJobHref } from "@/lib/admin/adminJobNavigation"; import { invalidateServiceAdministration, serviceAdminQueries, @@ -117,7 +118,12 @@ function DataOperationsSection({ apiUrl }: { apiUrl: string }) { severity="info" sx={{ mb: 2 }} action={ - } diff --git a/apps/web/src/components/admin/services/NotablePlacesMaintenance.tsx b/apps/web/src/components/admin/services/NotablePlacesMaintenance.tsx index 1d077478a..143d0c6a6 100644 --- a/apps/web/src/components/admin/services/NotablePlacesMaintenance.tsx +++ b/apps/web/src/components/admin/services/NotablePlacesMaintenance.tsx @@ -16,6 +16,7 @@ import type { NotablePlacesStatus } from "@openmapx/core/api-contracts"; import { useMutation, useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useMemo, useState } from "react"; +import { adminJobHref } from "@/lib/admin/adminJobNavigation"; import { SERVICE_READ_TIMEOUT_MS } from "@/lib/admin/serviceAdmin"; import { useServiceJobRegistration } from "@/lib/admin/serviceAdminJobs"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; @@ -39,6 +40,7 @@ export function NotablePlacesMaintenance({ apiUrl }: { apiUrl: string }) { } function NotablePlacesMaintenanceView({ apiUrl }: { apiUrl: string }) { const showToast = useAdminToast(); + const [lastAcceptedJobId, setLastAcceptedJobId] = useState(null); const register = useServiceJobRegistration(); const client = useMemo( () => createApiClient({ baseUrl: apiUrl, credentials: "include" }), @@ -71,6 +73,7 @@ function NotablePlacesMaintenanceView({ apiUrl }: { apiUrl: string }) { try { const jobId = await runAdminOperation(client, "notable-places-build", {}); register({ client, apiUrl, jobId, effect }); + if (execution.active) setLastAcceptedJobId(jobId); if (execution.active) { showToast(`Queued notable-places build (${jobId})`); setConfirmOpen(false); @@ -108,7 +111,15 @@ function NotablePlacesMaintenanceView({ apiUrl }: { apiUrl: string }) { - diff --git a/apps/web/src/components/admin/services/OvertureMaintenance.tsx b/apps/web/src/components/admin/services/OvertureMaintenance.tsx index b3070c128..95eff23b8 100644 --- a/apps/web/src/components/admin/services/OvertureMaintenance.tsx +++ b/apps/web/src/components/admin/services/OvertureMaintenance.tsx @@ -22,6 +22,7 @@ import type { OvertureRecoveryStatus, OvertureStatus } from "@openmapx/core/api- import { useMutation, useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useMemo, useState } from "react"; +import { adminJobHref } from "@/lib/admin/adminJobNavigation"; import { SERVICE_READ_TIMEOUT_MS } from "@/lib/admin/serviceAdmin"; import { useServiceJobRegistration } from "@/lib/admin/serviceAdminJobs"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; @@ -134,6 +135,7 @@ export function OvertureMaintenance({ apiUrl }: { apiUrl: string }) { } function OvertureMaintenanceView({ apiUrl }: { apiUrl: string }) { const showToast = useAdminToast(); + const [lastAcceptedJobId, setLastAcceptedJobId] = useState(null); const register = useServiceJobRegistration(); const client = useMemo( () => createApiClient({ baseUrl: apiUrl, credentials: "include" }), @@ -208,6 +210,7 @@ function OvertureMaintenanceView({ apiUrl }: { apiUrl: string }) { const request = operationRequest(operationName, prepared.region); const jobId = await runAdminOperation(client, request.id, prepared.input); register({ client, apiUrl, jobId, effect }); + if (execution.active) setLastAcceptedJobId(jobId); if (execution.active) { showToast(`Queued ${operationName} (${jobId})`); setPrepared(null); @@ -271,7 +274,15 @@ function OvertureMaintenanceView({ apiUrl }: { apiUrl: string }) { > Refresh - diff --git a/apps/web/src/components/admin/services/SearchIndexMaintenance.tsx b/apps/web/src/components/admin/services/SearchIndexMaintenance.tsx index ae210fa85..057b3f99d 100644 --- a/apps/web/src/components/admin/services/SearchIndexMaintenance.tsx +++ b/apps/web/src/components/admin/services/SearchIndexMaintenance.tsx @@ -18,6 +18,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; import { useMemo, useState } from "react"; +import { adminJobHref } from "@/lib/admin/adminJobNavigation"; import { SERVICE_READ_TIMEOUT_MS } from "@/lib/admin/serviceAdmin"; import { useServiceJobRegistration } from "@/lib/admin/serviceAdminJobs"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; @@ -53,6 +54,7 @@ function SearchIndexMaintenanceView({ apiUrl }: { apiUrl: string }) { const t = useTranslations("adminSearchIndex"); const locale = useLocale(); const showToast = useAdminToast(); + const [lastAcceptedJobId, setLastAcceptedJobId] = useState(null); const register = useServiceJobRegistration(); const client = useMemo( () => createApiClient({ baseUrl: apiUrl, credentials: "include" }), @@ -92,6 +94,7 @@ function SearchIndexMaintenanceView({ apiUrl }: { apiUrl: string }) { region: effectiveRegion, }); register({ client, apiUrl, jobId, effect }); + if (execution.active) setLastAcceptedJobId(jobId); if (execution.active) { showToast(t("queued", { jobId })); setConfirmOpen(false); @@ -138,7 +141,15 @@ function SearchIndexMaintenanceView({ apiUrl }: { apiUrl: string }) { - diff --git a/apps/web/src/components/admin/services/ServiceAdministration.test.tsx b/apps/web/src/components/admin/services/ServiceAdministration.test.tsx index 46901fa05..eb9d7c425 100644 --- a/apps/web/src/components/admin/services/ServiceAdministration.test.tsx +++ b/apps/web/src/components/admin/services/ServiceAdministration.test.tsx @@ -2,6 +2,7 @@ import { SERVICE_ACTIONS } from "@openmapx/core/ops"; import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { job } from "@/components/admin/activity/__tests__/fixtures"; +import { AdminDraftProvider } from "@/lib/admin/AdminDraftProvider"; import { AdminJobEffectsProvider } from "@/lib/admin/adminJobEffects"; import { act, cleanup, createTestQueryClient, fireEvent, render, screen, waitFor } from "@/test"; import { RequiredServicesPanel } from "../integrations/RequiredServicesPanel"; @@ -309,7 +310,7 @@ describe("service administration visible behavior with the generated SDK", () => await config(); fireEvent.click(screen.getByRole("button", { name: "Save & apply" })); expect( - await screen.findByText("Start denied; saved configuration retained"), + await screen.findByText(/Configuration saved.*application could not be confirmed/), ).toBeInTheDocument(); await waitFor(() => expect(screen.getByRole("spinbutton")).toHaveValue(2)); expect(qc.getQueryData(["admin", "services", apiUrl, "config", "demo"])).toMatchObject({ @@ -1012,3 +1013,41 @@ vi.mock( "next/navigation", async () => (await import("@/test/adminNavigation")).adminNavigationMock, ); + +it("applies the saved service configuration without posting another save and links its exact job", async () => { + const requests = setup(); + mount(); + await config(); + fireEvent.click(screen.getByRole("button", { name: /^Save \(/ })); + await screen.findByText(/Configuration saved\./); + await waitFor(() => expect(screen.getAllByRole("button", { name: "Start" }).length).toBe(2)); + fireEvent.click(screen.getAllByRole("button", { name: "Start" })[1]); + expect(await screen.findByRole("link", { name: "View job" })).toHaveAttribute( + "href", + "/admin/activity?tab=jobs&job=job-1&source=application", + ); + expect( + requests.filter( + (request) => request.url.pathname.endsWith("/config") && request.init.method === "POST", + ), + ).toHaveLength(1); + expect( + requests.filter( + (request) => request.url.pathname.endsWith("/action") && request.init.method === "POST", + ), + ).toHaveLength(1); +}); +it("retains ordinary service fields through Credentials tab disposal", async () => { + setup(); + mount( + + + , + ); + await config(); + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "9" } }); + fireEvent.click(screen.getByRole("tab", { name: "Credentials" })); + await screen.findByText("Demo token"); + fireEvent.click(screen.getByRole("tab", { name: "Configuration" })); + expect(await screen.findByRole("spinbutton")).toHaveValue(9); +}); diff --git a/apps/web/src/components/admin/services/ServiceCatalog.tsx b/apps/web/src/components/admin/services/ServiceCatalog.tsx index 23bb460c7..af64475ed 100644 --- a/apps/web/src/components/admin/services/ServiceCatalog.tsx +++ b/apps/web/src/components/admin/services/ServiceCatalog.tsx @@ -38,6 +38,7 @@ import { useServicesBulkAction, useServicesList, } from "@/hooks/useServices"; +import { adminJobHref } from "@/lib/admin/adminJobNavigation"; import { serviceActionReason } from "@/lib/admin/serviceAdmin"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; import { useActivityClient } from "../activity/activityQueries"; @@ -176,6 +177,7 @@ function ServiceCatalogView() { const { client, apiUrl } = useActivityClient(); const owner = useAdminMutationOwner(client); const showToast = useAdminToast(); + const [lastAcceptedJobId, setLastAcceptedJobId] = useState(null); const { data, isLoading, isError, refetch } = useServicesList(); const [search, setSearch] = useState(""); @@ -271,6 +273,7 @@ function ServiceCatalogView() { const execution = owner; try { const result = await stageMutation.mutateAsync(stageIntent); + if (execution.active) setLastAcceptedJobId(result.jobId); if (execution.active) { setStageIntent(null); showToast(`Queued image staging (${result.jobId})`); @@ -322,6 +325,7 @@ function ServiceCatalogView() { const execution = owner; try { const result = await bulkMutation.mutateAsync(payload); + if (execution.active) setLastAcceptedJobId(result.jobId); if (execution.active) showToast(`Queued ${payload.action} (${result.jobId})`); } catch (error) { if (execution.active) @@ -714,7 +718,16 @@ function ServiceCatalogView() { > Build All - diff --git a/apps/web/src/components/admin/services/ServiceConfigForm.tsx b/apps/web/src/components/admin/services/ServiceConfigForm.tsx index c72ea56f6..1b96676af 100644 --- a/apps/web/src/components/admin/services/ServiceConfigForm.tsx +++ b/apps/web/src/components/admin/services/ServiceConfigForm.tsx @@ -23,6 +23,9 @@ interface ServiceConfigFormProps { * land in the rendered compose environment at container creation time. */ onSaveAndApply?: (values: Record) => Promise; + onApplySaved?: () => Promise; + applySavedLabel?: string; + applyPending?: boolean; } /** @@ -38,6 +41,9 @@ export function ServiceConfigForm({ ownership, onSave, onSaveAndApply, + onApplySaved, + applySavedLabel, + applyPending, }: ServiceConfigFormProps) { const { apiUrl } = useActivityClient(); const withResourceResets = (values: Record) => @@ -61,6 +67,9 @@ export function ServiceConfigForm({ onSaveAndApply={ onSaveAndApply ? (values) => onSaveAndApply(withResourceResets(values)) : undefined } + onApplySaved={onApplySaved} + applySavedLabel={applySavedLabel} + applyPending={applyPending} applyLabel="Save & apply" emptyMessage="No editable configuration fields declared for this service." /> diff --git a/apps/web/src/components/admin/services/ServiceDetail.tsx b/apps/web/src/components/admin/services/ServiceDetail.tsx index 1e97dcf78..6c928186b 100644 --- a/apps/web/src/components/admin/services/ServiceDetail.tsx +++ b/apps/web/src/components/admin/services/ServiceDetail.tsx @@ -34,11 +34,13 @@ import { import { serviceActionReason } from "@/lib/admin/serviceAdmin"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; import { useAdminTab } from "@/lib/admin/useAdminQueryState"; +import { AdminJobLink } from "../activity/AdminJobLink"; import { useActivityClient } from "../activity/activityQueries"; import { StatusBadge } from "../integrations/StatusBadge"; import { useAdminToast } from "../shared/AdminToast"; import { CompactAlert } from "../shared/CompactAlert"; import { MetaRow } from "../shared/MetaRow"; +import { SavedConfigurationApplyError } from "../shared/SchemaConfigForm"; import { statusColor, statusLabel } from "../shared/ServiceStatusChip"; import { ManagedDawarichSetup } from "./ManagedDawarichSetup"; import { ServiceConfigForm } from "./ServiceConfigForm"; @@ -387,7 +389,15 @@ function OverviewTab({ data }: { data: ServiceDetailData }) { ); } -function ConfigTab({ data }: { data: ServiceDetailData }) { +function ConfigTab({ + data, + onApplySaved, + applyPending, +}: { + data: ServiceDetailData; + onApplySaved: () => Promise; + applyPending: boolean; +}) { const { manifest } = data; const startAction = data.status === "running" ? "apply" : "start"; const reason = (action: import("@openmapx/core/ops").ServiceAction) => @@ -396,7 +406,6 @@ function ConfigTab({ data }: { data: ServiceDetailData }) { // manifest itself so the config tab doesn't refetch the full LoadedService). const configQuery = useServiceConfig(manifest.id); const saveConfig = useServiceConfigSave(manifest.id); - const action = useServiceAction(manifest.id); const { client, apiUrl } = useActivityClient(); const owner = useAdminMutationOwner(client, JSON.stringify([manifest.id, manifest.configSchema])); @@ -431,6 +440,9 @@ function ConfigTab({ data }: { data: ServiceDetailData }) { onSave={async (values) => { await saveConfig.mutateAsync(values); }} + onApplySaved={reason(startAction) ? undefined : onApplySaved} + applySavedLabel={startAction === "start" ? "Start" : "Apply saved configuration"} + applyPending={applyPending} onSaveAndApply={ reason(startAction) ? undefined @@ -443,7 +455,11 @@ function ConfigTab({ data }: { data: ServiceDetailData }) { const execution = owner; await saveConfig.mutateAsync(values); if (!execution.active) return; - await action.mutateAsync(startAction); + try { + await onApplySaved(); + } catch (error) { + throw new SavedConfigurationApplyError(error); + } } } /> @@ -510,6 +526,7 @@ function ServiceDetailView({ id }: ServiceDetailProps) { "manifest", ]); const [logsOpen, setLogsOpen] = useState(false); + const [lastActionJobId, setLastActionJobId] = useState(null); const showToast = useAdminToast(); const { data, isLoading, isError, refetch } = useServiceDetail(id); @@ -544,16 +561,18 @@ function ServiceDetailView({ id }: ServiceDetailProps) { const isBusy = actionMutation.isPending; async function runAction(action: AdminServiceAction["action"]) { - if (reason(action)) return; + if (reason(action)) throw new Error(reason(action) ?? "Action unavailable"); const execution = owner; try { const result = await actionMutation.mutateAsync(action); if (!execution.active) return; + setLastActionJobId(result.jobId); // Terminal observation refreshes the status; acceptance only queues work. showToast(`Service ${action} queued (job ${result.jobId})`); } catch (err) { if (!execution.active) return; showToast(err instanceof Error ? err.message : `Action "${action}" failed`, "error"); + throw err; } } @@ -643,7 +662,9 @@ function ServiceDetailView({ id }: ServiceDetailProps) { variant="contained" color="success" startIcon={isBusy ? : } - onClick={() => runAction(startAction)} + onClick={() => { + void runAction(startAction).catch(() => {}); + }} disabled={isBusy || Boolean(reason(startAction))} > {data.status === "running" ? "Apply changes" : "Start"} @@ -655,7 +676,9 @@ function ServiceDetailView({ id }: ServiceDetailProps) { variant="outlined" color="error" startIcon={isBusy ? : } - onClick={() => runAction("stop")} + onClick={() => { + void runAction("stop").catch(() => {}); + }} disabled={isBusy || Boolean(reason("stop"))} title={reason("stop") ?? undefined} > @@ -667,7 +690,9 @@ function ServiceDetailView({ id }: ServiceDetailProps) { size="small" variant="outlined" startIcon={isBusy ? : } - onClick={() => runAction("restart")} + onClick={() => { + void runAction("restart").catch(() => {}); + }} disabled={isBusy || Boolean(reason("restart"))} > Restart @@ -686,6 +711,7 @@ function ServiceDetailView({ id }: ServiceDetailProps) { > Logs + {lastActionJobId && } {tab === 0 && } - {tab === 1 && } + {tab === 1 && ( + runAction(startAction)} + applyPending={isBusy} + /> + )} {tab === 2 && } {tab === 3 && ( + } diff --git a/apps/web/src/components/admin/settings/PlatformConfiguration.tsx b/apps/web/src/components/admin/settings/PlatformConfiguration.tsx index 55a61afd9..5d6154481 100644 --- a/apps/web/src/components/admin/settings/PlatformConfiguration.tsx +++ b/apps/web/src/components/admin/settings/PlatformConfiguration.tsx @@ -1,4 +1,5 @@ "use client"; + import Alert from "@mui/material/Alert"; import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; @@ -17,9 +18,11 @@ import { type AdminPlatformPreview, adminPlatformPreviewRetainedSchema, } from "@openmapx/core/api-contracts"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; +import { AdminJobLink } from "@/components/admin/activity/AdminJobLink"; import { useAccountAdmin } from "@/lib/admin/accountAdmin"; +import { useOptionalAdminJobRegistration } from "@/lib/admin/adminJobEffects"; import { activityJobQueryOptions, useActivityClient } from "../activity/activityQueries"; import { JobDetail } from "../activity/JobDetail"; import { AdminReauthenticationDialog } from "../users/AdminReauthenticationDialog"; @@ -101,6 +104,8 @@ export function PlatformConfiguration() { ); } function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string }) { + const register = useOptionalAdminJobRegistration(); + const queryClient = useQueryClient(); const { client, apiUrl } = useActivityClient(), storageKey = `openmapx-platform-preview:${apiUrl}:${actorId}`; const [retained, setRetained] = useState { if (!retained || Date.parse(retained.expiresAt) <= Date.now()) throw Error("Preview expired"); applyKey.current ??= crypto.randomUUID(); - return unwrapAdminResponse( + const accepted = await unwrapAdminResponse( await applyAdminPlatformConfiguration( { expectedCurrentRevisionId: retained.preview.expectedCurrentRevisionId, @@ -185,6 +190,16 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string } { client }, ), ); + register?.({ + client, + apiUrl, + jobId: accepted.jobId, + label: "Platform configuration", + kind: "platform", + returnTo: "/admin/settings", + onSettled: () => queryClient.invalidateQueries({ queryKey: ["admin", "platform", apiUrl] }), + }); + return accepted; }, onSuccess: (value) => { setAccepted(value.jobId); @@ -359,7 +374,10 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string } )} {[...new Set([...pending.map((job) => job.jobId), ...(accepted ? [accepted] : [])])].map( (jobId) => ( - +
+ + +
), )} ) => Promise; /** Optional second action that also applies the change. */ onSaveAndApply?: (diff: Record) => Promise; + onApplySaved?: () => Promise; + applySavedLabel?: string; + applyPending?: boolean; /** Label for the apply button (default "Save & apply"). */ applyLabel?: string; /** Feature-specific acknowledgement: an accepted job is not completed work. */ @@ -185,6 +196,9 @@ export function SchemaConfigForm({ ownership, onSave, onSaveAndApply, + onApplySaved, + applySavedLabel = "Apply saved configuration", + applyPending = false, applyLabel = "Save & apply", applySuccessMessage = "Configuration saved and applied.", preserveDirtyValuesOnRefresh: _preserveDirtyValuesOnRefresh = false, @@ -218,24 +232,32 @@ export function SchemaConfigForm({ signature: "schema-config-v1", }); const { values, setValues } = draft; - const [pending, setPending] = useState<"save" | "apply" | null>(null); + const [pending, setPending] = useState<"save" | "apply" | "apply-saved" | null>(null); const [error, setError] = useState(null); const [saved, setSaved] = useState<"saved" | "applied" | null>(null); const changedKeys = draft.dirtyKeys.filter((key) => Object.hasOwn(originalValues, key)); - const saving = pending !== null; + const saving = pending !== null || applyPending; - async function run(kind: "save" | "apply", fn: (diff: Record) => Promise) { + async function run( + kind: "save" | "apply" | "apply-saved", + fn: (diff: Record) => Promise, + ) { + if (saving || draft.conflicts.length > 0) return; setError(null); setSaved(null); setPending(kind); + const submitted = Object.fromEntries(changedKeys.map((key) => [key, values[key]])); try { - const submitted = Object.fromEntries(changedKeys.map((key) => [key, values[key]])); const diff = buildConfigDiff(fields, values, changedKeys); await fn(diff); draft.acknowledge(submitted); - setSaved(kind === "apply" ? "applied" : "saved"); + setSaved(kind === "save" ? "saved" : "applied"); } catch (err) { + if (err instanceof SavedConfigurationApplyError) { + draft.acknowledge(submitted); + setSaved("saved"); + } setError(err instanceof Error ? err.message : "Save failed"); } finally { setPending(null); @@ -272,7 +294,12 @@ export function SchemaConfigForm({ )} {saved === "saved" && ( setSaved(null)}> - Configuration saved.{onSaveAndApply ? " Use Save & apply to take effect." : ""} + Configuration saved. + {onApplySaved + ? " Apply the saved configuration when ready." + : onSaveAndApply + ? " Runtime application is still pending." + : ""} )} {saved === "applied" && ( @@ -453,15 +480,28 @@ export function SchemaConfigForm({ > Save{changedKeys.length > 0 ? ` (${changedKeys.length} changed)` : ""} - {onSaveAndApply && ( + {(onSaveAndApply || onApplySaved) && ( )} diff --git a/apps/web/src/components/admin/shared/__tests__/SchemaConfigForm.ownership.test.tsx b/apps/web/src/components/admin/shared/__tests__/SchemaConfigForm.ownership.test.tsx index d35c712e4..ff589a89a 100644 --- a/apps/web/src/components/admin/shared/__tests__/SchemaConfigForm.ownership.test.tsx +++ b/apps/web/src/components/admin/shared/__tests__/SchemaConfigForm.ownership.test.tsx @@ -85,3 +85,32 @@ it("retains an ordinary draft across tab disposal and requires review of a serve expect(screen.getByRole("textbox")).toHaveValue("theirs"); expect(screen.queryByDisplayValue("never")).not.toBeInTheDocument(); }); + +it("offers Apply saved configuration after saving without another dirty edit", async () => { + const saves: Record[] = []; + const apply = vi.fn(); + const schema = { properties: { region: { type: "string" } } }; + render( + { + saves.push(diff); + }} + onSaveAndApply={async () => {}} + onApplySaved={async () => { + apply(); + }} + />, + ); + const input = screen.getByRole("textbox"); + await userEvent.clear(input); + await userEvent.type(input, "new"); + await userEvent.click(screen.getByRole("button", { name: /^Save \(/ })); + expect( + await screen.findByRole("button", { name: "Apply saved configuration" }), + ).not.toBeDisabled(); + await userEvent.click(screen.getByRole("button", { name: "Apply saved configuration" })); + expect(saves).toEqual([{ region: "new" }]); + expect(apply).toHaveBeenCalledTimes(1); +}); diff --git a/apps/web/src/components/admin/system/SystemMaintenance.tsx b/apps/web/src/components/admin/system/SystemMaintenance.tsx index 9469c913a..b4e47b9ce 100644 --- a/apps/web/src/components/admin/system/SystemMaintenance.tsx +++ b/apps/web/src/components/admin/system/SystemMaintenance.tsx @@ -30,7 +30,9 @@ import { clearAdminReleaseSelection } from "@openmapx/core/api-admin-sdk"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useEffect, useState } from "react"; +import { AdminJobLink } from "@/components/admin/activity/AdminJobLink"; import Dialog from "@/components/ui/Dialog"; +import { useOptionalAdminJobRegistration } from "@/lib/admin/adminJobEffects"; import { serviceOrThrow } from "@/lib/admin/serviceAdmin"; import { activityJobQueryOptions, useActivityClient } from "../activity/activityQueries"; import { AdminPageHeader } from "../shared/AdminPageHeader"; @@ -172,6 +174,7 @@ function imageStatusChip(image: ImageStatus) { export function SystemMaintenance() { const { apiUrl, client } = useActivityClient(); const queryClient = useQueryClient(); + const register = useOptionalAdminJobRegistration(); const showToast = useAdminToast(); const [dialogOpen, setDialogOpen] = useState(false); const [candidateToApply, setCandidateToApply] = useState<{ @@ -281,6 +284,15 @@ export function SystemMaintenance() { response.status === 409 && typeof result.jobId === "string", ); } + register?.({ + client, + apiUrl, + jobId: result.jobId, + label, + kind: "maintenance", + returnTo: "/admin/system", + onSettled: () => queryClient.invalidateQueries({ queryKey: ["admin", "system", apiUrl] }), + }); return { id: result.jobId, label, path }; }, onMutate: ({ path }) => @@ -327,6 +339,15 @@ export function SystemMaintenance() { { client }, ), ); + register?.({ + client, + apiUrl, + jobId: result.jobId, + label: "Release pin clear", + kind: "maintenance", + returnTo: "/admin/system", + onSettled: () => queryClient.invalidateQueries({ queryKey: ["admin", "system", apiUrl] }), + }); return { id: result.jobId, label: "Release pin clear", path: "release/clear" }; }, onSuccess: (job) => { @@ -510,6 +531,7 @@ export function SystemMaintenance() { {trackedJob.label} + diff --git a/apps/web/src/lib/admin/AdminDraftProvider.test.tsx b/apps/web/src/lib/admin/AdminDraftProvider.test.tsx index 545e84bd4..ae48ece9d 100644 --- a/apps/web/src/lib/admin/AdminDraftProvider.test.tsx +++ b/apps/web/src/lib/admin/AdminDraftProvider.test.tsx @@ -81,8 +81,8 @@ function ManyForm({ id }: { id: string }) { it("still accepts an edit after an old clean entry is evicted from the cache", () => { render( - {Array.from({ length: 40 }, (_, id) => ( - + {Array.from({ length: 40 }, (_, id) => `form-${id}`).map((id) => ( + ))} , ); diff --git a/apps/web/src/lib/admin/AdminDraftProvider.tsx b/apps/web/src/lib/admin/AdminDraftProvider.tsx index 4f0535764..5253a7b7a 100644 --- a/apps/web/src/lib/admin/AdminDraftProvider.tsx +++ b/apps/web/src/lib/admin/AdminDraftProvider.tsx @@ -115,3 +115,16 @@ export function useAdminDraftExit() { const request = useContext(ExitContext); return request ?? (() => Promise.resolve(true)); } + +const noopSubscribe = () => () => {}; +const emptySnapshot = () => 0; +/** Also fences accepted work when a confirmed session/role replacement retires drafts. */ +export function useAdminDraftAuthority() { + const store = useContext(AdminDraftContext); + useSyncExternalStore( + store?.subscribe ?? noopSubscribe, + store?.snapshot ?? emptySnapshot, + store?.snapshot ?? emptySnapshot, + ); + return { present: !!store, owner: store?.authority, generation: store?.generation ?? 0 }; +} diff --git a/apps/web/src/lib/admin/adminDraftStore.ts b/apps/web/src/lib/admin/adminDraftStore.ts index 07386388a..b498b21b0 100644 --- a/apps/web/src/lib/admin/adminDraftStore.ts +++ b/apps/web/src/lib/admin/adminDraftStore.ts @@ -34,6 +34,9 @@ export class AdminDraftStore { fn(); }); } + get authority() { + return this.owner; + } get scopeKey() { return this.owner ? JSON.stringify([this.owner.actorId, this.owner.role]) : null; } diff --git a/apps/web/src/lib/admin/adminJobEffects.test.tsx b/apps/web/src/lib/admin/adminJobEffects.test.tsx index b80a9983f..1d5155695 100644 --- a/apps/web/src/lib/admin/adminJobEffects.test.tsx +++ b/apps/web/src/lib/admin/adminJobEffects.test.tsx @@ -1,11 +1,13 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { job } from "@/components/admin/activity/__tests__/fixtures"; +import { AdminJobTray } from "@/components/admin/activity/AdminJobTray"; +import { useActivityClient } from "@/components/admin/activity/activityQueries"; import { useInstallExtension } from "@/hooks/useExtensions"; import { useServiceAction } from "@/hooks/useServices"; import { act, cleanup, createTestQueryClient, fireEvent, render, screen, waitFor } from "@/test"; import { deferred } from "@/test/extensionAdmin"; -import { AdminJobEffectsProvider } from "./adminJobEffects"; +import { AdminJobEffectsProvider, useAdminJobRegistration } from "./adminJobEffects"; let apiUrl = ""; vi.mock("@/integration-api/runtime/EnvProvider", () => ({ useEnv: () => ({ apiUrl }) })); @@ -134,3 +136,152 @@ describe("one layout observer shared by extension and service adapters", () => { expect(observed).toBe(false); }); }); + +it("observes one accepted identity once and unions distinct settlement callbacks", async () => { + const first = vi.fn(), + second = vi.fn(); + const firstEffect = () => { + first(); + }, + secondEffect = () => { + second(); + }; + let finished = false; + vi.stubGlobal("fetch", async () => + Response.json(job({ id: "shared-job", status: finished ? "success" : "running" })), + ); + function Register() { + const { client, apiUrl: origin } = useActivityClient(); + const register = useAdminJobRegistration(); + return ( + + ); + } + const qc = createTestQueryClient(); + caches.push(qc); + render( + + + + + , + ); + fireEvent.click(screen.getByText("Register retry")); + await waitFor(() => + expect( + qc.getQueryData(["admin", "jobs", "detail", "", "application", "shared-job"]), + ).toMatchObject({ status: "running" }), + ); + expect( + qc + .getQueryCache() + .find({ queryKey: ["admin", "jobs", "detail", "", "application", "shared-job"] }) + ?.getObserversCount(), + ).toBe(1); + finished = true; + await act(() => qc.invalidateQueries({ queryKey: ["admin", "jobs", "detail"] })); + await waitFor(() => expect(first).toHaveBeenCalledTimes(1)); + expect(second).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByText("Register retry")); + await act(async () => {}); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); +}); + +it("bounds the visible tray without dropping any of nine unresolved observers", async () => { + vi.stubGlobal("fetch", async (raw: string) => + raw.includes("job-1?") + ? Response.json({ error: "Unavailable" }, { status: 503 }) + : Response.json( + job({ + id: decodeURIComponent(new URL(raw).pathname.split("/").at(-1) ?? ""), + status: "running", + }), + ), + ); + function Register() { + const { client, apiUrl: origin } = useActivityClient(); + const register = useAdminJobRegistration(); + return ( + + ); + } + const qc = createTestQueryClient(); + caches.push(qc); + render( + + + + + + , + ); + fireEvent.click(screen.getByText("Queue nine")); + await waitFor(() => + expect( + qc + .getQueryCache() + .getAll() + .filter((query) => query.queryKey[2] === "detail" && query.getObserversCount() === 1), + ).toHaveLength(9), + ); + fireEvent.click(screen.getByRole("button", { name: "Administration jobs, 9 active" })); + expect(screen.getAllByRole("link", { name: /^Job / })).toHaveLength(8); + expect(screen.getByRole("link", { name: "All jobs" })).toHaveAttribute( + "href", + "/admin/activity?tab=jobs", + ); + expect(await screen.findByText("Outcome unknown")).toBeInTheDocument(); +}); +it("does not observe broad application jobs when the administration role disables registration", async () => { + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + function Register() { + const { client, apiUrl: origin } = useActivityClient(); + const register = useAdminJobRegistration(); + return ( + + ); + } + const qc = createTestQueryClient(); + caches.push(qc); + render( + + + + + , + ); + fireEvent.click(screen.getByText("Try registration")); + await act(async () => {}); + expect(fetcher).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/lib/admin/adminJobEffects.tsx b/apps/web/src/lib/admin/adminJobEffects.tsx index e5c98aa36..520aad686 100644 --- a/apps/web/src/lib/admin/adminJobEffects.tsx +++ b/apps/web/src/lib/admin/adminJobEffects.tsx @@ -16,39 +16,86 @@ import { hasUnresolvedJobObservation, useActivityClient, } from "@/components/admin/activity/activityQueries"; +import { useAdminDraftAuthority } from "./AdminDraftProvider"; import { useAdminMutationOwner } from "./useAdminMutationOwner"; export interface AcceptedAdminJob { client: ApiClient; apiUrl: string; jobId: string; + label?: string; + returnTo?: string; + kind?: string; onSettled: () => void | Promise; } export type RegisterAdminJob = (accepted: AcceptedAdminJob) => void; +export interface ObservedAdminJob { + jobId: string; + source: "application"; + label: string; + returnTo?: string; + kind?: string; + status: string; + unresolved: boolean; + settled: boolean; +} const Registration = createContext(null); +const ObservedJobs = createContext([]); const terminal = new Set(["success", "failed", "canceled"]); - -/** Mounted at AdminLayout: navigation disposes views, never accepted application work. - * Register with the callback captured inside the executing mutation. Its provider - * lifetime fences late acknowledgements after origin replacement (including A→B→A). - * A hook-local client at the same origin is legitimate: retain that executing client. +type Entry = AcceptedAdminJob & { + owner: ReturnType; + callbacks: Set; + called: Set; + status: string; + unresolved: boolean; + settled: boolean; +}; +/** Navigation disposes views, never accepted work. Retain executing clients and owner fences. + * Observation deduplicates identities; settlement retains the union of effect callbacks. + * Completed metadata remains in this session only, independently of the bounded tray UI. */ -export function AdminJobEffectsProvider({ children }: PropsWithChildren) { +export function AdminJobEffectsProvider({ + children, + enabled = true, +}: PropsWithChildren<{ enabled?: boolean }>) { const { client, apiUrl } = useActivityClient(); - const owner = useAdminMutationOwner(client, apiUrl); + const authority = useAdminDraftAuthority(); + const canObserve = enabled && (!authority.present || authority.owner?.role === "admin"); + const owner = useAdminMutationOwner(client, JSON.stringify([apiUrl, authority.generation])); const qc = useQueryClient(); - const [accepted, setAccepted] = useState<(AcceptedAdminJob & { owner: typeof owner })[]>([]); - const jobs = accepted.filter((job) => job.owner === owner); + const [accepted, setAccepted] = useState([]); + const current = canObserve ? accepted.filter((job) => job.owner === owner) : []; + const jobs = current.filter((job) => !job.settled); const register = useCallback( (job) => { - if (!owner.active || job.apiUrl !== apiUrl) return; + if (!canObserve || !owner.active || job.apiUrl !== apiUrl) return; void qc.invalidateQueries({ queryKey: ["admin", "jobs", "list", apiUrl] }); - setAccepted((current) => [ - ...current.filter((previous) => previous.owner === owner), - { ...job, owner }, - ]); + setAccepted((previous) => { + const entries = previous.filter((item) => item.owner === owner); + const existing = entries.find((item) => item.jobId === job.jobId); + if (existing) { + if (existing.callbacks.has(job.onSettled)) return previous; + return entries.map((item) => + item === existing + ? { ...item, callbacks: new Set([...item.callbacks, job.onSettled]) } + : item, + ); + } + return [ + ...entries, + { + ...job, + owner, + callbacks: new Set([job.onSettled]), + called: new Set(), + status: "queued", + unresolved: false, + settled: false, + }, + ]; + }); }, - [owner, apiUrl, qc], + [canObserve, owner, apiUrl, qc], ); const observations = useQueries({ queries: jobs.map((job) => { @@ -75,24 +122,65 @@ export function AdminJobEffectsProvider({ children }: PropsWithChildren) { }), }); useEffect(() => { - const finished = jobs.filter((job, index) => { - const data = observations[index]?.data; - return ( - data?.id === job.jobId && - data.source === "application" && - terminal.has(data.status) && - !hasUnresolvedJobObservation(data.result) + if (!owner.active) return; + const changes = new Map>(); + let settledNow = false; + for (const [index, job] of jobs.entries()) { + const observation = observations[index], + data = observation?.data; + if (!data && observation?.isError && !job.unresolved) + changes.set(job.jobId, { status: job.status, unresolved: true, settled: false }); + if (data?.id !== job.jobId || data.source !== "application") continue; + const unresolved = observation.isError || hasUnresolvedJobObservation(data.result); + const settled = terminal.has(data.status) && !unresolved; + if (job.status !== data.status || job.unresolved !== unresolved || job.settled !== settled) + changes.set(job.jobId, { status: data.status, unresolved, settled }); + if (settled) settledNow = true; + } + for (const job of current) { + if (!job.settled && !changes.get(job.jobId)?.settled) continue; + for (const callback of job.callbacks) { + if (job.called.has(callback)) continue; + job.called.add(callback); + void callback(); + } + } + if (settledNow) void qc.invalidateQueries({ queryKey: ["admin", "jobs", "list", apiUrl] }); + if (changes.size) + setAccepted((previous) => + previous.map((job) => + job.owner === owner && changes.has(job.jobId) + ? { ...job, ...changes.get(job.jobId) } + : job, + ), ); - }); - if (!finished.length) return; - for (const job of finished) void job.onSettled(); - void qc.invalidateQueries({ queryKey: ["admin", "jobs", "list", apiUrl] }); - setAccepted((current) => current.filter((job) => !finished.includes(job))); - }, [jobs, observations, qc, apiUrl]); - return {children}; + }, [current, jobs, observations, qc, apiUrl, owner]); + const metadata = current.map((job) => ({ + jobId: job.jobId, + source: "application" as const, + label: job.label ?? job.jobId, + returnTo: job.returnTo, + kind: job.kind, + status: job.status, + unresolved: job.unresolved, + settled: job.settled, + })); + return ( + + {children} + + ); } export function useAdminJobRegistration(): RegisterAdminJob { const register = useContext(Registration); if (!register) throw new Error("Admin mutations require AdminJobEffectsProvider"); return register; } +export function useAdminObservedJobs() { + return useContext(ObservedJobs); +} + +/** Some focused tools also work outside the admin shell with their own retained observer. */ +export function useOptionalAdminJobRegistration() { + return useContext(Registration); +} diff --git a/apps/web/src/lib/admin/adminJobNavigation.test.ts b/apps/web/src/lib/admin/adminJobNavigation.test.ts new file mode 100644 index 000000000..888d960bd --- /dev/null +++ b/apps/web/src/lib/admin/adminJobNavigation.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from "vitest"; +import { adminJobHref, parseAdminJobSelection } from "./adminJobNavigation"; + +it("source-qualifies and encodes an exact job and safe return", () => { + expect(adminJobHref({ id: "job /1", source: "data-manager", returnTo: "/admin/transit" })).toBe( + "/admin/activity?tab=jobs&job=job+%2F1&source=data-manager&returnTo=%2Fadmin%2Ftransit", + ); +}); +it.each([ + "https://evil.test/admin", + "//evil.test/admin", + "/admin-else", + "/admin/users/../settings?token=secret", + "/admin/unknown", +])("omits an untrusted return destination %s", (returnTo) => { + expect(adminJobHref({ id: "job", source: "application", returnTo })).toBe( + "/admin/activity?tab=jobs&job=job&source=application", + ); +}); +it.each([ + "job=x", + "job=x&source=external", + "job=%0A&source=application", + `job=${"x".repeat(257)}&source=application`, +])("rejects ambiguous or unsafe job selections", (query) => { + expect(parseAdminJobSelection(new URLSearchParams(query))).toBeNull(); +}); +it("keeps both source and id, never treating source as a list filter", () => { + expect(parseAdminJobSelection(new URLSearchParams("job=job-1&source=data-manager"))).toEqual({ + id: "job-1", + source: "data-manager", + returnTo: null, + }); +}); diff --git a/apps/web/src/lib/admin/adminJobNavigation.ts b/apps/web/src/lib/admin/adminJobNavigation.ts new file mode 100644 index 000000000..8d151bdf7 --- /dev/null +++ b/apps/web/src/lib/admin/adminJobNavigation.ts @@ -0,0 +1,92 @@ +import type { AdminActivityJobSource } from "@openmapx/core/api-contracts"; + +const destinations = new Set([ + "/admin", + "/admin/users", + "/admin/privacy", + "/admin/privacy-setup", + "/admin/integrations", + "/admin/extensions", + "/admin/services", + "/admin/services/compose", + "/admin/services/backups", + "/admin/services/data", + "/admin/transit", + "/admin/coverage", + "/admin/activity", + "/admin/settings", + "/admin/system", + "/admin/cache", +]); +const boundedId = (id: string | null): id is string => + !!id && + id.trim().length > 0 && + id.length <= 256 && + !Array.from(id).some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127); +export function safeAdminJobReturn(value: string | null | undefined): string | null { + if (!value || value.length > 2048 || !value.startsWith("/admin") || value.includes("\\")) + return null; + try { + const url = new URL(value, "https://admin.invalid"); + if (url.origin !== "https://admin.invalid" || url.hash || value.split("?")[0] !== url.pathname) + return null; + const detail = /^\/admin\/(services|integrations|users)\/([^/]+)$/.exec(url.pathname); + if ( + !destinations.has(url.pathname) && + (!detail || + !boundedId(decodeURIComponent(detail[2])) || + /[/\\]/.test(decodeURIComponent(detail[2]))) + ) + return null; + if ([...url.searchParams.keys()].some((key) => key !== "tab")) return null; + const tab = url.searchParams.get("tab"); + if ( + tab && + ![ + "overview", + "configuration", + "credentials", + "health", + "sources", + "profile", + "sessions", + "accounts", + "logs", + "manifest", + "browse", + "installed", + "local", + "jobs", + "audit", + ].includes(tab) + ) + return null; + return url.pathname + url.search; + } catch { + return null; + } +} +export function parseAdminJobSelection( + params: Pick, +): { id: string; source: AdminActivityJobSource; returnTo: string | null } | null { + const id = params.get("job"), + source = params.get("source"); + if (!boundedId(id) || (source !== "application" && source !== "data-manager")) return null; + return { id, source, returnTo: safeAdminJobReturn(params.get("returnTo")) }; +} +export function adminJobHref({ + id, + source, + returnTo, +}: { + id: string; + source: AdminActivityJobSource; + returnTo?: string; +}): string { + if (!boundedId(id) || (source !== "application" && source !== "data-manager")) + throw new Error("Invalid administration job identity"); + const params = new URLSearchParams({ tab: "jobs", job: id, source }); + const safeReturn = safeAdminJobReturn(returnTo); + if (safeReturn) params.set("returnTo", safeReturn); + return `/admin/activity?${params.toString()}`; +} diff --git a/apps/web/src/lib/admin/integrationAdmin.ts b/apps/web/src/lib/admin/integrationAdmin.ts index b03af1ff7..e6b65892e 100644 --- a/apps/web/src/lib/admin/integrationAdmin.ts +++ b/apps/web/src/lib/admin/integrationAdmin.ts @@ -18,6 +18,7 @@ import { } from "@/components/admin/activity/activityQueries"; import { invalidateIntegrationRuntime } from "@/lib/integrationRuntimeQuery"; +import { useOptionalAdminJobRegistration } from "./adminJobEffects"; import { integrationErrorMessage } from "./integrationErrors"; export { integrationErrorMessage } from "./integrationErrors"; @@ -231,8 +232,9 @@ export function hasVaultCredential(credential: AdminIntegrationCredential): bool export function useIntegrationReload(id?: string) { const { apiUrl, client } = useActivityClient(); const qc = useQueryClient(); + const register = useOptionalAdminJobRegistration(); const [accepted, setAccepted] = useState<{ client: ApiClient; id: string }[]>([]); - const jobs = accepted.filter((job) => job.client === client); + const jobs = register ? [] : accepted.filter((job) => job.client === client); const observations = useQueries({ queries: jobs.map(({ id: jobId }) => { const options = activityJobQueryOptions(client, apiUrl, jobId, "application"); @@ -271,10 +273,22 @@ export function useIntegrationReload(id?: string) { ? sdk.reloadAdminIntegrations(undefined, { client }) : sdk.reloadAdminIntegration(id, undefined, { client }), ); - setAccepted((current) => [ - ...current.filter((job) => job.client !== client || job.id !== acceptance.jobId), - { client, id: acceptance.jobId }, - ]); + if (register) + register({ + client, + apiUrl, + jobId: acceptance.jobId, + label: id ? `${id} · reload` : "Reload integrations", + returnTo: id ? `/admin/integrations/${encodeURIComponent(id)}` : "/admin/integrations", + onSettled: async () => { + await invalidateIntegrationAdmin(qc, apiUrl); + }, + }); + else + setAccepted((current) => [ + ...current.filter((job) => job.client !== client || job.id !== acceptance.jobId), + { client, id: acceptance.jobId }, + ]); return acceptance; }, }); diff --git a/apps/web/src/lib/admin/serviceAdminJobs.tsx b/apps/web/src/lib/admin/serviceAdminJobs.tsx index d062b923c..521567ab3 100644 --- a/apps/web/src/lib/admin/serviceAdminJobs.tsx +++ b/apps/web/src/lib/admin/serviceAdminJobs.tsx @@ -4,6 +4,7 @@ import type { ApiClient } from "@openmapx/core"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import { useAdminJobRegistration } from "./adminJobEffects"; +import { safeAdminJobReturn } from "./adminJobNavigation"; import { invalidateServiceAdministration, type ServiceAdministrationEffect } from "./serviceAdmin"; export interface AcceptedServiceJob { @@ -18,14 +19,24 @@ export type RegisterServiceJob = (accepted: AcceptedServiceJob) => void; export function useServiceJobRegistration(): RegisterServiceJob { const register = useAdminJobRegistration(); const qc = useQueryClient(); + const tab = + typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("tab"); + const returnTo = + typeof window === "undefined" + ? undefined + : (safeAdminJobReturn( + window.location.pathname + (tab ? `?${new URLSearchParams({ tab }).toString()}` : ""), + ) ?? undefined); return useCallback( ({ client, apiUrl, jobId, effect }) => register({ client, apiUrl, jobId, + label: "serviceId" in effect ? `${effect.serviceId} · ${effect.kind}` : effect.kind, + returnTo, onSettled: () => invalidateServiceAdministration(qc, apiUrl, effect), }), - [register, qc], + [register, qc, returnTo], ); } From f00ba1dba273b24e36925dafeec38fe551aa3776 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 12:07:43 +0100 Subject: [PATCH 04/12] fix(admin): resolve review findings in drafts and accepted work --- .../admin/AdminLayout.navigation.test.tsx | 18 +++++ apps/web/src/components/admin/AdminLayout.tsx | 2 +- .../web/src/components/admin/AdminSidebar.tsx | 2 +- .../admin/activity/AdminJobTray.tsx | 5 +- .../src/components/admin/activity/JobList.tsx | 4 +- .../__tests__/ActivityConsumers.test.tsx | 14 ++++ .../privacy/PrivacyArtifactDelivery.test.tsx | 79 ++++++++++++++++++- .../admin/privacy/PrivacyArtifactDelivery.tsx | 7 +- .../admin/services/ComposePreview.tsx | 3 +- .../services/ServiceAdministration.test.tsx | 29 ++++++- .../admin/services/ServiceConfigForm.tsx | 4 +- .../admin/services/ServiceDetail.tsx | 24 ++++-- .../admin/settings/SystemSettings.tsx | 14 +++- .../admin/shared/SchemaConfigForm.tsx | 24 ++++-- .../users/AdminReauthenticationDialog.tsx | 7 +- apps/web/src/hooks/useServices.ts | 3 +- .../src/lib/admin/AdminDraftProvider.test.tsx | 46 ++++++++++- apps/web/src/lib/admin/AdminDraftProvider.tsx | 65 ++++++++++----- .../web/src/lib/admin/adminDraftStore.test.ts | 14 ++++ apps/web/src/lib/admin/adminDraftStore.ts | 15 +++- .../src/lib/admin/adminJobEffects.test.tsx | 52 ++++++++++++ .../src/lib/admin/adminJobNavigation.test.ts | 7 ++ apps/web/src/lib/admin/adminJobNavigation.ts | 2 +- .../src/lib/admin/serviceAdminJobs.test.tsx | 34 ++++++++ apps/web/src/lib/admin/serviceAdminJobs.tsx | 16 ++-- apps/web/src/lib/admin/useAdminDraft.ts | 4 +- .../src/lib/admin/useAdminMutationOwner.ts | 3 + apps/web/src/lib/jsxHrefSanitization.test.ts | 42 +++++++++- .../src/build-artifacts/child-phase.test.ts | 8 +- packages/i18n/locales/de.json | 7 ++ packages/i18n/locales/en.json | 7 ++ 31 files changed, 495 insertions(+), 66 deletions(-) diff --git a/apps/web/src/components/admin/AdminLayout.navigation.test.tsx b/apps/web/src/components/admin/AdminLayout.navigation.test.tsx index f27e12775..81955218a 100644 --- a/apps/web/src/components/admin/AdminLayout.navigation.test.tsx +++ b/apps/web/src/components/admin/AdminLayout.navigation.test.tsx @@ -1,5 +1,6 @@ import { en } from "@openmapx/i18n"; import { NextIntlClientProvider } from "next-intl"; +import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, expect, it, vi } from "vitest"; import { act, cleanup, createQueryWrapper, fireEvent, render, screen } from "@/test"; import { AdminLayout } from "./AdminLayout"; @@ -67,3 +68,20 @@ it("keeps the desktop sidebar preference independent of mobile drawer navigation }); expect(toggle).toHaveAttribute("aria-expanded", "false"); }); + +it("server-renders the desktop sidebar open without a hydration width shift", () => { + const Wrapper = createQueryWrapper(); + const markup = renderToStaticMarkup( + + + +
Page
+
+
+
, + ); + expect(markup).toContain('aria-expanded="true"'); +}); diff --git a/apps/web/src/components/admin/AdminLayout.tsx b/apps/web/src/components/admin/AdminLayout.tsx index 990b2fa22..af78422a0 100644 --- a/apps/web/src/components/admin/AdminLayout.tsx +++ b/apps/web/src/components/admin/AdminLayout.tsx @@ -32,7 +32,7 @@ export function AdminLayout({ children, user, selfHosted = false }: AdminLayoutP const { apiUrl } = useEnv(); useBindAdminDraftOwner({ apiUrl, actorId: user.id, role: user.role ?? "admin" }); const hydrated = useHydrated(); - const desktop = useMediaQuery("(min-width:900px)"); + const desktop = useMediaQuery("(min-width:900px)", { defaultMatches: true }); const [desktopOpen, setDesktopOpen] = useState(true); const [mobileOpen, setMobileOpen] = useState(false); const sidebarOpen = desktop ? desktopOpen : mobileOpen; diff --git a/apps/web/src/components/admin/AdminSidebar.tsx b/apps/web/src/components/admin/AdminSidebar.tsx index f1c4236a1..c994f3a8c 100644 --- a/apps/web/src/components/admin/AdminSidebar.tsx +++ b/apps/web/src/components/admin/AdminSidebar.tsx @@ -293,7 +293,7 @@ export function AdminSidebar({ role = "admin", }: AdminSidebarProps) { const pathname = usePathname(); - const desktop = useMediaQuery("(min-width:900px)"); + const desktop = useMediaQuery("(min-width:900px)", { defaultMatches: true }); const onNavigate = (event: React.MouseEvent) => { if ( !event.defaultPrevented && diff --git a/apps/web/src/components/admin/activity/AdminJobTray.tsx b/apps/web/src/components/admin/activity/AdminJobTray.tsx index 3038ca096..cfcd4ce76 100644 --- a/apps/web/src/components/admin/activity/AdminJobTray.tsx +++ b/apps/web/src/components/admin/activity/AdminJobTray.tsx @@ -16,7 +16,10 @@ export function AdminJobTray() { const jobs = useAdminObservedJobs(); const [anchor, setAnchor] = useState(null); const active = jobs.filter((job) => !job.settled).length; - const recent = [...jobs].reverse().slice(0, 8); + const recent = [...jobs] + .reverse() + .sort((a, b) => Number(a.settled) - Number(b.settled)) + .slice(0, 8); return ( <> update({ tab: "jobs", job: job.id, source: job.source })} + onSelect={() => + update({ tab: "jobs", job: job.id, source: job.source, returnTo: null }) + } /> ))} diff --git a/apps/web/src/components/admin/activity/__tests__/ActivityConsumers.test.tsx b/apps/web/src/components/admin/activity/__tests__/ActivityConsumers.test.tsx index a7260ad9e..347ec67b0 100644 --- a/apps/web/src/components/admin/activity/__tests__/ActivityConsumers.test.tsx +++ b/apps/web/src/components/admin/activity/__tests__/ActivityConsumers.test.tsx @@ -464,3 +464,17 @@ it.each(["id", "source"])("rejects a direct detail response with the wrong %s", expect(await screen.findByText("Failed to load job details.")).toBeInTheDocument(); expect(screen.queryByText(/mismatched evidence/)).not.toBeInTheDocument(); }); + +it("clears the previous task return when selecting a different inventory job", async () => { + const { adminTestHistory } = await import("@/test/adminNavigation"); + adminTestHistory.reset("/admin/activity?job=old&source=application&returnTo=%2Fadmin%2Fsettings"); + vi.stubGlobal("fetch", async (input: string) => { + const { result, logs, stages, ...row } = job({ id: "next" }); + return input.includes("?limit=") + ? Response.json({ jobs: [{ ...row, actor: null }], total: 1 }) + : Response.json(job({ id: input.includes("/old") ? "old" : "next" })); + }); + mount(); + fireEvent.click(await screen.findByRole("button", { name: "Open job next" })); + expect(screen.queryByRole("link", { name: "Return to task" })).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/admin/privacy/PrivacyArtifactDelivery.test.tsx b/apps/web/src/components/admin/privacy/PrivacyArtifactDelivery.test.tsx index bfac7c42c..c00481d02 100644 --- a/apps/web/src/components/admin/privacy/PrivacyArtifactDelivery.test.tsx +++ b/apps/web/src/components/admin/privacy/PrivacyArtifactDelivery.test.tsx @@ -1,8 +1,12 @@ -import { screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { AdminDraftProvider } from "@/lib/admin/AdminDraftProvider"; +import * as documentExit from "@/lib/admin/adminDocumentExit"; +import { PRIVACY_ADMIN_RESUME_KEY } from "@/lib/admin/privacyReauthentication"; +import { useAdminDraft } from "@/lib/admin/useAdminDraft"; import { PrivacyArtifactDelivery } from "./PrivacyArtifactDelivery"; import { privacyDetail } from "./privacyFixtures.test-support"; -import { renderPrivacyComponent } from "./privacyTestClient.test-support"; +import { privacyJson, renderPrivacyComponent } from "./privacyTestClient.test-support"; vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl()); afterEach(() => vi.unstubAllGlobals()); @@ -54,3 +58,74 @@ describe("assisted artifact delivery availability", () => { ).toBeDisabled(); }); }); + +function DirtyDraft() { + const draft = useAdminDraft({ + id: "privacy-exit", + baseline: { label: "saved" }, + editableKeys: ["label"], + signature: "v1", + }); + return ( + draft.setValues({ label: event.target.value })} + /> + ); +} +it("does not start or retain a privacy reauthentication challenge when the admin chooses Stay", async () => { + sessionStorage.clear(); + vi.stubGlobal("isSecureContext", true); + vi.stubGlobal("showSaveFilePicker", vi.fn()); + const detail = privacyDetail(); + detail.request.state = "ready"; + detail.capabilities.assistedDelivery = true; + detail.artifacts = [ + { + id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + state: "ready", + filename: "export.zip", + plaintextBytes: 8, + expiresAt: "2030-01-01T00:00:00Z", + readyAt: "2026-10-01T00:00:00Z", + }, + ]; + const fetcher = vi.fn(async () => + privacyJson({ + challengeId: "11111111-1111-4111-8111-111111111111", + expiresAt: new Date(Date.now() + 300000).toISOString(), + loginPath: "/auth/privacy/admin", + }), + ); + vi.stubGlobal("fetch", fetcher); + renderPrivacyComponent(detail, (client) => ( + + + + + )); + fireEvent.change(screen.getByLabelText("Admin draft"), { target: { value: "unsaved" } }); + fireEvent.change(screen.getByLabelText("privacyAdmin.workflows.deliveryReason"), { + target: { value: "operator requested" }, + }); + const start = screen.getByRole("button", { + name: "privacyAdmin.workflows.startAssistedDelivery", + }); + await waitFor(() => expect(start).not.toBeDisabled()); + fireEvent.click(start); + fireEvent.click(await screen.findByRole("button", { name: "Stay" })); + expect(fetcher).not.toHaveBeenCalled(); + expect(sessionStorage.getItem(PRIVACY_ADMIN_RESUME_KEY)).toBeNull(); + expect(start).not.toBeDisabled(); + const navigate = vi.spyOn(documentExit, "navigateAdminDocument").mockImplementation(() => {}); + fireEvent.click(start); + fireEvent.click(await screen.findByRole("button", { name: "Discard and continue" })); + await waitFor(() => + expect(navigate).toHaveBeenCalledWith("/auth/privacy/admin", "reauthentication"), + ); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(sessionStorage.getItem(PRIVACY_ADMIN_RESUME_KEY)).not.toBeNull(); + navigate.mockRestore(); + sessionStorage.clear(); +}); diff --git a/apps/web/src/components/admin/privacy/PrivacyArtifactDelivery.tsx b/apps/web/src/components/admin/privacy/PrivacyArtifactDelivery.tsx index 03e19d7aa..97cbbb0a7 100644 --- a/apps/web/src/components/admin/privacy/PrivacyArtifactDelivery.tsx +++ b/apps/web/src/components/admin/privacy/PrivacyArtifactDelivery.tsx @@ -85,6 +85,10 @@ function Artifact({ Date.parse(artifact.expiresAt) > Date.now() && authorized; const start = async () => { + if ( + !(await requestExit("Administrator reauthentication leaves this page.", "reauthentication")) + ) + return; const result = await client.execute("artifact-reauth-start", (c, h) => privacyResult( sdk.startPrivacyAdminReauthentication( @@ -107,8 +111,7 @@ function Artifact({ expiresAt: result.expiresAt, channel, }); - if (await requestExit("Administrator reauthentication leaves this page.")) - navigateAdminDocument("/auth/privacy/admin", "reauthentication"); + navigateAdminDocument("/auth/privacy/admin", "reauthentication"); } catch { setNotice("resumeUnavailable"); } diff --git a/apps/web/src/components/admin/services/ComposePreview.tsx b/apps/web/src/components/admin/services/ComposePreview.tsx index d54dac4dc..ee104fa43 100644 --- a/apps/web/src/components/admin/services/ComposePreview.tsx +++ b/apps/web/src/components/admin/services/ComposePreview.tsx @@ -9,6 +9,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import * as sdk from "@openmapx/core/api-admin-sdk"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; import { useRef, useState } from "react"; import { useActivityClient } from "@/components/admin/activity/activityQueries"; import { useComposePreview } from "@/hooks/useComposePreview"; @@ -211,7 +212,7 @@ function ComposePreviewView({ client, apiUrl }: ReturnType } + > + Administrator access changed. Reload to confirm the current account and permissions. + + ) : ( + {text.loadFailed} + ); } return ( diff --git a/apps/web/src/components/admin/shared/SchemaConfigForm.tsx b/apps/web/src/components/admin/shared/SchemaConfigForm.tsx index 1dc116015..3441fc4fc 100644 --- a/apps/web/src/components/admin/shared/SchemaConfigForm.tsx +++ b/apps/web/src/components/admin/shared/SchemaConfigForm.tsx @@ -15,6 +15,7 @@ import Switch from "@mui/material/Switch"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; +import { ApiClientError } from "@openmapx/core"; import type { ConfigurationOwnership } from "@openmapx/core/api-contracts"; import { OPERATOR_STRING_FORMATS, validOperatorString } from "@openmapx/core/configuration-values"; import { type CredentialSetup, readCredentialSetup } from "@openmapx/integration-framework"; @@ -150,10 +151,18 @@ export function extractConfigFields( })); } +export class AdminActionNotStartedError extends Error {} export class SavedConfigurationApplyError extends Error { constructor(cause: unknown) { + const status = + cause instanceof Error && cause.cause instanceof ApiClientError ? cause.cause.status : null; + const notStarted = + cause instanceof AdminActionNotStartedError || + (status !== null && status >= 400 && status < 500 && status !== 408); super( - "Configuration saved. Service application could not be confirmed. Inspect Activity before trying again.", + notStarted + ? `Configuration saved. Service application was not started. ${cause instanceof Error ? cause.message : "The action was rejected."}` + : "Configuration saved. Service application could not be confirmed. Inspect Activity before trying again.", { cause }, ); } @@ -181,8 +190,7 @@ export interface SchemaConfigFormProps { applyLabel?: string; /** Feature-specific acknowledgement: an accepted job is not completed work. */ applySuccessMessage?: string; - /** Preserve edited fields when a background read refreshes the saved baseline. */ - preserveDirtyValuesOnRefresh?: boolean; + applySavedSuccessMessage?: string; /** Shown when there are no editable (non-secret) fields. */ emptyMessage?: string; } @@ -201,7 +209,7 @@ export function SchemaConfigForm({ applyPending = false, applyLabel = "Save & apply", applySuccessMessage = "Configuration saved and applied.", - preserveDirtyValuesOnRefresh: _preserveDirtyValuesOnRefresh = false, + applySavedSuccessMessage = "Saved configuration application queued.", emptyMessage = "No editable configuration fields. Credentials live on the Credentials tab.", }: SchemaConfigFormProps) { const fields = useMemo(() => extractConfigFields(schema, excludeKeys), [schema, excludeKeys]); @@ -234,7 +242,7 @@ export function SchemaConfigForm({ const { values, setValues } = draft; const [pending, setPending] = useState<"save" | "apply" | "apply-saved" | null>(null); const [error, setError] = useState(null); - const [saved, setSaved] = useState<"saved" | "applied" | null>(null); + const [saved, setSaved] = useState<"saved" | "applied" | "applied-saved" | null>(null); const changedKeys = draft.dirtyKeys.filter((key) => Object.hasOwn(originalValues, key)); const saving = pending !== null || applyPending; @@ -252,7 +260,7 @@ export function SchemaConfigForm({ const diff = buildConfigDiff(fields, values, changedKeys); await fn(diff); draft.acknowledge(submitted); - setSaved(kind === "save" ? "saved" : "applied"); + setSaved(kind === "save" ? "saved" : kind === "apply-saved" ? "applied-saved" : "applied"); } catch (err) { if (err instanceof SavedConfigurationApplyError) { draft.acknowledge(submitted); @@ -302,9 +310,9 @@ export function SchemaConfigForm({ : ""} )} - {saved === "applied" && ( + {(saved === "applied" || saved === "applied-saved") && ( setSaved(null)}> - {applySuccessMessage} + {saved === "applied-saved" ? applySavedSuccessMessage : applySuccessMessage} )} {envPrefix && ( diff --git a/apps/web/src/components/admin/users/AdminReauthenticationDialog.tsx b/apps/web/src/components/admin/users/AdminReauthenticationDialog.tsx index d0f1460f1..4d13044d0 100644 --- a/apps/web/src/components/admin/users/AdminReauthenticationDialog.tsx +++ b/apps/web/src/components/admin/users/AdminReauthenticationDialog.tsx @@ -128,7 +128,12 @@ export function AdminReauthenticationDialog(props: Props) { href="/auth/admin" onClick={async (event) => { event.preventDefault(); - if (await requestExit("Administrator reauthentication leaves this page.")) + if ( + await requestExit( + "Administrator reauthentication leaves this page.", + "reauthentication", + ) + ) navigateAdminDocument("/auth/admin", "reauthentication"); }} > diff --git a/apps/web/src/hooks/useServices.ts b/apps/web/src/hooks/useServices.ts index eb33cc691..61329b6b4 100644 --- a/apps/web/src/hooks/useServices.ts +++ b/apps/web/src/hooks/useServices.ts @@ -47,7 +47,7 @@ function useServiceWrite( const qc = useQueryClient(); const register = useServiceJobRegistration(); const intent = useAdminMutationKey(client); - return useMutation({ + const mutation = useMutation({ mutationKey: ["service-write", apiUrl, effect], retry: false, mutationFn: async (values: V) => { @@ -62,6 +62,7 @@ function useServiceWrite( return result; }, }); + return { ...mutation, resetIntent: intent.reset }; } export function useServiceConfigSave(id: string) { return useServiceWrite( diff --git a/apps/web/src/lib/admin/AdminDraftProvider.test.tsx b/apps/web/src/lib/admin/AdminDraftProvider.test.tsx index ae48ece9d..9e758c1a7 100644 --- a/apps/web/src/lib/admin/AdminDraftProvider.test.tsx +++ b/apps/web/src/lib/admin/AdminDraftProvider.test.tsx @@ -1,6 +1,6 @@ import { afterEach, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@/test"; -import { AdminDraftProvider } from "./AdminDraftProvider"; +import { AdminDraftProvider, useAdminDraftAuthority } from "./AdminDraftProvider"; import { allowAdminDocumentExit } from "./adminDocumentExit"; import { useAdminDraft } from "./useAdminDraft"; @@ -90,3 +90,47 @@ it("still accepts an edit after an old clean entry is evicted from the cache", ( expect(screen.getByLabelText("form-0")).toHaveValue("edited after eviction"); expect(exit()).toBe(true); }); + +function OptionalField() { + const draft = useAdminDraft({ + id: "optional", + baseline: { x: undefined }, + editableKeys: ["x"], + signature: "v1", + }); + return ( + draft.setValues({ x: event.target.value })} + /> + ); +} +it("retains an edited optional field whose baseline was undefined", () => { + render( + + + , + ); + fireEvent.change(screen.getByLabelText("Optional"), { target: { value: "a" } }); + expect(screen.getByLabelText("Optional")).toHaveValue("a"); + expect(exit()).toBe(true); +}); + +it("does not notify authority consumers for ordinary keystrokes", () => { + let renders = 0; + function Authority() { + useAdminDraftAuthority(); + renders++; + return Authority; + } + render( + + + + , + ); + const before = renders; + fireEvent.change(screen.getByLabelText("Label"), { target: { value: "ordinary edit" } }); + expect(renders).toBe(before); +}); diff --git a/apps/web/src/lib/admin/AdminDraftProvider.tsx b/apps/web/src/lib/admin/AdminDraftProvider.tsx index 5253a7b7a..6553ba1a2 100644 --- a/apps/web/src/lib/admin/AdminDraftProvider.tsx +++ b/apps/web/src/lib/admin/AdminDraftProvider.tsx @@ -5,6 +5,7 @@ import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; import { authClient } from "@openmapx/core"; +import { useTranslations } from "next-intl"; import { createContext, type PropsWithChildren, @@ -19,28 +20,45 @@ import { import Dialog from "@/components/ui/Dialog"; import { useEnv } from "@/integration-api/runtime/EnvProvider"; import { consumeAdminDocumentExitAllowance } from "./adminDocumentExit"; -import { type AdminDraftOwner, AdminDraftStore } from "./adminDraftStore"; +import { + type AdminDraftDiscardReason, + type AdminDraftOwner, + AdminDraftStore, +} from "./adminDraftStore"; export const AdminDraftContext = createContext(null); -const ExitContext = createContext<((reason: string) => Promise) | null>(null); -export function AdminDraftProvider({ children }: PropsWithChildren) { +type RequestExit = (message: string, reason?: AdminDraftDiscardReason) => Promise; +const ExitContext = createContext(null); +const DEFAULT_COPY = { + title: "Discard unsaved administration changes?", + ordinary: "Ordinary drafts will be discarded. Stay to review your work first.", + reauthentication: "The reviewed reauthentication continuation will be retained.", + stay: "Stay", + discard: "Discard and continue", +}; +export function AdminDraftProvider({ + children, + copy = DEFAULT_COPY, +}: PropsWithChildren<{ copy?: typeof DEFAULT_COPY }>) { const [store] = useState(() => new AdminDraftStore()); const [reason, setReason] = useState(null); - const pending = useRef<((choice: boolean) => void) | null>(null); - useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot); + const pending = useRef<{ + resolve: (choice: boolean) => void; + reason: AdminDraftDiscardReason; + } | null>(null); const requestExit = useCallback( - (message: string): Promise => { + (message: string, reason: AdminDraftDiscardReason = "discard"): Promise => { if (!store.dirty) return Promise.resolve(true); if (pending.current) return Promise.resolve(false); setReason(message); return new Promise((resolve) => { - pending.current = resolve; + pending.current = { resolve, reason }; }); }, [store], ); const choose = (discard: boolean) => { - if (discard) store.discard(); - pending.current?.(discard); + if (discard) store.discard(undefined, undefined, pending.current?.reason); + pending.current?.resolve(discard); pending.current = null; setReason(null); }; @@ -53,7 +71,7 @@ export function AdminDraftProvider({ children }: PropsWithChildren) { window.addEventListener("beforeunload", beforeUnload); return () => { window.removeEventListener("beforeunload", beforeUnload); - pending.current?.(false); + pending.current?.resolve(false); }; }, [store]); return ( @@ -65,20 +83,19 @@ export function AdminDraftProvider({ children }: PropsWithChildren) { onClose={() => choose(false)} aria-labelledby="admin-draft-exit-title" > - - Discard unsaved administration changes? - + {copy.title} - {reason} Ordinary drafts will be discarded. Stay to save them first. + {reason} {copy.ordinary}{" "} + {pending.current?.reason === "reauthentication" && copy.reauthentication} @@ -98,8 +115,16 @@ function AuthorityObserver() { } /** Must stay outside SessionAuthorityBoundary, including its pending refresh gate. */ export function AdminDraftRoot({ children }: PropsWithChildren) { + const t = useTranslations("adminDrafts"); + const copy = { + title: t("title"), + ordinary: t("ordinary"), + reauthentication: t("reauthentication"), + stay: t("stay"), + discard: t("discard"), + }; return ( - + {children} @@ -117,14 +142,14 @@ export function useAdminDraftExit() { } const noopSubscribe = () => () => {}; -const emptySnapshot = () => 0; +const emptySnapshot = () => "none"; /** Also fences accepted work when a confirmed session/role replacement retires drafts. */ export function useAdminDraftAuthority() { const store = useContext(AdminDraftContext); useSyncExternalStore( store?.subscribe ?? noopSubscribe, - store?.snapshot ?? emptySnapshot, - store?.snapshot ?? emptySnapshot, + store?.authoritySnapshot ?? emptySnapshot, + store?.authoritySnapshot ?? emptySnapshot, ); return { present: !!store, owner: store?.authority, generation: store?.generation ?? 0 }; } diff --git a/apps/web/src/lib/admin/adminDraftStore.test.ts b/apps/web/src/lib/admin/adminDraftStore.test.ts index 3201e6754..7aa8f9169 100644 --- a/apps/web/src/lib/admin/adminDraftStore.test.ts +++ b/apps/web/src/lib/admin/adminDraftStore.test.ts @@ -110,3 +110,17 @@ it("retains a null baseline across repeated edits for conflict detection", () => s.update("form", { x: "second" }); expect(s.read("form").conflicts).toEqual(["x"]); }); + +it("notifies metadata consumers of explicit discard separately from a reauthentication exit", () => { + const store = new AdminDraftStore(); + const events: unknown[] = []; + const stop = store.subscribeDiscard((event) => events.push(event)); + store.discard("form"); + store.discard(undefined, undefined, "reauthentication"); + stop(); + store.discard(); + expect(events).toEqual([ + { id: "form", reason: "discard" }, + { id: undefined, reason: "reauthentication" }, + ]); +}); diff --git a/apps/web/src/lib/admin/adminDraftStore.ts b/apps/web/src/lib/admin/adminDraftStore.ts index b498b21b0..c221ae9ea 100644 --- a/apps/web/src/lib/admin/adminDraftStore.ts +++ b/apps/web/src/lib/admin/adminDraftStore.ts @@ -1,3 +1,5 @@ +export type AdminDraftDiscardReason = "discard" | "reauthentication"; +export type AdminDraftDiscard = { id?: string; reason: AdminDraftDiscardReason }; export interface AdminDraftOwner { apiUrl: string; actorId: string; @@ -16,6 +18,13 @@ const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b export class AdminDraftStore { private entries = new Map(); private listeners = new Set<() => void>(); + private discardListeners = new Set<(event: AdminDraftDiscard) => void>(); + subscribeDiscard = (listener: (event: AdminDraftDiscard) => void) => { + this.discardListeners.add(listener); + return () => { + this.discardListeners.delete(listener); + }; + }; private version = 0; private owner: AdminDraftOwner | null = null; private origin: string | undefined; @@ -28,6 +37,7 @@ export class AdminDraftStore { }; }; snapshot = () => this.version; + authoritySnapshot = () => JSON.stringify([this.generation, this.scopeKey]); private emit() { this.version++; this.listeners.forEach((fn) => { @@ -172,13 +182,16 @@ export class AdminDraftStore { this.trim(); this.emit(); } - discard(id?: string, keys?: readonly string[]) { + discard(id?: string, keys?: readonly string[], reason: AdminDraftDiscardReason = "discard") { for (const [key, entry] of this.entries) if (!id || id === key) { if (keys) for (const field of keys) entry.edits.delete(field); else entry.edits.clear(); } this.trim(); + this.discardListeners.forEach((listener) => { + listener({ id, reason }); + }); this.emit(); } keepEdits(id: string, keys?: readonly string[]) { diff --git a/apps/web/src/lib/admin/adminJobEffects.test.tsx b/apps/web/src/lib/admin/adminJobEffects.test.tsx index 1d5155695..199b1c82a 100644 --- a/apps/web/src/lib/admin/adminJobEffects.test.tsx +++ b/apps/web/src/lib/admin/adminJobEffects.test.tsx @@ -285,3 +285,55 @@ it("does not observe broad application jobs when the administration role disable await act(async () => {}); expect(fetcher).not.toHaveBeenCalled(); }); + +it("keeps an older active job visible before newer completed jobs", async () => { + vi.stubGlobal("fetch", async (raw: string) => { + const id = new URL(raw).pathname.split("/").at(-1) ?? ""; + return Response.json(job({ id, status: id === "old-active" ? "running" : "success" })); + }); + function Register() { + const { client, apiUrl: origin } = useActivityClient(); + const register = useAdminJobRegistration(); + return ( + + ); + } + const qc = createTestQueryClient(); + caches.push(qc); + render( + + + + + + , + ); + fireEvent.click(screen.getByText("Queue mixed")); + await screen.findByRole("button", { name: "Administration jobs, 1 active" }); + fireEvent.click(screen.getByRole("button", { name: "Administration jobs, 1 active" })); + expect(screen.getByRole("link", { name: "Still running" })).toHaveAttribute( + "href", + "/admin/activity?tab=jobs&job=old-active&source=application", + ); +}); diff --git a/apps/web/src/lib/admin/adminJobNavigation.test.ts b/apps/web/src/lib/admin/adminJobNavigation.test.ts index 888d960bd..be053f4de 100644 --- a/apps/web/src/lib/admin/adminJobNavigation.test.ts +++ b/apps/web/src/lib/admin/adminJobNavigation.test.ts @@ -32,3 +32,10 @@ it("keeps both source and id, never treating source as a list filter", () => { returnTo: null, }); }); + +it.each(["", " ", "\n", "x".repeat(257)])( + "falls back to Activity for an unaddressable server job %s", + (id) => { + expect(adminJobHref({ id, source: "application" })).toBe("/admin/activity?tab=jobs"); + }, +); diff --git a/apps/web/src/lib/admin/adminJobNavigation.ts b/apps/web/src/lib/admin/adminJobNavigation.ts index 8d151bdf7..68203cbe8 100644 --- a/apps/web/src/lib/admin/adminJobNavigation.ts +++ b/apps/web/src/lib/admin/adminJobNavigation.ts @@ -84,7 +84,7 @@ export function adminJobHref({ returnTo?: string; }): string { if (!boundedId(id) || (source !== "application" && source !== "data-manager")) - throw new Error("Invalid administration job identity"); + return "/admin/activity?tab=jobs"; const params = new URLSearchParams({ tab: "jobs", job: id, source }); const safeReturn = safeAdminJobReturn(returnTo); if (safeReturn) params.set("returnTo", safeReturn); diff --git a/apps/web/src/lib/admin/serviceAdminJobs.test.tsx b/apps/web/src/lib/admin/serviceAdminJobs.test.tsx index 26edd1229..fe6b10b42 100644 --- a/apps/web/src/lib/admin/serviceAdminJobs.test.tsx +++ b/apps/web/src/lib/admin/serviceAdminJobs.test.tsx @@ -2,6 +2,7 @@ import { runAdminServiceAction } from "@openmapx/core/api-admin-sdk"; import { useMutation, useQuery } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { job } from "@/components/admin/activity/__tests__/fixtures"; +import { AdminJobTray } from "@/components/admin/activity/AdminJobTray"; import { useActivityClient } from "@/components/admin/activity/activityQueries"; import { act, @@ -13,6 +14,7 @@ import { screen, waitFor, } from "@/test"; +import { adminTestHistory } from "@/test/adminNavigation"; import { AdminJobEffectsProvider } from "./adminJobEffects"; import { serviceAdminQueries, serviceOrThrow } from "./serviceAdmin"; import { useServiceJobRegistration } from "./serviceAdminJobs"; @@ -307,3 +309,35 @@ describe("admin-owned accepted service jobs", () => { expect(qc.getQueryState(["admin", "services", apiUrl, "list"])?.isInvalidated).toBe(false); }); }); + +vi.mock( + "next/navigation", + async () => (await import("@/test/adminNavigation")).adminNavigationMock, +); +it("records the committed Next tab rather than the previous browser URL", async () => { + window.history.replaceState(null, "", "/admin/services/demo?tab=overview"); + adminTestHistory.reset("/admin/services/demo?tab=overview"); + vi.stubGlobal("fetch", async (raw: string) => + raw.endsWith("/action") + ? Response.json({ ok: true, jobId: "tab-job" }) + : Response.json(job({ id: "tab-job", status: "running" })), + ); + const qc = createTestQueryClient(); + caches.push(qc); + const Wrapper = createQueryWrapper(qc); + render( + + + + + + , + ); + act(() => adminTestHistory.push("/admin/services/demo?tab=configuration")); + fireEvent.click(screen.getByText("Queue")); + fireEvent.click(await screen.findByRole("button", { name: "Administration jobs, 1 active" })); + const link = await screen.findByRole("link", { name: "demo · service" }); + expect(new URL(link.getAttribute("href") ?? "", "http://test").searchParams.get("returnTo")).toBe( + "/admin/services/demo?tab=configuration", + ); +}); diff --git a/apps/web/src/lib/admin/serviceAdminJobs.tsx b/apps/web/src/lib/admin/serviceAdminJobs.tsx index 521567ab3..c48f40674 100644 --- a/apps/web/src/lib/admin/serviceAdminJobs.tsx +++ b/apps/web/src/lib/admin/serviceAdminJobs.tsx @@ -2,6 +2,7 @@ import type { ApiClient } from "@openmapx/core"; import { useQueryClient } from "@tanstack/react-query"; +import { usePathname, useSearchParams } from "next/navigation"; import { useCallback } from "react"; import { useAdminJobRegistration } from "./adminJobEffects"; import { safeAdminJobReturn } from "./adminJobNavigation"; @@ -19,14 +20,13 @@ export type RegisterServiceJob = (accepted: AcceptedServiceJob) => void; export function useServiceJobRegistration(): RegisterServiceJob { const register = useAdminJobRegistration(); const qc = useQueryClient(); - const tab = - typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("tab"); - const returnTo = - typeof window === "undefined" - ? undefined - : (safeAdminJobReturn( - window.location.pathname + (tab ? `?${new URLSearchParams({ tab }).toString()}` : ""), - ) ?? undefined); + const pathname = usePathname(), + params = useSearchParams(); + const tab = params?.get("tab"); + const returnTo = pathname + ? (safeAdminJobReturn(pathname + (tab ? `?${new URLSearchParams({ tab }).toString()}` : "")) ?? + undefined) + : undefined; return useCallback( ({ client, apiUrl, jobId, effect }) => register({ diff --git a/apps/web/src/lib/admin/useAdminDraft.ts b/apps/web/src/lib/admin/useAdminDraft.ts index 1253864bf..6d69c92e8 100644 --- a/apps/web/src/lib/admin/useAdminDraft.ts +++ b/apps/web/src/lib/admin/useAdminDraft.ts @@ -23,7 +23,9 @@ export function useAdminDraft({ useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot); const generation = store.generation; const entryId = JSON.stringify([id, signature]); - const baselineKey = JSON.stringify(baseline), + const baselineKey = JSON.stringify(baseline, (_key, value) => + value === undefined ? null : value, + ), keys = JSON.stringify(editableKeys); useLayoutEffect(() => { if (!ready) return; diff --git a/apps/web/src/lib/admin/useAdminMutationOwner.ts b/apps/web/src/lib/admin/useAdminMutationOwner.ts index 2b3e064f1..ea1d07c1b 100644 --- a/apps/web/src/lib/admin/useAdminMutationOwner.ts +++ b/apps/web/src/lib/admin/useAdminMutationOwner.ts @@ -13,6 +13,9 @@ export function useAdminMutationKey(client: ApiClient) { current.current = { client, intent, key: crypto.randomUUID() }; return current.current.key; }, + reset() { + current.current = null; + }, complete(key: string) { if (current.current?.key === key) current.current = null; }, diff --git a/apps/web/src/lib/jsxHrefSanitization.test.ts b/apps/web/src/lib/jsxHrefSanitization.test.ts index ae5de6f19..844a5147e 100644 --- a/apps/web/src/lib/jsxHrefSanitization.test.ts +++ b/apps/web/src/lib/jsxHrefSanitization.test.ts @@ -28,14 +28,50 @@ const ALLOWED: Record = { // Both are relative in-app deep links built from validated numeric // coordinates and enum modes; no third-party value reaches the binding. "app/s/[token]/SharedViewClient.tsx": ["placeAppUrl(place)", "openUrl"], - "components/admin/AdminSidebar.tsx": ["item.href"], + "components/admin/AdminSidebar.tsx": ["item.href", "entry.href"], "components/admin/AdminTopBar.tsx": ["crumb.href"], "components/admin/shared/AdminPageHeader.tsx": ["backHref"], "components/admin/shared/CompactAlertList.tsx": ["item.href"], - "components/admin/overview/AdminOverview.tsx": ["href"], + "components/admin/overview/AdminOverview.tsx": [ + "href", + "adminJobHref({ id: job.id, source: job.source })", + ], // `actionHref` is read only from the module-local CHECK_ACTIONS constant, // whose values are fixed relative admin routes; no runtime URL enters it. "components/admin/privacy/PrivacyReadinessChecklist.tsx": ["actionHref"], + // Registry entries and validated job-return values are internal administration destinations. + "components/admin/data/DataWorkspace.tsx": ["entry.href"], + "components/admin/shared/AdminTopicNavigation.tsx": ["entry.href"], + "components/admin/activity/JobList.tsx": ["selection.returnTo"], + // adminJobHref validates the source/id and encodes every runtime part into an internal query. + "components/admin/activity/AdminJobLink.tsx": ["adminJobHref({ id, source, returnTo })"], + "components/admin/activity/AdminJobTray.tsx": [ + "adminJobHref({ id: job.jobId, source: job.source, returnTo: job.returnTo })", + ], + "components/admin/services/ServiceCatalog.tsx": [ + 'adminJobHref({ id: lastAcceptedJobId, source: "application" })', + ], + "components/admin/services/TransitSourcesSection.tsx": [ + 'adminJobHref({ id: syncJob?.jobId ?? lastJobId ?? "", source: "data-manager" })', + ], + "components/admin/services/ArtifactTransfersSection.tsx": [ + `lastAcceptedJobId ? adminJobHref({ id: lastAcceptedJobId, source: "application" }) : "/admin/activity"`, + ], + "components/admin/services/BackupsPage.tsx": [ + `lastAcceptedJobId ? adminJobHref({ id: lastAcceptedJobId, source: "application" }) : "/admin/activity"`, + ], + "components/admin/services/ComposePreview.tsx": [ + `lastAcceptedJobId ? adminJobHref({ id: lastAcceptedJobId, source: "application" }) : "/admin/activity"`, + ], + "components/admin/services/NotablePlacesMaintenance.tsx": [ + `lastAcceptedJobId ? adminJobHref({ id: lastAcceptedJobId, source: "application" }) : "/admin/activity"`, + ], + "components/admin/services/OvertureMaintenance.tsx": [ + `lastAcceptedJobId ? adminJobHref({ id: lastAcceptedJobId, source: "application" }) : "/admin/activity"`, + ], + "components/admin/services/SearchIndexMaintenance.tsx": [ + `lastAcceptedJobId ? adminJobHref({ id: lastAcceptedJobId, source: "application" }) : "/admin/activity"`, + ], "components/auth/MangroveLocalAccountSection.tsx": ["MANGROVE_HOME_URL"], "components/panels/place/reviews/WriteReviewDialog.tsx": [ "MANGROVE_HOME_URL", @@ -118,7 +154,7 @@ function findUnsafeHrefs(file: string): string[] { expression.startsWith('"') || expression.startsWith("'") || expression.startsWith("`") || - allowed.has(expression) + [...allowed].some((entry) => entry.replace(/\s+/g, " ") === expression.replace(/\s+/g, " ")) ) { continue; } diff --git a/packages/core/src/build-artifacts/child-phase.test.ts b/packages/core/src/build-artifacts/child-phase.test.ts index 9458ae551..bf0b24448 100644 --- a/packages/core/src/build-artifacts/child-phase.test.ts +++ b/packages/core/src/build-artifacts/child-phase.test.ts @@ -523,7 +523,13 @@ it("binds one same-slot identity across actual filesystem processes", async () = expect(bound).not.toBeNull(); for (const result of results) if (result.ok) expect(result.child).toEqual(bound); - else expect(result.error).toMatch(/busy|writer/); + else { + // A process can read the old signed cell just before its peer publishes + // the binding and updates the retained digest. The indexed-record check + // must refuse that stale read too. Neither refusal authorizes a new + // identity; the fresh retry below must observe the winner exactly. + expect(result.error).toMatch(/busy|writer|^Error: Retained record digest changed$/); + } expect( await control.bindControlChildPhase(f.authority, { phase: "stop-primary", ordinal: 0 }), ).toEqual(bound); diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 28d667109..707706309 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -4908,5 +4908,12 @@ "failed": "Das Passwort konnte nicht gespeichert werden. Prüfe die Passwortanforderungen oder fordere einen neuen Link an.", "originChanged": "Die API hat sich geändert. Öffne den ursprünglichen Link erneut, bevor du dein Passwort festlegst.", "back": "Zurück zu OpenMapX" + }, + "adminDrafts": { + "title": "Nicht gespeicherte Verwaltungsänderungen verwerfen?", + "ordinary": "Gewöhnliche Entwürfe werden verworfen. Bleiben Sie, um Ihre Arbeit zuerst zu prüfen.", + "reauthentication": "Die geprüfte Fortsetzung der erneuten Anmeldung bleibt erhalten.", + "stay": "Bleiben", + "discard": "Verwerfen und fortfahren" } } diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 0135f27fe..71ad20bda 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -4908,5 +4908,12 @@ "failed": "The password could not be saved. Check the password requirements or request a new link.", "originChanged": "The API changed. Open the original link again before setting your password.", "back": "Back to OpenMapX" + }, + "adminDrafts": { + "title": "Discard unsaved administration changes?", + "ordinary": "Ordinary drafts will be discarded. Stay to review your work first.", + "reauthentication": "The reviewed reauthentication continuation will be retained.", + "stay": "Stay", + "discard": "Discard and continue" } } From 6d3587526edafedb23fd8daba9acc21d9d867d2f Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 17:11:16 +0100 Subject: [PATCH 05/12] test(admin): cover exact job links in the href inventory --- apps/web/src/lib/jsxHrefSanitization.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/lib/jsxHrefSanitization.test.ts b/apps/web/src/lib/jsxHrefSanitization.test.ts index 844a5147e..ad868a4ab 100644 --- a/apps/web/src/lib/jsxHrefSanitization.test.ts +++ b/apps/web/src/lib/jsxHrefSanitization.test.ts @@ -50,6 +50,10 @@ const ALLOWED: Record = { ], "components/admin/services/ServiceCatalog.tsx": [ 'adminJobHref({ id: lastAcceptedJobId, source: "application" })', + `lastAcceptedJobId ? adminJobHref({ id: lastAcceptedJobId, source: "application" }) : "/admin/activity"`, + ], + "components/admin/services/DataWorkflowsPage.tsx": [ + 'adminJobHref({ id: lastJobId, source: "application" })', ], "components/admin/services/TransitSourcesSection.tsx": [ 'adminJobHref({ id: syncJob?.jobId ?? lastJobId ?? "", source: "data-manager" })', From 5d730cdd9e9fc432ece7a0bc289f832d73fcff8b Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 18:10:07 +0100 Subject: [PATCH 06/12] test(admin): await transit lease cleanup after terminal status --- .../src/jobs/execution/legacy-admission-postgres.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/data-manager/src/jobs/execution/legacy-admission-postgres.test.ts b/services/data-manager/src/jobs/execution/legacy-admission-postgres.test.ts index e22a20a4d..45a3fd38c 100644 --- a/services/data-manager/src/jobs/execution/legacy-admission-postgres.test.ts +++ b/services/data-manager/src/jobs/execution/legacy-admission-postgres.test.ts @@ -823,9 +823,12 @@ it.skipIf(!enabled)( expect(f.workerError()).toMatchObject({ code: "preflight_stale" }); expect(f.dispatches()).toBe(1); expect((await f.runtime.inspect(id))?.publication).toBe("not_started"); - expect( - (await import("node:fs")).existsSync(join(root, "data/.admin-target-control/locks/motis")), - ).toBe(false); + // Terminal status is published before asynchronous lease settlement finishes. + await expect + .poll(() => fs.existsSync(join(root, "data/.admin-target-control/locks/motis")), { + timeout: 10000, + }) + .toBe(false); } finally { f.release(); await f.close(); From 8391676752518a7b48366a98ec6776d2cc53352f Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 18:22:55 +0100 Subject: [PATCH 07/12] test(admin): await relay capacity after asynchronous cleanup --- .../transitous/operator-feed-relay.test.ts | 65 +++++++++++++------ 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/services/data-manager/src/jobs/transitous/operator-feed-relay.test.ts b/services/data-manager/src/jobs/transitous/operator-feed-relay.test.ts index 9dcfc0072..94119c0fd 100644 --- a/services/data-manager/src/jobs/transitous/operator-feed-relay.test.ts +++ b/services/data-manager/src/jobs/transitous/operator-feed-relay.test.ts @@ -1002,17 +1002,28 @@ describe("OperatorFeedRelayStore", () => { await vi.waitFor(() => { expect(destination && existsSync(destination)).toBe(false); }); - const replacement = relay.register({ - runId: "run-after-unread", - sourceId: "operator:de:after-unread", - remoteUrl: new URL("https://operator.example/after-unread.zip"), - }); + const replacement = await vi.waitFor( + () => + relay.register({ + runId: "run-after-unread", + sourceId: "operator:de:after-unread", + remoteUrl: new URL("https://operator.example/after-unread.zip"), + }), + { timeout: 5_000 }, + ); expect(replacement.handle).toMatch(/^[a-f0-9]{64}$/); await relay.endRun("run-after-unread"); }); it("keeps the total deadline active after a small payload is fully buffered", async () => { vi.useFakeTimers(); + const actualRm = relayFileLifecycle.actualRm; + if (!actualRm) throw new Error("relay filesystem fixture is unavailable"); + const allowRemovalToSettle = deferred(); + rmMock.mockImplementation(async (...args: Parameters) => { + await actualRm(...args); + await allowRemovalToSettle.promise; + }); let destination: string | undefined; const relay = new OperatorFeedRelayStore({ maxEntries: 1, @@ -1045,11 +1056,17 @@ describe("OperatorFeedRelayStore", () => { await vi.waitFor(() => { expect(destination && existsSync(destination)).toBe(false); }); - const replacement = relay.register({ - runId: "run-after-buffered-total", - sourceId: "operator:de:after-buffered-total", - remoteUrl: new URL("https://operator.example/after-buffered-total.zip"), - }); + allowRemovalToSettle.resolve(); + // Capacity is released after every cleanup operation settles, including rm(). + const replacement = await vi.waitFor( + () => + relay.register({ + runId: "run-after-buffered-total", + sourceId: "operator:de:after-buffered-total", + remoteUrl: new URL("https://operator.example/after-buffered-total.zip"), + }), + { timeout: 5_000 }, + ); expect(replacement.handle).toMatch(/^[a-f0-9]{64}$/); await relay.endRun("run-after-buffered-total"); }); @@ -1189,11 +1206,15 @@ describe("OperatorFeedRelayStore", () => { await vi.waitFor(() => { expect(destination && existsSync(destination)).toBe(false); }); - const replacement = relay.register({ - runId: "run-after-partial-reader", - sourceId: "operator:de:after-partial-reader", - remoteUrl: new URL("https://operator.example/after-partial-reader.zip"), - }); + const replacement = await vi.waitFor( + () => + relay.register({ + runId: "run-after-partial-reader", + sourceId: "operator:de:after-partial-reader", + remoteUrl: new URL("https://operator.example/after-partial-reader.zip"), + }), + { timeout: 5_000 }, + ); expect(replacement.handle).toMatch(/^[a-f0-9]{64}$/); await relay.endRun("run-after-partial-reader"); }); @@ -1227,11 +1248,15 @@ describe("OperatorFeedRelayStore", () => { await vi.waitFor(() => { expect(destination && existsSync(destination)).toBe(false); }); - const replacement = relay.register({ - runId: "run-after-disconnect", - sourceId: "operator:de:after-disconnect", - remoteUrl: new URL("https://operator.example/after-disconnect.zip"), - }); + const replacement = await vi.waitFor( + () => + relay.register({ + runId: "run-after-disconnect", + sourceId: "operator:de:after-disconnect", + remoteUrl: new URL("https://operator.example/after-disconnect.zip"), + }), + { timeout: 5_000 }, + ); expect(replacement.handle).toMatch(/^[a-f0-9]{64}$/); await relay.endRun("run-after-disconnect"); }); From 2c749a2de78041c18d21b13fb55fc2f4704ee421 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 18:59:01 +0100 Subject: [PATCH 08/12] test(admin): make capability and crash fixtures deterministic --- .../src/artifact-transfer/routes.test.ts | 15 ++++++++--- .../fixtures/catalog-data-manager-process.mjs | 7 +++++ .../catalog-workflow-postgres.test.ts | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/ops-agent/src/artifact-transfer/routes.test.ts b/apps/ops-agent/src/artifact-transfer/routes.test.ts index dbba4077b..2e2118f65 100644 --- a/apps/ops-agent/src/artifact-transfer/routes.test.ts +++ b/apps/ops-agent/src/artifact-transfer/routes.test.ts @@ -4,13 +4,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { issueArtifactStreamCapability } from "@openmapx/core/ops"; import Fastify from "fastify"; -import { afterEach, expect, it } from "vitest"; +import { afterEach, expect, it, vi } from "vitest"; import { registerArtifactTransferRoutes } from "./routes"; import { ArtifactTransferStore } from "./store"; const cleanups: Array<() => Promise> = []; afterEach(async () => { for (const close of cleanups.splice(0).reverse()) await close(); + vi.restoreAllMocks(); }); it("authenticates before streaming, preserves binary bytes, refuses purpose and attempt replay", async () => { const root = await mkdtemp(join(tmpdir(), "artifact-http-")); @@ -37,6 +38,12 @@ it("authenticates before streaming, preserves binary bytes, refuses purpose and declaredBytes: 4, operationKey: `opk1_${"a".repeat(20)}`, }); + const now = Date.now(); + vi.spyOn(Date, "now") + .mockImplementationOnce(() => now) + .mockImplementationOnce(() => now + 1) + .mockImplementationOnce(() => now + 2); + const issuedAt = Date.now(); const input = { version: 1 as const, stageId: stage.stageId, @@ -45,9 +52,9 @@ it("authenticates before streaming, preserves binary bytes, refuses purpose and direction: "upload" as const, byteCap: 4, attemptId: randomUUID(), - issuedAt: Date.now(), - expiresAt: Date.now() + 60000, - deadlineAt: Date.now() + 10000, + issuedAt, + expiresAt: issuedAt + 60000, + deadlineAt: issuedAt + 10000, }; const cap = issueArtifactStreamCapability(token, input); const path = `/v1/artifact-transfers/${stage.stageId}/content`; diff --git a/services/data-manager/__tests__/fixtures/catalog-data-manager-process.mjs b/services/data-manager/__tests__/fixtures/catalog-data-manager-process.mjs index 8d9fb6cde..18c443539 100644 --- a/services/data-manager/__tests__/fixtures/catalog-data-manager-process.mjs +++ b/services/data-manager/__tests__/fixtures/catalog-data-manager-process.mjs @@ -1,4 +1,5 @@ /** Native owning DM assembly. Only exact pinned public CSV acquisition is fixture IO. */ +import { writeSync } from "node:fs"; import { createInterface } from "node:readline"; const lines = createInterface({ input: process.stdin }); @@ -87,4 +88,10 @@ registerApi(app, { }); await runtime.reconcile(); const url = await app.listen({ host: "127.0.0.1", port: input.listenPort ?? 0 }); +// The pin-pointer crash fixture stops between synchronous lifecycle writes. +// Signal callbacks run after the current JavaScript stack has unwound. +process.once("SIGUSR2", () => { + writeSync(1, JSON.stringify({ event: "crash-ready" }) + "\n"); + process.kill(process.pid, "SIGSTOP"); +}); process.stdout.write(JSON.stringify({ event: "ready", url }) + "\n"); diff --git a/services/data-manager/__tests__/transitous/catalog-workflow-postgres.test.ts b/services/data-manager/__tests__/transitous/catalog-workflow-postgres.test.ts index ac0a3cf6d..64b973275 100644 --- a/services/data-manager/__tests__/transitous/catalog-workflow-postgres.test.ts +++ b/services/data-manager/__tests__/transitous/catalog-workflow-postgres.test.ts @@ -678,12 +678,17 @@ it }) + "\n", ); const lines = createInterface({ input: child.stdout! }); + let markCrashReady!: () => void; + const crashReady = new Promise((resolve) => { + markCrashReady = resolve; + }); const ready = new Promise((resolve, reject) => { child.once("exit", (code) => reject(Error(`Native DM exited ${code}: ${stderr}`))); void (async () => { for await (const line of lines) { const event = JSON.parse(line); if (event.event === "ready") resolve(event.url); + else if (event.event === "crash-ready") markCrashReady(); else stderr += line + "\n"; } })().catch(reject); @@ -697,6 +702,7 @@ it return { child, url, + crashReady, get stderr() { return stderr; }, @@ -861,6 +867,26 @@ it expect(existsSync(join(data, ".admin-target-control/locks/motis"))).toBe(true); } if (nativeDm) { + // Freeze between lifecycle writes before injecting the pin-pointer crash. + nativeDm.child.kill("SIGUSR2"); + await Promise.race([ + nativeDm.crashReady, + new Promise((_, reject) => + setTimeout( + () => reject(Error(`Native DM crash boundary timeout: ${nativeDm?.stderr ?? ""}`)), + 10000, + ).unref(), + ), + ]); + expect( + existsSync( + join( + data, + ".admin-target-control/executions/data-manager", + `${activation.operationKey}.writer`, + ), + ), + ).toBe(false); nativeDm.child.kill("SIGKILL"); await new Promise((resolve) => nativeDm!.child.once("exit", resolve)); } From f992010e939d3061cb0add6a393eb3184ee3af5c Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 20:09:19 +0100 Subject: [PATCH 09/12] test(admin): finish held observation job before fixture shutdown --- .../src/jobs/execution/legacy-admission-postgres.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/data-manager/src/jobs/execution/legacy-admission-postgres.test.ts b/services/data-manager/src/jobs/execution/legacy-admission-postgres.test.ts index 45a3fd38c..290339069 100644 --- a/services/data-manager/src/jobs/execution/legacy-admission-postgres.test.ts +++ b/services/data-manager/src/jobs/execution/legacy-admission-postgres.test.ts @@ -696,6 +696,12 @@ it.skipIf(!enabled)( }); expect(actor.statusCode).toBe(409); expect(f.dispatches()).toBeLessThanOrEqual(1); + // Finish the held synthetic job before close() requests cancellation. + f.release(); + await expect + .poll(async () => (await f.store.inspect(admitted.id))?.status.status, { timeout: 15000 }) + .toBe("ok"); + expect(f.dispatches()).toBe(1); } finally { await f.close(); } From 825e94dddf4103a6d00c865d6daf0b4e20648df3 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 20:41:48 +0100 Subject: [PATCH 10/12] fix(ops): bound waits for pre-adoption observation contention --- .../src/configuration-handoff-control.test.ts | 185 ++++++++++++ .../src/configuration-handoff-control.ts | 101 ++++--- .../docs/developer/build-artifacts-control.md | 6 + .../target-lease-adoption.test.ts | 264 ++++++++++++++++++ .../core/src/build-artifacts/target-lease.ts | 23 +- 5 files changed, 541 insertions(+), 38 deletions(-) create mode 100644 apps/ops-agent/src/configuration-handoff-control.test.ts create mode 100644 packages/core/src/build-artifacts/target-lease-adoption.test.ts diff --git a/apps/ops-agent/src/configuration-handoff-control.test.ts b/apps/ops-agent/src/configuration-handoff-control.test.ts new file mode 100644 index 000000000..b5940d52a --- /dev/null +++ b/apps/ops-agent/src/configuration-handoff-control.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const mock = vi.hoisted(() => ({ + openFixedChildInstallation: vi.fn(), + readConfigurationHandoffSpawnIntent: vi.fn(), + verifyFixedCliControlPeer: vi.fn(), + verifiedParentForArtifactDelegation: vi.fn(), + adoptArtifactTargetLease: vi.fn(), + retainAdmittedControlExecution: vi.fn(), + bindBorrowedControlExecution: vi.fn(), + assertArtifactTargetLeaseExecution: vi.fn(), + beginFixedCliExecution: vi.fn(), + closeArtifactTargetLeaseHandle: vi.fn(), + closeControlInstallation: vi.fn(), + executionAuthorityState: vi.fn(), +})); +vi.mock("@openmapx/core/build-artifacts", async (original) => ({ + ...(await original()), + ...mock, +})); +vi.mock("node:timers/promises", async (original) => ({ + ...(await original()), + setTimeout: (milliseconds: number) => + new Promise((resolve) => setTimeout(resolve, milliseconds)), +})); + +import { + ArtifactTargetAdoptionObservationBusyError, + ArtifactTargetJournalBusyError, +} from "@openmapx/core/build-artifacts"; +import { createControlRootOperationKey } from "@openmapx/core/ops"; +import { + closeConfigurationHandoffChild, + openConfigurationHandoffChild, +} from "./configuration-handoff-control"; + +// These mocks isolate retry/cleanup control flow. The target-lease filesystem +// tests and native platform suite verify the actual authority and adoption. +function fixture() { + const deadlineAt = Date.now() + 60000, + execution = { + executor: "host-cli" as const, + principal: "host-cli" as const, + executionId: "11111111-1111-4111-8111-111111111111", + operationKey: createControlRootOperationKey({ + notAfter: deadlineAt, + nonce: Buffer.alloc(16), + }), + operationFingerprint: "a".repeat(64), + fingerprint: "b".repeat(64), + deadlineAt, + }, + input = { + rootDir: "/synthetic/owned-deployment", + handoffId: "22222222-2222-4222-8222-222222222222", + assertion: "original-fixed-child-assertion", + execution, + }, + installation = { installationId: "fixture-installation", publicKey: "original-child-key" }, + preparer = { operationKey: "original-preparer", fingerprint: "c".repeat(64) }, + peer = Object.freeze({ fixture: "original-peer" }), + lease = Object.freeze({ fixture: "original-lease" }); + mock.openFixedChildInstallation.mockReturnValue(installation); + mock.readConfigurationHandoffSpawnIntent.mockReturnValue({ + installationId: installation.installationId, + operation: { handoffId: input.handoffId, serviceIds: ["motis"] }, + assertion: input.assertion, + helperPublicKey: installation.publicKey, + helper: execution, + preparer, + admittedAt: Date.now(), + requestedDeadlineAt: deadlineAt, + }); + mock.verifyFixedCliControlPeer.mockReturnValue(peer); + mock.verifiedParentForArtifactDelegation.mockReturnValue(preparer); + mock.adoptArtifactTargetLease.mockResolvedValue(lease); + mock.retainAdmittedControlExecution.mockReturnValue(Object.freeze({ fixture: "authority" })); + return { input, installation, peer, lease }; +} +beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); +}); +afterEach(() => vi.useRealTimers()); + +it.each([ + () => new ArtifactTargetAdoptionObservationBusyError(Error("held observation guard")), + () => new ArtifactTargetJournalBusyError(Error("held journal guard")), +])("retries only a nominal pre-adoption refusal with the original identity", async (busy) => { + const f = fixture(); + mock.adoptArtifactTargetLease.mockRejectedValueOnce(busy()); + const pending = openConfigurationHandoffChild(f.input); + void pending.catch(() => {}); + await vi.advanceTimersByTimeAsync(50); + const child = await pending; + try { + expect(mock.adoptArtifactTargetLease).toHaveBeenCalledTimes(2); + const [first, second] = mock.adoptArtifactTargetLease.mock.calls; + expect(second).toEqual(first); + expect(second?.[0].peer).toBe(first?.[0].peer); + expect(first?.[0]).toMatchObject({ + assertion: f.input.assertion, + execution: f.input.execution, + peer: f.peer, + serviceIds: ["motis"], + }); + expect(mock.retainAdmittedControlExecution).toHaveBeenCalledTimes(1); + expect(mock.beginFixedCliExecution).toHaveBeenCalledTimes(1); + } finally { + closeConfigurationHandoffChild(child); + } +}); + +it("stops at the five-second contention budget without adopting or retaining authority", async () => { + const f = fixture(), + error = new ArtifactTargetAdoptionObservationBusyError(Error("persistent guard")); + mock.adoptArtifactTargetLease.mockRejectedValue(error); + const failed = expect(openConfigurationHandoffChild(f.input)).rejects.toBe(error); + await vi.advanceTimersByTimeAsync(5050); + await failed; + expect(mock.adoptArtifactTargetLease).toHaveBeenCalledTimes(101); + expect(mock.retainAdmittedControlExecution).not.toHaveBeenCalled(); + expect(mock.beginFixedCliExecution).not.toHaveBeenCalled(); + expect(mock.closeControlInstallation).toHaveBeenCalledExactlyOnceWith(f.installation); +}); + +it("stops at the original deadline before the five-second wait budget", async () => { + const f = fixture(), + error = new ArtifactTargetAdoptionObservationBusyError(Error("held guard")); + f.input.execution.deadlineAt = Date.now() + 20; + mock.adoptArtifactTargetLease.mockRejectedValue(error); + const failed = expect(openConfigurationHandoffChild(f.input)).rejects.toBe(error); + await vi.advanceTimersByTimeAsync(100); + await failed; + expect(mock.adoptArtifactTargetLease).toHaveBeenCalledTimes(2); + for (const [input] of mock.adoptArtifactTargetLease.mock.calls) + expect(input.execution.deadlineAt).toBe(f.input.execution.deadlineAt); + expect(mock.retainAdmittedControlExecution).not.toHaveBeenCalled(); + expect(mock.closeControlInstallation).toHaveBeenCalledExactlyOnceWith(f.installation); +}); + +it("propagates raw family contention after exactly one adoption attempt", async () => { + const f = fixture(), + error = Object.assign(Error("family writer busy after adoption started"), { + code: "EEXIST", + syscall: "mkdir", + path: "/synthetic/.family-writer", + }); + mock.adoptArtifactTargetLease.mockRejectedValueOnce(error); + await expect(openConfigurationHandoffChild(f.input)).rejects.toBe(error); + expect(mock.adoptArtifactTargetLease).toHaveBeenCalledTimes(1); + expect(mock.retainAdmittedControlExecution).not.toHaveBeenCalled(); + expect(mock.closeControlInstallation).toHaveBeenCalledExactlyOnceWith(f.installation); +}); + +it("does not retry any failure after the one-use execution marker", async () => { + const f = fixture(), + error = Error("one-use marker publication refused"); + mock.beginFixedCliExecution.mockImplementation(() => { + throw error; + }); + await expect(openConfigurationHandoffChild(f.input)).rejects.toBe(error); + expect(mock.adoptArtifactTargetLease).toHaveBeenCalledTimes(1); + expect(mock.beginFixedCliExecution).toHaveBeenCalledTimes(1); + expect(mock.closeArtifactTargetLeaseHandle).toHaveBeenCalledExactlyOnceWith(f.lease); + expect(mock.closeControlInstallation).toHaveBeenCalledExactlyOnceWith(f.installation); +}); + +it("keeps the existing raw family-writer wait limited to source reads", async () => { + const f = fixture(), + error = Object.assign(Error("source writer busy"), { + code: "EEXIST", + syscall: "mkdir", + path: "/synthetic/.family-writer", + }); + mock.readConfigurationHandoffSpawnIntent.mockImplementationOnce(() => { + throw error; + }); + const pending = openConfigurationHandoffChild(f.input); + void pending.catch(() => {}); + await vi.advanceTimersByTimeAsync(50); + closeConfigurationHandoffChild(await pending); + expect(mock.readConfigurationHandoffSpawnIntent).toHaveBeenCalledTimes(2); + expect(mock.adoptArtifactTargetLease).toHaveBeenCalledTimes(1); +}); diff --git a/apps/ops-agent/src/configuration-handoff-control.ts b/apps/ops-agent/src/configuration-handoff-control.ts index a186a44e0..7ec9fb351 100644 --- a/apps/ops-agent/src/configuration-handoff-control.ts +++ b/apps/ops-agent/src/configuration-handoff-control.ts @@ -4,6 +4,7 @@ import { existsSync } from "node:fs"; import { basename, join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { + ArtifactTargetAdoptionObservationBusyError, ArtifactTargetJournalBusyError, adoptArtifactTargetLease, assertArtifactTargetLeaseExecution, @@ -50,23 +51,30 @@ const children = new WeakMap< intent: ConfigurationHandoffIntentRecord; } >(); -/** Parent terminal/copy publication can briefly own the authenticated source - * index. Wait only around source reads, before adoption/forward writes. The - * original deadline remains authoritative; no lock is removed or stolen. */ -async function waitForHandoffSource(execution: ExecutionRef, read: () => T): Promise { +function sourceReadBusy(error: unknown): boolean { + const io = error as NodeJS.ErrnoException | undefined; + return ( + error instanceof ArtifactTargetJournalBusyError || + (io?.code === "EEXIST" && + io.syscall === "mkdir" && + typeof io.path === "string" && + basename(io.path) === ".family-writer") + ); +} +/** Retry only the caller's certified pre-write contention. The original + * deadline remains authoritative; no lock is removed or stolen. */ +async function waitForHandoffContention( + execution: ExecutionRef, + attempt: () => T | Promise, + canRetry: (error: unknown) => boolean, +): Promise { const started = performance.now(); for (;;) { try { - return read(); + return await attempt(); } catch (error) { - const io = error as NodeJS.ErrnoException; - const familyBusy = - io.code === "EEXIST" && - io.syscall === "mkdir" && - !!io.path && - basename(io.path) === ".family-writer"; if ( - (!familyBusy && !(error instanceof ArtifactTargetJournalBusyError)) || + !canRetry(error) || Date.now() >= execution.deadlineAt || performance.now() - started >= 5000 ) @@ -99,8 +107,10 @@ export async function openConfigurationHandoffChild(input: { let lease: BorrowedArtifactTargetLease | undefined; try { const intent = immutable( - await waitForHandoffSource(execution, () => - readConfigurationHandoffSpawnIntent(installation, execution, input.handoffId), + await waitForHandoffContention( + execution, + () => readConfigurationHandoffSpawnIntent(installation, execution, input.handoffId), + sourceReadBusy, ), ); if ( @@ -116,27 +126,39 @@ export async function openConfigurationHandoffChild(input: { handoffId: input.handoffId, intentDigest: controlDigest(intent.operation), }, - { peer, parent } = await waitForHandoffSource(execution, () => ({ - peer: verifyFixedCliControlPeer(installation, { - assertion: input.assertion, - execution, - kind: body.kind, - recipient: "configuration-helper", + { peer, parent } = await waitForHandoffContention( + execution, + () => ({ + peer: verifyFixedCliControlPeer(installation, { + assertion: input.assertion, + execution, + kind: body.kind, + recipient: "configuration-helper", + }), + parent: verifiedParentForArtifactDelegation(installation, input.assertion, execution), }), - parent: verifiedParentForArtifactDelegation(installation, input.assertion, execution), - })); + sourceReadBusy, + ); if ( canonicalJson(parent.operationKey) !== canonicalJson(intent.preparer.operationKey) || parent.fingerprint !== intent.preparer.fingerprint ) throw Error("Original helper preparer required"); - lease = await adoptArtifactTargetLease({ - dataRoot: join(infraDir, "data"), - assertion: input.assertion, - peer, + lease = await waitForHandoffContention( execution, - serviceIds: intent.operation.serviceIds, - }); + () => + adoptArtifactTargetLease({ + dataRoot: join(infraDir, "data"), + assertion: input.assertion, + peer, + execution, + serviceIds: intent.operation.serviceIds, + }), + // A raw family-writer error may follow partial adoption and is never retried. + (error) => + error instanceof ArtifactTargetAdoptionObservationBusyError || + error instanceof ArtifactTargetJournalBusyError, + ); const authority = retainAdmittedControlExecution(installation, { execution, kind: body.kind, @@ -173,8 +195,10 @@ export async function reconcileCompletedConfigurationHandoff(input: { container: existsSync("/run/secrets/openmapx-control-verifiers"), }); try { - const intent = await waitForHandoffSource(execution, () => - readConfigurationHandoffSpawnIntent(installation, execution, input.handoffId), + const intent = await waitForHandoffContention( + execution, + () => readConfigurationHandoffSpawnIntent(installation, execution, input.handoffId), + sourceReadBusy, ), state = installationState(installation), file = configurationHandoffRecordName(input.handoffId, "completion"); @@ -192,13 +216,16 @@ export async function reconcileCompletedConfigurationHandoff(input: { raw, configurationHandoffWorkerRecordSchema, ); - await waitForHandoffSource(execution, () => - verifyFamilyOwnedRecord( - state, - intent.origin, - { scope: "private", namespace: "observations", container: "", name: file }, - canonicalJson(raw), - ), + await waitForHandoffContention( + execution, + () => + verifyFamilyOwnedRecord( + state, + intent.origin, + { scope: "private", namespace: "observations", container: "", name: file }, + canonicalJson(raw), + ), + sourceReadBusy, ); if (worker.phase !== "applied" || canonicalJson(worker.execution) !== canonicalJson(execution)) throw Error("Unfinished original helper remains fenced"); diff --git a/docs/docs/developer/build-artifacts-control.md b/docs/docs/developer/build-artifacts-control.md index ba48e70de..0fa31d649 100644 --- a/docs/docs/developer/build-artifacts-control.md +++ b/docs/docs/developer/build-artifacts-control.md @@ -260,6 +260,12 @@ may adopt early but `verifyConfigurationHelperReady` refuses drain until that signed terminal preparation exists. Maintenance continuations remain frozen, one-use subsets; helper descendants have no further edge. +The detached configuration helper may wait up to five seconds per source read or +initial adoption attempt sequence, within its original deadline, for family or +lease journal writer contention. Adoption retries cover only journal-entry and +initial-observation contention, before any adoption record is written; later +failures remain recovery-required. Waiting never removes locks or renews authority. + ## Deferred product consumers Task02.3 owns durable data-manager HTTP/row admission and containment. The existing diff --git a/packages/core/src/build-artifacts/target-lease-adoption.test.ts b/packages/core/src/build-artifacts/target-lease-adoption.test.ts new file mode 100644 index 000000000..bd097fe26 --- /dev/null +++ b/packages/core/src/build-artifacts/target-lease-adoption.test.ts @@ -0,0 +1,264 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmdirSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { createControlRootOperationKey } from "../ops/control-root-key"; +import { type ExecutionRef, executionFingerprint } from "../ops/execution-identity"; +import { opsOperationFingerprint } from "../ops/fingerprint"; +import * as c from "./control-authority"; +import { AnchoredDirectory } from "./filesystem"; +import { reserveControlFamily } from "./retention-family"; +import * as a from "./target-lease"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const cleanup of cleanups.splice(0).reverse()) cleanup(); +}); + +async function adoptionFixture() { + const root = mkdtempSync(join(tmpdir(), "omx-adoption-observation-")); + cleanups.push(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, "data"), { mode: 0o700 }); + await c.provisionControlInstallation(root); + const dm = c.openControlInstallation({ + infraDir: root, + role: "data-manager", + serviceIds: ["motis"], + }), + agent = c.openControlInstallation({ + infraDir: root, + role: "ops-agent", + serviceIds: ["motis"], + }); + cleanups.push( + () => c.closeControlInstallation(dm), + () => c.closeControlInstallation(agent), + ); + const operation = { kind: "transit-sync", countries: ["de"] }, + admittedAt = Date.now(), + deadlineAt = admittedAt + 60000, + operationFingerprint = c.controlDigest(operation), + execution: ExecutionRef = { + executor: "data-manager", + principal: "data-manager", + executionId: randomUUID(), + operationKey: createControlRootOperationKey({ notAfter: deadlineAt, nonce: randomBytes(16) }), + operationFingerprint, + deadlineAt, + fingerprint: executionFingerprint({ kind: operation.kind, operationFingerprint, deadlineAt }), + }, + body = { kind: "motis.primary.stop" as const }, + childFingerprint = opsOperationFingerprint(body), + child: ExecutionRef = { + executor: "ops-agent", + principal: "data-manager", + executionId: `job1_${randomUUID().replaceAll("-", "")}`, + operationKey: c.controlChildOperationKey(execution.operationKey, "stop-primary", 0), + operationFingerprint: childFingerprint, + deadlineAt, + fingerprint: executionFingerprint({ + kind: body.kind, + operationFingerprint: childFingerprint, + deadlineAt, + parent: c.retainedControlParent(execution), + }), + }, + plan = { + phase: "stop-primary" as const, + ordinal: 0, + kind: body.kind, + execution: child, + serviceIds: ["motis"], + recipient: "ops-agent" as const, + }, + owner = c.retainAdmittedControlExecution(dm, { + execution, + operation, + kind: operation.kind, + admittedAt, + requestedDeadlineAt: deadlineAt, + serviceIds: ["motis"], + plan: [plan], + }), + lease = await a.acquireArtifactTargetLease({ + dataRoot: join(root, "data"), + serviceIds: ["motis"], + owner, + }); + cleanups.push(() => a.closeArtifactTargetLeaseHandle(lease)); + const assertion = await a.delegateArtifactTargetLease(lease, { + ...plan, + childPublicKey: agent.publicKey, + }), + inspected = a.inspectArtifactDelegation({ + dataRoot: join(root, "data"), + assertion, + installation: agent, + }); + if (!inspected.origin) throw Error("Expected certified fixture origin"); + reserveControlFamily(c.installationState(agent), inspected.origin); + a.inspectArtifactDelegation({ dataRoot: join(root, "data"), assertion, installation: agent }); + const peer = c.verifyControlPeer(agent, { + rawHeaders: ["x-openmapx-control-delegation", assertion], + transportPrincipal: "data-manager", + execution: child, + kind: body.kind, + }), + privateRoot = join(root, "control-private/ops-agent"), + families = join(privateRoot, "retention-families"), + slots = readdirSync(families).filter((name) => /^slot-\d+$/.test(name)); + expect(slots).toHaveLength(1); + const family = join(families, slots[0]!), + ledger = join(root, "data/.admin-target-control/leases", lease.leaseId); + return { + input: { + dataRoot: join(root, "data"), + assertion, + peer, + execution: child, + serviceIds: ["motis"], + }, + observation: join(privateRoot, "observations", `${lease.leaseId}.json`), + adoption: join(privateRoot, "adoptions", `${inspected.delegationId}.json`), + child: join(ledger, "children", `${inspected.delegationId}.json`), + journalGuard: join(ledger, ".journal-writer"), + family, + familyGuard: join(family, ".family-writer"), + clockGuard: join(privateRoot, ".retention-clock-writer"), + }; +} + +function familySnapshot(family: string) { + return Object.fromEntries( + readdirSync(family) + .filter((name) => name === "progress.json" || /^page-\d+\.json$/.test(name)) + .sort() + .map((name) => [name, readFileSync(join(family, name))]), + ); +} + +it("distinguishes initial observation contention without changing any adoption or family record", async () => { + const f = await adoptionFixture(), + observation = readFileSync(f.observation), + before = familySnapshot(f.family); + mkdirSync(f.familyGuard, { mode: 0o700 }); + const held = statSync(f.familyGuard); + try { + const error = await a.adoptArtifactTargetLease(f.input).catch((error: Error) => error); + expect(error).toBeInstanceOf(a.ArtifactTargetAdoptionObservationBusyError); + expect(error).toMatchObject({ + message: "Lease adoption observation is busy or requires supervised recovery", + cause: { code: "EEXIST", syscall: "mkdir", path: expect.stringContaining(".family-writer") }, + }); + expect(readFileSync(f.observation)).toEqual(observation); + expect(familySnapshot(f.family)).toEqual(before); + expect(existsSync(f.journalGuard)).toBe(false); + expect(statSync(f.familyGuard)).toMatchObject({ dev: held.dev, ino: held.ino }); + expect(existsSync(f.adoption)).toBe(false); + expect(existsSync(f.child)).toBe(false); + } finally { + rmdirSync(f.familyGuard); + } + const adopted = await a.adoptArtifactTargetLease(f.input); + cleanups.push(() => a.closeArtifactTargetLeaseHandle(adopted)); + expect(JSON.parse(readFileSync(f.adoption, "utf8"))).toMatchObject({ + state: "ready", + execution: f.input.execution, + }); + expect(readdirSync(dirname(f.adoption)).filter((name) => name.endsWith(".json"))).toEqual([ + basename(f.adoption), + ]); + expect(readdirSync(dirname(f.child)).filter((name) => name.endsWith(".json"))).toEqual([ + basename(f.child), + ]); +}); + +it("keeps late family contention raw and retains the already published pending adoption", async () => { + const f = await adoptionFixture(), + family = statSync(f.family, { bigint: true }), + write = AnchoredDirectory.prototype.write, + remove = AnchoredDirectory.prototype.removeDirectory; + let pending: Buffer | undefined, + armed = false, + held: ReturnType | undefined; + vi.spyOn(AnchoredDirectory.prototype, "write").mockImplementation(function ( + this: AnchoredDirectory, + ...args + ) { + const result = write.apply(this, args); + if ( + args[0] === basename(f.adoption) && + this.path.endsWith("/adoptions") && + JSON.parse(args[1].toString()).state === "pending-adoption" + ) { + pending = readFileSync(f.adoption); + armed = true; + } + return result; + }); + vi.spyOn(AnchoredDirectory.prototype, "removeDirectory").mockImplementation(function ( + this: AnchoredDirectory, + ...args + ) { + const result = remove.apply(this, args); + if ( + armed && + args[0] === ".family-writer" && + this.stat.dev === family.dev && + this.stat.ino === family.ino + ) { + armed = false; + mkdirSync(f.familyGuard, { mode: 0o700 }); + held = statSync(f.familyGuard); + } + return result; + }); + try { + const error = await a.adoptArtifactTargetLease(f.input).catch((error: Error) => error); + expect(error).toMatchObject({ code: "EEXIST", syscall: "mkdir" }); + expect(error).not.toBeInstanceOf(a.ArtifactTargetAdoptionObservationBusyError); + expect(error).not.toBeInstanceOf(a.ArtifactTargetJournalBusyError); + expect(pending).toBeDefined(); + expect(readFileSync(f.adoption)).toEqual(pending); + expect(JSON.parse(readFileSync(f.adoption, "utf8")).state).toBe("pending-adoption"); + expect(existsSync(f.child)).toBe(false); + expect(existsSync(f.journalGuard)).toBe(false); + expect(held).toBeDefined(); + expect(statSync(f.familyGuard)).toMatchObject({ dev: held?.dev, ino: held?.ino }); + } finally { + if (held) rmdirSync(f.familyGuard); + } +}); + +it("keeps retention-clock contention after the initial observation raw", async () => { + const f = await adoptionFixture(); + mkdirSync(f.clockGuard, { mode: 0o700 }); + const held = statSync(f.clockGuard); + try { + const error = await a.adoptArtifactTargetLease(f.input).catch((error: Error) => error); + expect(error).toMatchObject({ + code: "EEXIST", + syscall: "mkdir", + path: expect.stringContaining(".retention-clock-writer"), + }); + expect(error).not.toBeInstanceOf(a.ArtifactTargetAdoptionObservationBusyError); + expect(error).not.toBeInstanceOf(a.ArtifactTargetJournalBusyError); + expect(existsSync(f.adoption)).toBe(false); + expect(existsSync(f.child)).toBe(false); + expect(existsSync(f.journalGuard)).toBe(false); + expect(statSync(f.clockGuard)).toMatchObject({ dev: held.dev, ino: held.ino }); + } finally { + rmdirSync(f.clockGuard); + } +}); diff --git a/packages/core/src/build-artifacts/target-lease.ts b/packages/core/src/build-artifacts/target-lease.ts index b676e456a..c33121a5b 100644 --- a/packages/core/src/build-artifacts/target-lease.ts +++ b/packages/core/src/build-artifacts/target-lease.ts @@ -1,5 +1,6 @@ import { createPrivateKey, type KeyObject, randomUUID } from "node:crypto"; import { fsyncSync, mkdirSync } from "node:fs"; +import { basename } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { z } from "zod"; import { canonicalJson } from "../ops/canonical-json"; @@ -726,6 +727,13 @@ export class ArtifactTargetJournalBusyError extends Error { super("Lease journal writer is busy or requires supervised recovery", { cause }); } } +/** Only initial adoption observation failed to acquire its family writer, + * before any adoption record was written. Never permits reclaiming a guard. */ +export class ArtifactTargetAdoptionObservationBusyError extends Error { + constructor(cause: unknown) { + super("Lease adoption observation is busy or requires supervised recovery", { cause }); + } +} function guarded(s: State, run: () => T, checkTargets = true): T { if (s.journalGuardHeld) throw Error("Nested lease journal writer"); if (checkTargets) verifyAnchors(s); @@ -1630,7 +1638,20 @@ export async function adoptArtifactTargetLease(options: { const s = openState(options.dataRoot, assertion.leaseId, peer.installation); try { return await guarded(s, () => { - const t = tree(s); + let t: LeaseTree; + try { + t = tree(s); + } catch (cause) { + const io = cause as NodeJS.ErrnoException | undefined; + if ( + io?.code === "EEXIST" && + io.syscall === "mkdir" && + typeof io.path === "string" && + basename(io.path) === ".family-writer" + ) + throw new ArtifactTargetAdoptionObservationBusyError(cause); + throw cause; + } const grant = t.grants.get(assertion.delegationId); if ( !grant || From bd269483bf2bcc19b054261939b748457b0fd634 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 21:47:21 +0100 Subject: [PATCH 11/12] fix(docker): upgrade APT in Debian runtime images --- apps/transitous-runner/Dockerfile | 5 +++- release-notes/debian-apt-runtime-security.md | 28 ++++++++++++++++++++ services/data-manager/Dockerfile | 5 +++- services/motis/tools/transitous/Dockerfile | 5 +++- services/ops-agent/privacy-backup/Dockerfile | 3 +++ 5 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 release-notes/debian-apt-runtime-security.md diff --git a/apps/transitous-runner/Dockerfile b/apps/transitous-runner/Dockerfile index 9d608cfbe..5fa2884a5 100644 --- a/apps/transitous-runner/Dockerfile +++ b/apps/transitous-runner/Dockerfile @@ -93,9 +93,12 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Python and ca-certificates pull in, to their security fixes. The version # checks fail a stale mirror and, when raised, invalidate this layer in the # registry build cache. +# apt and libapt-pkg6.0 2.6.2 fix CVE-2026-109518 (Debian DLA-4880-1). RUN apt-get update \ && apt-get install -y --no-install-recommends \ - ca-certificates curl git python3 python3-pip python3-venv age libpcre2-8-0 libexpat1 libssl3 openssl \ + ca-certificates curl git python3 python3-pip python3-venv age apt libapt-pkg6.0 libpcre2-8-0 libexpat1 libssl3 openssl \ + && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' apt)" ge 2.6.2 \ + && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libapt-pkg6.0)" ge 2.6.2 \ && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libexpat1)" ge 2.5.0-1+deb12u5 \ && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libssl3)" ge 3.0.22-1~deb12u2 \ && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' openssl)" ge 3.0.22-1~deb12u2 \ diff --git a/release-notes/debian-apt-runtime-security.md b/release-notes/debian-apt-runtime-security.md new file mode 100644 index 000000000..f94199408 --- /dev/null +++ b/release-notes/debian-apt-runtime-security.md @@ -0,0 +1,28 @@ +--- +impacts: [security] +--- + +## What changes + +The Data Manager, Transitous runner and Transitous tools images explicitly upgrade +`apt` and `libapt-pkg6.0` to at least Debian Bookworm version 2.6.2. The privacy +backup image enforces Trixie's fixed version 3.0.4 for `apt` and `libapt-pkg7.0` +after its existing distribution upgrade, while keeping PostgreSQL and PostGIS +packages held at their pinned versions. These upgrades fix +[CVE-2026-109518](https://security-tracker.debian.org/tracker/CVE-2026-109518), as +documented in Debian's +[DLA-4880-1](https://lists.debian.org/debian-lts-announce/2026/11/msg00004.html) and +[DSA-6607-1](https://lists.debian.org/debian-security-announce/2026/msg00526.html). +Builds reject repositories that cannot supply that minimum version, and the +changed runtime instructions invalidate cached layers containing older packages. + +## Who is affected + +Operators using Data Manager, Transitous runner, Transitous tools or privacy +backup images built from the pinned Debian bases before these package upgrades. + +## Required action + +Receive the fix through a normal image update to a release containing this change. +Operators building custom images should rebuild the affected targets. No settings +or data migration is required. diff --git a/services/data-manager/Dockerfile b/services/data-manager/Dockerfile index 92788152b..189ed499f 100644 --- a/services/data-manager/Dockerfile +++ b/services/data-manager/Dockerfile @@ -100,11 +100,14 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] # restore this layer from the registry build cache with the versions it held # then. Raising a version check here changes the instruction, so that layer is # not reused. +# apt and libapt-pkg6.0 2.6.2 fix CVE-2026-109518 (Debian DLA-4880-1). RUN apt-get update \ && apt-get install -y --no-install-recommends \ osmium-tool curl unzip bzip2 lbzip2 ca-certificates age \ git python3 python3-pip python3-venv wget rsync openssh-client \ - libexpat1 libpcre2-8-0 libbrotli1 libssl3 openssl \ + apt libapt-pkg6.0 libexpat1 libpcre2-8-0 libbrotli1 libssl3 openssl \ + && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' apt)" ge '2.6.2' \ + && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libapt-pkg6.0)" ge '2.6.2' \ && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libexpat1)" ge '2.5.0-1+deb12u4' \ && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libbrotli1)" ge '1.0.9-2+deb12u1' \ && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libssl3)" ge '3.0.22-1~deb12u2' \ diff --git a/services/motis/tools/transitous/Dockerfile b/services/motis/tools/transitous/Dockerfile index 03bafd910..fea7ff166 100644 --- a/services/motis/tools/transitous/Dockerfile +++ b/services/motis/tools/transitous/Dockerfile @@ -27,9 +27,12 @@ ARG TARGETARCH # git, Python and ca-certificates pull in, to their security fixes. The version # checks fail a stale mirror and, when raised, invalidate this layer in the # registry build cache. +# apt and libapt-pkg6.0 2.6.2 fix CVE-2026-109518 (Debian DLA-4880-1). RUN apt-get update -y \ && apt-get install -y --no-install-recommends \ - git python3-pip python3-venv curl wget bzip2 rsync openssh-client ca-certificates age libpcre2-8-0 libexpat1 libbrotli1 libssl3 openssl \ + git python3-pip python3-venv curl wget bzip2 rsync openssh-client ca-certificates age apt libapt-pkg6.0 libpcre2-8-0 libexpat1 libbrotli1 libssl3 openssl \ + && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' apt)" ge 2.6.2 \ + && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libapt-pkg6.0)" ge 2.6.2 \ && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libexpat1)" ge 2.5.0-1+deb12u5 \ && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libbrotli1)" ge 1.0.9-2+deb12u1 \ && dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libssl3)" ge 3.0.22-1~deb12u2 \ diff --git a/services/ops-agent/privacy-backup/Dockerfile b/services/ops-agent/privacy-backup/Dockerfile index 6ee785e7a..7ef5ccf14 100644 --- a/services/ops-agent/privacy-backup/Dockerfile +++ b/services/ops-agent/privacy-backup/Dockerfile @@ -6,6 +6,7 @@ FROM ghcr.io/baosystems/postgis:18-3.6@sha256:dd7515b7d8b8b8bac3516a9c3e12584df5 # Apply distribution security fixes; this non-root entrypoint does not use gosu. # The minimum Brotli version (CVE-2026-111497) fails a stale mirror and, when # raised, invalidates this layer in the registry build cache. +# apt and libapt-pkg7.0 3.0.4 fix CVE-2026-109518 (Debian DSA-6607-1). # PostgreSQL, PostGIS and libpq stay at the pinned image's versions: the restore # engine must match the deployment's, and the PostgreSQL repository can publish # a PostGIS release's SQL scripts before its library (3.7.0 scripts over the @@ -17,6 +18,8 @@ RUN apt-get update && \ awk '$1 == "ii" { print $2 }' | xargs apt-mark hold && \ apt-get upgrade -y && \ apt-get install -y --no-install-recommends python3 ca-certificates && \ + dpkg --compare-versions "$(dpkg-query -W -f='${Version}' apt)" ge 3.0.4 && \ + dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libapt-pkg7.0)" ge 3.0.4 && \ dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libbrotli1)" ge 1.1.0-2+deb13u1 && \ [ "$(dpkg-query -W -f='${Version}' postgresql-18-postgis-3)" = \ "$(dpkg-query -W -f='${Version}' postgresql-18-postgis-3-scripts)" ] && \ From e13fa2cd9a09931d3c0509e743d79e858a59ca7b Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 23:00:01 +0100 Subject: [PATCH 12/12] ci: allow headroom for native API recovery suites --- .github/workflows/ci.yml | 7 ++++--- docs/docs/developer/ci-performance.md | 20 +++++++++++++------- scripts/database-ci-contract.test.ts | 2 +- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36fedfc78..32f3e6bb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -341,9 +341,10 @@ jobs: database: name: test (database, ${{ matrix.group }}) runs-on: ubuntu-latest - # Native recovery suites run independently with real ownership/staging deadlines. - # Retain ingestion's existing budget while gathering its reduced runtime. - timeout-minutes: ${{ contains(fromJSON('["ingestion", "configuration-recovery", "coordinator-recovery"]'), matrix.group) && 90 || 30 }} + # API/privacy includes the full native platform-helper recovery matrix. + # Give native recovery groups headroom for setup and all serial suites; + # individual test and execution deadlines remain independently enforced. + timeout-minutes: ${{ contains(fromJSON('["api-privacy", "ingestion", "configuration-recovery", "coordinator-recovery"]'), matrix.group) && 90 || 30 }} strategy: # Each group has a separate runner and service; keep the other groups # running after a failure so all applicable evidence is collected. diff --git a/docs/docs/developer/ci-performance.md b/docs/docs/developer/ci-performance.md index b13c9292f..94a074e7d 100644 --- a/docs/docs/developer/ci-performance.md +++ b/docs/docs/developer/ci-performance.md @@ -232,19 +232,25 @@ The wider package/release baseline is handled separately. ## Database groups -Database validation runs in three jobs on separate GitHub-hosted runners: +Database validation runs in five jobs on separate GitHub-hosted runners: -| Group | Coverage | Additional prerequisites | -| ---------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `api-privacy` | Authentication, API routes, privacy lifecycle, core database and migration tests | Migrated PostGIS | -| `ingestion` | Data-manager accounting, search, Overture and ambient publication | Test-owned PostGIS containers | -| `export-restore` | Controller-wide export and backup restore replay | Exact-image Dawarich export and retained-backup archive | +| Group | Coverage | Additional prerequisites | +| ------------------------ | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `api-privacy` | Authentication, API routes, privacy lifecycle, migrations and native platform-helper recovery | Migrated PostGIS | +| `ingestion` | Data-manager accounting, search, Overture and ambient publication | Test-owned PostGIS containers | +| `configuration-recovery` | Native configuration-maintenance recovery | Test-owned PostGIS containers | +| `coordinator-recovery` | Native coordinator lifecycle and crash recovery | Test-owned PostGIS containers | +| `export-restore` | Controller-wide export and backup restore replay | Exact-image Dawarich export and retained-backup archive | Each runner gets the same pinned PostgreSQL 18 / PostGIS 3.6 service and applies all production migrations. Testcontainers, Compose fixtures and archive paths are owned by that runner. No database or archive is shared between jobs. Within each group Vitest uses one worker; increasing that can corrupt shared fixture state. +The four groups with native recovery suites have a 90-minute runner budget; +`export-restore` has 30 minutes. These limits include setup and the serial suites. +Individual test timeouts and runtime execution deadlines remain independent. + The matrix does not cancel other groups when one fails. The required `lint / types / test` aggregate still requires the database matrix to succeed; a failure, cancellation or skipped database job cannot pass it. The three privacy @@ -258,7 +264,7 @@ for disposable-database setup and archive preparation. The checked-in inventory is validated against discovered opt-in tests and actual Vitest file selection. Dawarich's exact-image test remains a separate explicit command in the export job. -The groups run concurrently and repeat runner setup three times. Compare normal-run gate time, queue delay and summed runner seconds before +The groups run concurrently and repeat runner setup five times. Compare normal-run gate time, queue delay and summed runner seconds before claiming an overall improvement. Local correctness checks do not establish CI latency or cost; the ten-run comparison remains required. diff --git a/scripts/database-ci-contract.test.ts b/scripts/database-ci-contract.test.ts index 8d8e8819f..69571d775 100644 --- a/scripts/database-ci-contract.test.ts +++ b/scripts/database-ci-contract.test.ts @@ -62,7 +62,7 @@ describe("required database test gate", () => { "DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/openmapx_test", ); expect(databaseJob).toContain( - `contains(fromJSON('["ingestion", "configuration-recovery", "coordinator-recovery"]'), matrix.group) && 90 || 30`, + `contains(fromJSON('["api-privacy", "ingestion", "configuration-recovery", "coordinator-recovery"]'), matrix.group) && 90 || 30`, ); expect(databaseJob).toContain("ghcr.io/baosystems/postgis:18-3.6@sha256:"); expect(databaseJob).toContain(