diff --git a/apps/web/src/app/admin/(full-admin)/system/backups/page.tsx b/apps/web/src/app/admin/(full-admin)/system/backups/page.tsx
index 126b161c7..c538ee1d0 100644
--- a/apps/web/src/app/admin/(full-admin)/system/backups/page.tsx
+++ b/apps/web/src/app/admin/(full-admin)/system/backups/page.tsx
@@ -1,6 +1,13 @@
+import Stack from "@mui/material/Stack";
import type { Metadata } from "next";
import { BackupsPage } from "@/components/admin/services/BackupsPage";
+import { PlatformConfiguration } from "@/components/admin/settings/PlatformConfiguration";
export const metadata: Metadata = { title: "Backups — Admin — OpenMapX" };
export default function Page() {
- return ;
+ return (
+
+
+
+
+ );
}
diff --git a/apps/web/src/app/admin/(full-admin)/system/deployment/page.tsx b/apps/web/src/app/admin/(full-admin)/system/deployment/page.tsx
index 5fd81e44a..b9fa3bd19 100644
--- a/apps/web/src/app/admin/(full-admin)/system/deployment/page.tsx
+++ b/apps/web/src/app/admin/(full-admin)/system/deployment/page.tsx
@@ -1,11 +1,26 @@
+import Accordion from "@mui/material/Accordion";
+import AccordionDetails from "@mui/material/AccordionDetails";
+import AccordionSummary from "@mui/material/AccordionSummary";
import Stack from "@mui/material/Stack";
import { ComposePreview } from "@/components/admin/services/ComposePreview";
+import { ServiceSelectionEditor } from "@/components/admin/services/ServiceSelectionEditor";
import { PlatformConfiguration } from "@/components/admin/settings/PlatformConfiguration";
+import { AdminPageHeader } from "@/components/admin/shared/AdminPageHeader";
export default function Page() {
return (
-
-
+
+
+
+
+ Compose and host recovery
+
+
+
+
);
}
diff --git a/apps/web/src/app/admin/(full-admin)/system/updates/page.tsx b/apps/web/src/app/admin/(full-admin)/system/updates/page.tsx
index 67f344009..4253c379c 100644
--- a/apps/web/src/app/admin/(full-admin)/system/updates/page.tsx
+++ b/apps/web/src/app/admin/(full-admin)/system/updates/page.tsx
@@ -1,4 +1,13 @@
+import Stack from "@mui/material/Stack";
+import { PlatformConfiguration } from "@/components/admin/settings/PlatformConfiguration";
+import { AdminPageHeader } from "@/components/admin/shared/AdminPageHeader";
import { SystemMaintenance } from "@/components/admin/system/SystemMaintenance";
export default function Page() {
- return ;
+ return (
+
+
+
+
+
+ );
}
diff --git a/apps/web/src/components/admin/AdminLayout.navigation.test.tsx b/apps/web/src/components/admin/AdminLayout.navigation.test.tsx
index 81955218a..9920a62ec 100644
--- a/apps/web/src/components/admin/AdminLayout.navigation.test.tsx
+++ b/apps/web/src/components/admin/AdminLayout.navigation.test.tsx
@@ -1,8 +1,11 @@
import { en } from "@openmapx/i18n";
import { NextIntlClientProvider } from "next-intl";
+import { useContext } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, expect, it, vi } from "vitest";
+import { AdminDraftContext, AdminDraftProvider } from "@/lib/admin/AdminDraftProvider";
import { act, cleanup, createQueryWrapper, fireEvent, render, screen } from "@/test";
+import { adminTestHistory } from "@/test/adminNavigation";
import { AdminLayout } from "./AdminLayout";
vi.mock("@/integration-api/runtime/EnvProvider", () => ({
@@ -16,6 +19,71 @@ afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
+it.each([false, null])("guards direct host-topic entry for deployment %s", (selfHosted) => {
+ adminTestHistory.reset("/admin/system/deployment");
+ vi.stubGlobal("matchMedia", () => ({
+ matches: true,
+ addEventListener() {},
+ removeEventListener() {},
+ }));
+ render(
+
+
+
+
+ ,
+ { wrapper: createQueryWrapper() },
+ );
+ if (selfHosted === false) {
+ expect(screen.getByRole("combobox", { name: "Topic" })).toHaveTextContent("Choose a topic");
+ expect(screen.queryByRole("button", { name: "Host mutation" })).toBeNull();
+ expect(screen.getAllByRole("link", { name: "Cache" }).length).toBeGreaterThan(0);
+ } else expect(screen.getByRole("button", { name: "Host mutation" })).toBeInTheDocument();
+});
+it("retires the admin view and navigation after a confirmed authority change", async () => {
+ function Child() {
+ const store = useContext(AdminDraftContext);
+ return (
+
+ );
+ }
+ vi.stubGlobal("matchMedia", () => ({
+ matches: true,
+ addEventListener() {},
+ removeEventListener() {},
+ }));
+ render(
+
+
+
+
+
+
+ ,
+ { wrapper: createQueryWrapper() },
+ );
+ fireEvent.click(await screen.findByRole("button", { name: "Change authority" }));
+ expect(screen.queryByRole("button", { name: "Change authority" })).toBeNull();
+ expect(screen.queryByRole("link", { name: "System" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Administration jobs" })).toBeNull();
+ expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument();
+});
it("keeps the desktop sidebar preference independent of mobile drawer navigation", () => {
let wide = true;
const listeners = new Set<() => void>();
diff --git a/apps/web/src/components/admin/AdminLayout.tsx b/apps/web/src/components/admin/AdminLayout.tsx
index 703773b5d..8535c03a5 100644
--- a/apps/web/src/components/admin/AdminLayout.tsx
+++ b/apps/web/src/components/admin/AdminLayout.tsx
@@ -1,18 +1,26 @@
"use client";
+import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
+import Stack from "@mui/material/Stack";
import useMediaQuery from "@mui/material/useMediaQuery";
+import Link from "next/link";
import { usePathname } from "next/navigation";
+import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { useEnv } from "@/integration-api/runtime/EnvProvider";
-import { useBindAdminDraftOwner } from "@/lib/admin/AdminDraftProvider";
+import { useAdminDraftAuthority, useBindAdminDraftOwner } from "@/lib/admin/AdminDraftProvider";
import { AdminJobEffectsProvider } from "@/lib/admin/adminJobEffects";
-import { adminWorkspaceForPath } from "@/lib/admin/adminNavigation";
+import { adminDestinationForPath, adminWorkspaceForPath } from "@/lib/admin/adminNavigation";
import { useHydrated } from "@/lib/useHydrated";
import { AdminSidebar } from "./AdminSidebar";
import { AdminTopBar } from "./AdminTopBar";
import { TOPBAR_HEIGHT } from "./layoutConstants";
+import { PlatformPreviewLifecycle } from "./settings/PlatformPreviewLifecycle";
+import { AdminNavigationContext } from "./shared/AdminNavigationContext";
+import { AdminPageHeader } from "./shared/AdminPageHeader";
import { AdminThemeProvider } from "./shared/AdminThemeProvider";
import { AdminToastProvider } from "./shared/AdminToast";
import { AdminTopicNavigation } from "./shared/AdminTopicNavigation";
@@ -41,8 +49,19 @@ export function AdminLayout(props: AdminLayoutProps) {
}
function AdminLayoutContent({ children, user, selfHosted = false }: AdminLayoutProps) {
const { apiUrl } = useEnv();
- const workspace = adminWorkspaceForPath(usePathname());
+ const pathname = usePathname();
+ const workspace = adminWorkspaceForPath(pathname);
+ const destination = adminDestinationForPath(pathname);
+ const hostUnavailable = destination?.hostOnly && selfHosted === false;
+ const t = useTranslations("adminNavigation");
useBindAdminDraftOwner({ apiUrl, actorId: user.id, role: user.role ?? "admin" });
+ const authority = useAdminDraftAuthority();
+ const authorized =
+ !authority.present ||
+ (authority.owner?.apiUrl === apiUrl &&
+ authority.owner?.actorId === user.id &&
+ authority.owner?.role === (user.role ?? "admin"));
+ const visibleRole = authorized ? (user.role ?? "admin") : "";
const hydrated = useHydrated();
const desktop = useMediaQuery("(min-width:900px)", { defaultMatches: true });
const [desktopOpen, setDesktopOpen] = useState(true);
@@ -54,83 +73,117 @@ function AdminLayoutContent({ children, user, selfHosted = false }: AdminLayoutP
}, [desktop]);
return (
-
-
-
-
- setMobileOpen(false)}
- selfHosted={selfHosted}
- role={user.role}
- />
+
+
+
+
-
+ setMobileOpen(false)}
selfHosted={selfHosted}
- sidebarOpen={sidebarOpen}
- onToggleSidebar={toggleSidebar}
+ role={visibleRole}
/>
+
- {hydrated ? (
-
- {workspace && (
-
- )}
- {children}
-
- ) : (
-
-
-
- )}
+
+ {hydrated ? (
+
+ {workspace && (
+
+ )}
+
+ {!authorized ? (
+ window.location.reload()}>
+ {t("reload")}
+
+ }
+ >
+ {t("authorityChanged")}
+
+ ) : hostUnavailable && destination ? (
+
+
+ {t("hostUnavailable")}
+
+
+
+
+
+ ) : (
+ children
+ )}
+
+
+ ) : (
+
+
+
+ )}
+
-
-
-
-
+
+
+
+
);
}
diff --git a/apps/web/src/components/admin/AdminTopBar.tsx b/apps/web/src/components/admin/AdminTopBar.tsx
index f48a12318..2042ce936 100644
--- a/apps/web/src/components/admin/AdminTopBar.tsx
+++ b/apps/web/src/components/admin/AdminTopBar.tsx
@@ -92,6 +92,10 @@ export function AdminTopBar({ user, selfHosted, sidebarOpen, onToggleSidebar }:
flex: 1,
minWidth: 0,
"& .MuiBreadcrumbs-ol": { flexWrap: "nowrap" },
+ "& .MuiBreadcrumbs-li": { minWidth: 0 },
+ "& .MuiBreadcrumbs-li:not(:last-child), & .MuiBreadcrumbs-separator": {
+ display: { xs: "none", sm: "block" },
+ },
}}
>
{breadcrumbs.map((crumb, i) => {
diff --git a/apps/web/src/components/admin/integrations/IntegrationDetail.tsx b/apps/web/src/components/admin/integrations/IntegrationDetail.tsx
index ba8c496c7..aad25a966 100644
--- a/apps/web/src/components/admin/integrations/IntegrationDetail.tsx
+++ b/apps/web/src/components/admin/integrations/IntegrationDetail.tsx
@@ -9,6 +9,9 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew";
import RefreshIcon from "@mui/icons-material/Refresh";
+import Accordion from "@mui/material/Accordion";
+import AccordionDetails from "@mui/material/AccordionDetails";
+import AccordionSummary from "@mui/material/AccordionSummary";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
@@ -164,27 +167,6 @@ function OverviewTab({ data }: { data: IntegrationDetailData }) {
))}
- {data.description && (
-
-
- Description
-
- {data.description}
-
- )}
@@ -347,18 +329,32 @@ function ConfigTab({
gap: 3,
}}
>
+ {/* Editable config form */}
+ {data.manifest.configSchema && (
+
+
+
+ Edit Configuration
+
+
+
+
+ )}
{/* Raw resolved config table */}
-
-
-
- Resolved Values
-
+
+ Effective values and sources
+
{entries.length === 0 ? (
No configuration keys found.
@@ -414,30 +410,8 @@ function ConfigTab({
)}
-
-
- {/* Editable config form */}
- {data.manifest.configSchema && (
-
-
-
- Edit Configuration
-
-
-
-
- )}
+
+
);
}
@@ -1147,6 +1121,7 @@ export function IntegrationDetail({ id }: IntegrationDetailProps) {
size={14}
/>
- {/* Summary cards */}
-
-
- } href="/status">
-
- {data.systemHealth.status === "pass" ? "OK" : data.systemHealth.unhealthyCount}
-
-
- {data.systemHealth.status === "pass"
- ? "All systems operational"
- : `${data.systemHealth.unhealthyCount} unhealthy`}
-
-
-
-
-
- } href="/admin/users">
-
- {data.users.total}
-
-
-
- {data.users.banned > 0 && (
-
- )}
-
-
-
-
-
- } href="/admin/integrations">
-
- {data.integrations.total}
-
-
-
- {data.integrations.unhealthy > 0 && (
-
- )}
- {data.integrations.unconfigured > 0 && (
-
- )}
-
-
-
-
-
- {data.services ? (
- } href="/admin/system/services">
-
- {data.services.running}
-
-
-
-
- {data.services.unhealthy > 0 && (
-
- )}
-
-
- ) : (
- } href="/status">
-
- N/A
-
-
- Cloud deployment
-
-
- )}
-
-
-
- } href="/admin/integrations">
- 0 ? "warning.main" : "success.main"}
- sx={{
- fontWeight: 700,
- }}
- >
- {totalMissingCredentials}
-
-
-
- 0
- ? `${missingCredentialIntegrations.length} missing`
- : "all configured"
- }
- size="small"
- color={totalMissingCredentials > 0 ? "warning" : "success"}
- variant="outlined"
- />
-
-
-
-
{/* Attention list */}
{generalAttention.length > 0 && (
@@ -620,7 +412,13 @@ export function AdminOverview() {
variant="outlined"
sx={{ fontSize: 10, height: 18, minWidth: 60 }}
/>
-
+
{job.type}
{job.status === "running" && job.progress != null && (
@@ -648,6 +446,216 @@ export function AdminOverview() {
+
+ {/* Summary cards */}
+
+
+ } href="/status">
+
+ {data.systemHealth.status === "pass" ? "OK" : data.systemHealth.unhealthyCount}
+
+
+ {data.systemHealth.status === "pass"
+ ? "All systems operational"
+ : `${data.systemHealth.unhealthyCount} unhealthy`}
+
+
+
+
+
+ } href="/admin/users">
+
+ {data.users.total}
+
+
+
+ {data.users.banned > 0 && (
+
+ )}
+
+
+
+
+
+ } href="/admin/integrations">
+
+ {data.integrations.total}
+
+
+
+ {data.integrations.unhealthy > 0 && (
+
+ )}
+ {data.integrations.unconfigured > 0 && (
+
+ )}
+
+
+
+
+
+ {data.services ? (
+ } href="/admin/system/services">
+
+ {data.services.running}
+
+
+
+
+ {data.services.unhealthy > 0 && (
+
+ )}
+
+
+ ) : (
+ } href="/status">
+
+ N/A
+
+
+ Cloud deployment
+
+
+ )}
+
+
+
+ } href="/admin/integrations">
+ 0 ? "warning.main" : "success.main"}
+ sx={{
+ fontWeight: 700,
+ }}
+ >
+ {totalMissingCredentials}
+
+
+
+ 0
+ ? `${missingCredentialIntegrations.length} missing`
+ : "all configured"
+ }
+ size="small"
+ color={totalMissingCredentials > 0 ? "warning" : "success"}
+ variant="outlined"
+ />
+
+
+
+
);
}
diff --git a/apps/web/src/components/admin/services/BackupPruneControls.tsx b/apps/web/src/components/admin/services/BackupPruneControls.tsx
index 9f3ba64b2..15c567d74 100644
--- a/apps/web/src/components/admin/services/BackupPruneControls.tsx
+++ b/apps/web/src/components/admin/services/BackupPruneControls.tsx
@@ -84,7 +84,7 @@ export function BackupPruneControls() {
>
Preview expired backups
-
-
+ {!data.enabled && }
+ >
+ }
+ actions={
+ <>
: }
- onClick={() => {
- void runAction("stop").catch(() => {});
- }}
- disabled={isBusy || Boolean(reason("stop"))}
- title={reason("stop") ?? undefined}
+ startIcon={isBusy ? : }
+ title={reason(startAction) ?? undefined}
+ disabled={isBusy || !!reason(startAction)}
+ onClick={() => void runAction(startAction).catch(() => {})}
>
- Stop
+ {data.status === "running" ? "Apply changes" : "Start"}
-
-
- : }
- onClick={() => {
- void runAction("restart").catch(() => {});
- }}
- disabled={isBusy || Boolean(reason("restart"))}
- >
- Restart
-
-
-
}
- onClick={() => {
- if (!reason("logs")) setLogsOpen(true);
- }}
- disabled={Boolean(reason("logs"))}
- title={reason("logs") ?? undefined}
+ size="small"
+ aria-haspopup="menu"
+ aria-expanded={!!actionMenu}
+ aria-controls={actionMenu ? "service-actions" : undefined}
+ onClick={(event) => setActionMenu(event.currentTarget)}
>
- Logs
+ More actions
{lastActionJobId && }
-
-
-
- {manifest.id} · v{manifest.version}
+ >
+ }
+ />
+ {manifest.description && (
+
+ {manifest.description}
- {manifest.description && (
- setActionMenu(null)}
+ >
+ {(["stop", "restart", "logs"] as const).map((action) => (
+
- )}
-
+
+
+ ))}
+
{[
...new Set(
[reason(startAction), reason("stop"), reason("restart"), reason("logs")].filter(Boolean),
@@ -769,7 +666,7 @@ function ServiceDetailView({ id }: ServiceDetailProps) {
)}
{["app-api", "traefik", "ops-agent"].includes(manifest.id) && (
- System Maintenance
+ Updates
)}
{manifest.id === "dawarich-app" && }
@@ -805,44 +702,15 @@ function ServiceDetailView({ id }: ServiceDetailProps) {
/>
)}
{tab === 2 && }
- {tab === 3 && (
-
-
- Click "Logs" or switch to this tab to open the live log stream.
-
- }
- onClick={() => {
- if (!reason("logs")) setLogsOpen(true);
- }}
- disabled={Boolean(reason("logs"))}
- title={reason("logs") ?? undefined}
- sx={{ mt: 1.5 }}
- >
- Open Logs Drawer
-
-
- )}
+ {tab === 3 &&
+ (reason("logs") ? (
+ {reason("logs")}
+ ) : (
+
+ ))}
{tab === 4 && }
- setLogsOpen(false)}
- serviceId={id}
- serviceName={manifest.name}
- />
);
}
diff --git a/apps/web/src/components/admin/services/ServiceLogsDrawer.tsx b/apps/web/src/components/admin/services/ServiceLogsDrawer.tsx
index 0abd74a41..ecd8b2834 100644
--- a/apps/web/src/components/admin/services/ServiceLogsDrawer.tsx
+++ b/apps/web/src/components/admin/services/ServiceLogsDrawer.tsx
@@ -31,17 +31,47 @@ export function ServiceLogsDrawer({
serviceId,
serviceName,
}: ServiceLogsDrawerProps) {
- const { client, apiUrl } = useActivityClient();
return (
-
+
+
+ );
+}
+export function ServiceLogsPanel({
+ active,
+ serviceId,
+ serviceName,
+ onClose,
+}: Omit & { active: boolean; onClose?: () => void }) {
+ const { client, apiUrl } = useActivityClient();
+ return active ? (
+
- );
+ ) : null;
}
function ServiceLogsSession({
@@ -50,7 +80,10 @@ function ServiceLogsSession({
serviceId,
serviceName,
client,
-}: ServiceLogsDrawerProps & Pick, "client">) {
+}: Omit & { onClose?: () => void } & Pick<
+ ReturnType,
+ "client"
+ >) {
const [output, setOutput] = useState({ lines: [] as string[], truncated: false });
const [streaming, setStreaming] = useState(open),
[error, setError] = useState(null);
@@ -64,7 +97,7 @@ function ServiceLogsSession({
() => ({ tail, follow, duration, connection, idempotencyKey: crypto.randomUUID() }),
[tail, follow, duration, connection],
);
- const bottomRef = useRef(null),
+ const outputRef = useRef(null),
abortRef = useRef(null);
useEffect(() => {
if (!open || paused) return;
@@ -115,7 +148,7 @@ function ServiceLogsSession({
useEffect(() => {
if (open && lines.length > 0) {
- bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+ if (outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [lines, open]);
@@ -124,22 +157,20 @@ function ServiceLogsSession({
setOutput({ lines: [], truncated: false });
setError(null);
setStreaming(false);
- onClose();
+ onClose?.();
}
return (
-
)}
-
-
-
+ {onClose && (
+
+
+
+ )}
)}
{lines.join("\n")}
-
-
-
+
);
}
diff --git a/apps/web/src/components/admin/services/ServiceSelectionEditor.tsx b/apps/web/src/components/admin/services/ServiceSelectionEditor.tsx
new file mode 100644
index 000000000..4c650c3a9
--- /dev/null
+++ b/apps/web/src/components/admin/services/ServiceSelectionEditor.tsx
@@ -0,0 +1,232 @@
+"use client";
+import Accordion from "@mui/material/Accordion";
+import AccordionDetails from "@mui/material/AccordionDetails";
+import AccordionSummary from "@mui/material/AccordionSummary";
+import Alert from "@mui/material/Alert";
+import Button from "@mui/material/Button";
+import Chip from "@mui/material/Chip";
+import Paper from "@mui/material/Paper";
+import Stack from "@mui/material/Stack";
+import Table from "@mui/material/Table";
+import TableBody from "@mui/material/TableBody";
+import TableCell from "@mui/material/TableCell";
+import TableContainer from "@mui/material/TableContainer";
+import TableHead from "@mui/material/TableHead";
+import TableRow from "@mui/material/TableRow";
+import TextField from "@mui/material/TextField";
+import Typography from "@mui/material/Typography";
+import Link from "next/link";
+import { useState } from "react";
+import { useServiceSelection, useServiceSelectionSave, useServicesList } from "@/hooks/useServices";
+import { useAdminDraft } from "@/lib/admin/useAdminDraft";
+import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner";
+import { AdminJobLink } from "../activity/AdminJobLink";
+import { useActivityClient } from "../activity/activityQueries";
+import { useAdminToast } from "../shared/AdminToast";
+
+const parseRoots = (value: string) =>
+ value
+ .split(",")
+ .map((id) => id.trim())
+ .filter(Boolean);
+export function ServiceSelectionEditor() {
+ const { apiUrl } = useActivityClient();
+ return ;
+}
+function SelectionEditor() {
+ const selection = useServiceSelection();
+ const services = useServicesList();
+ const save = useServiceSelectionSave();
+ const { client } = useActivityClient();
+ const owner = useAdminMutationOwner(client);
+ const toast = useAdminToast();
+ const [accepted, setAccepted] = useState();
+ const baseline = (selection.data?.selectedRoots ?? []).join(", ");
+ const locked = selection.data?.source === "env";
+ const draft = useAdminDraft({
+ id: "service-selection",
+ baseline: { roots: baseline },
+ editableKeys: locked ? [] : ["roots"],
+ signature: "roots-v1",
+ ready: !!selection.data,
+ });
+ const disabled = !selection.data || selection.isError || locked || save.isPending;
+ async function persist(roots: string[], submitted: string) {
+ try {
+ const result = await save.mutateAsync(roots);
+ draft.acknowledge({ roots: submitted });
+ if (!owner.active) return;
+ if ("jobId" in result) setAccepted(String(result.jobId));
+ toast(
+ "jobId" in result
+ ? `Queued service selection (${result.jobId})`
+ : "Service selection saved",
+ );
+ } catch (error) {
+ if (owner.active) toast(error instanceof Error ? error.message : "Save failed", "error");
+ }
+ }
+ return (
+
+
+
+ Service selection
+
+
+ Persistent roots determine enabled services and their dependencies. Removing a root can
+ keep it enabled as another root's dependency. Inventory checkboxes select temporary action
+ targets.
+
+ {locked && (
+
+ Selection is controlled by {selection.data?.envVarName}. Remove that host override
+ before editing the selection file.
+
+ )}
+ {selection.isError && (
+ void selection.refetch()}>Retry}
+ >
+ Failed to load service selection
+
+ )}
+ draft.setValues({ roots: event.target.value })}
+ disabled={disabled}
+ placeholder="app-api, valhalla, osrm"
+ />
+ {draft.conflicts.length > 0 && (
+ draft.keepEdits()}>Keep my edits}
+ >
+ Saved service roots changed. Review or discard your edits before saving.
+
+ )}
+
+
+
+
+ {
+ const submitted = String(draft.values.roots ?? "");
+ void persist(parseRoots(submitted), submitted);
+ }}
+ >
+ Save Selection
+
+ {draft.dirty && (
+ draft.discard()}>
+ Discard
+
+ )}
+
+ {!!selection.data?.warnings.length && (
+ {selection.data.warnings.join("; ")}
+ )}
+ {!!selection.data?.missingIds.length && (
+
+ Missing service IDs: {selection.data.missingIds.join(", ")}
+
+ )}
+ {accepted && }
+
+ Services and root controls
+
+ {services.isError && (
+
+ Service membership could not be loaded. The root editor remains available.
+
+ )}
+
+
+
+
+ Service
+ Membership
+ Root control
+
+
+
+ {(services.data?.services ?? []).map((service) => {
+ const root = selection.data?.selectedRoots.includes(service.id);
+ const dependency = service.enabled && !root;
+ const reason = dependency
+ ? "Required by a selected root. Edit the roots to remove that dependency."
+ : draft.dirty
+ ? "Save or discard root edits before changing one service."
+ : undefined;
+ return (
+
+
+
+ {service.name}
+
+
+ {service.id}
+
+
+
+ {root ? "Root" : dependency ? "Required dependency" : "Disabled"}
+
+
+ {
+ const roots = root
+ ? (selection.data?.selectedRoots.filter(
+ (id) => id !== service.id,
+ ) ?? [])
+ : [...(selection.data?.selectedRoots ?? []), service.id];
+ void persist(roots, roots.join(", "));
+ }}
+ >
+ {root
+ ? `Remove ${service.id} root`
+ : dependency
+ ? "Required dependency"
+ : `Enable ${service.id}`}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/admin/services/ServiceSessions.test.tsx b/apps/web/src/components/admin/services/ServiceSessions.test.tsx
index 6419a4cd5..4158addb9 100644
--- a/apps/web/src/components/admin/services/ServiceSessions.test.tsx
+++ b/apps/web/src/components/admin/services/ServiceSessions.test.tsx
@@ -6,7 +6,7 @@ import { act, cleanup, createTestQueryClient, fireEvent, render, screen, waitFor
import { job } from "../activity/__tests__/fixtures";
import { ComposePreview } from "./ComposePreview";
import { ManagedDawarichSetup } from "./ManagedDawarichSetup";
-import { ServiceLogsDrawer } from "./ServiceLogsDrawer";
+import { ServiceLogsDrawer, ServiceLogsPanel } from "./ServiceLogsDrawer";
let apiUrl = "http://admin.test";
const toast = vi.fn();
@@ -211,6 +211,30 @@ describe("Compose generated consumers", () => {
});
});
describe("generated log sessions", () => {
+ it("owns one inline stream, aborts on tab disposal and starts a fresh read on return", async () => {
+ const signals: AbortSignal[] = [];
+ vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
+ signals.push(init.signal as AbortSignal);
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode("inline output\n"));
+ },
+ }),
+ { headers: { "content-type": "text/plain" } },
+ );
+ });
+ const view = mount();
+ await screen.findByText("inline output");
+ expect(signals).toHaveLength(1);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ view.replace();
+ await waitFor(() => expect(signals[0].aborted).toBe(true));
+ expect(screen.queryByText("inline output")).toBeNull();
+ view.replace();
+ await screen.findByText("inline output");
+ expect(signals).toHaveLength(2);
+ });
it("decodes split UTF8/newlines, preserves blank output and keeps the last1000 lines", async () => {
const encoder = new TextEncoder();
const bytes = encoder.encode("α\n\nline\nlast😀");
diff --git a/apps/web/src/components/admin/services/__tests__/BackupPruneControls.test.tsx b/apps/web/src/components/admin/services/__tests__/BackupPruneControls.test.tsx
index 3bb059fab..a30bc809c 100644
--- a/apps/web/src/components/admin/services/__tests__/BackupPruneControls.test.tsx
+++ b/apps/web/src/components/admin/services/__tests__/BackupPruneControls.test.tsx
@@ -90,7 +90,7 @@ it("refuses an expired preview and links to the single source-aware policy edito
setup(true);
expect(screen.getByRole("link", { name: "Edit backup retention policy" })).toHaveAttribute(
"href",
- "/admin/system/deployment#backup-retention",
+ "/admin/system/backups#backup-retention",
);
fireEvent.click(screen.getByRole("button", { name: "Preview expired backups" }));
await screen.findByText(/30 days.*database/);
diff --git a/apps/web/src/components/admin/settings/AuthPolicySettings.test.tsx b/apps/web/src/components/admin/settings/AuthPolicySettings.test.tsx
index fa02cb0f5..7223fe478 100644
--- a/apps/web/src/components/admin/settings/AuthPolicySettings.test.tsx
+++ b/apps/web/src/components/admin/settings/AuthPolicySettings.test.tsx
@@ -1,5 +1,6 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { AdminDraftProvider } from "@/lib/admin/AdminDraftProvider";
import { cleanup, createTestQueryClient, fireEvent, render, screen, waitFor } from "@/test";
const mocks = vi.hoisted(() => ({ get: vi.fn(), apply: vi.fn() }));
@@ -68,6 +69,28 @@ it("focuses the addressed policy field without applying it", async () => {
await waitFor(() => expect(screen.getByLabelText("Minimum password length")).toHaveFocus());
expect(mocks.apply).not.toHaveBeenCalled();
});
+it("retains ordinary policy edits across topic disposal and clears confirmation", async () => {
+ const { AuthPolicySettings } = await import("./AuthPolicySettings");
+ const cache = createTestQueryClient();
+ caches.push(cache);
+ const view = (show: boolean) => (
+
+
+ {show ? : Another topic
}
+
+
+ );
+ const mounted = render(view(true));
+ fireEvent.change(await screen.findByLabelText("Minimum password length"), {
+ target: { value: "12" },
+ });
+ fireEvent.click(screen.getByRole("checkbox", { name: /I have reviewed/ }));
+ mounted.rerender(view(false));
+ mounted.rerender(view(true));
+ expect(await screen.findByLabelText("Minimum password length")).toHaveValue(12);
+ expect(screen.getByRole("checkbox", { name: /I have reviewed/ })).not.toBeChecked();
+ expect(mocks.apply).not.toHaveBeenCalled();
+});
it("keeps effective defaults separate from legacy false drafts and rejects invalid bounds", async () => {
await mount();
expect(await screen.findByText(/safe defaults are active/)).toBeVisible();
diff --git a/apps/web/src/components/admin/settings/AuthPolicySettings.tsx b/apps/web/src/components/admin/settings/AuthPolicySettings.tsx
index df5570214..4070eb641 100644
--- a/apps/web/src/components/admin/settings/AuthPolicySettings.tsx
+++ b/apps/web/src/components/admin/settings/AuthPolicySettings.tsx
@@ -11,6 +11,7 @@ import { authPolicySettingsSchema, type authPolicyViewSchema } from "@openmapx/c
import { useQuery } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { useAccountAction, useAccountAdmin } from "@/lib/admin/accountAdmin";
+import { useAdminDraft } from "@/lib/admin/useAdminDraft";
import { useAdminFieldFocus } from "@/lib/admin/useAdminFieldFocus";
export function AuthPolicySettings({ focusKey }: { focusKey?: string } = {}) {
const scope = useAccountAdmin(),
@@ -44,17 +45,42 @@ export function AuthPolicySettings({ focusKey }: { focusKey?: string } = {}) {
);
}
function PolicyForm({ view }: { view: ReturnType }) {
- const { active, pending, ownership } = view,
- [email, setEmail] = useState(active.emailVerificationRequired),
- [hours, setHours] = useState(String(active.sessionDurationHours)),
- [twoFa, setTwoFa] = useState(active.twoFaEnabled),
- [length, setLength] = useState(String(active.passwordMinLength)),
- [confirmed, setConfirmed] = useState(false),
+ const { active, pending, ownership } = view;
+ const draft = useAdminDraft({
+ id: "authentication-policy",
+ signature: "policy-v1",
+ baseline: {
+ emailVerificationRequired: active.emailVerificationRequired,
+ sessionDurationHours: String(active.sessionDurationHours),
+ twoFaEnabled: active.twoFaEnabled,
+ passwordMinLength: String(active.passwordMinLength),
+ },
+ editableKeys: [
+ "sessionDurationHours",
+ "twoFaEnabled",
+ "passwordMinLength",
+ ...(ownership.emailVerificationRequired === "env" ? [] : ["emailVerificationRequired"]),
+ ],
+ });
+ const submitted = useRef>({});
+ const email = Boolean(draft.values.emailVerificationRequired ?? active.emailVerificationRequired),
+ hours = String(draft.values.sessionDurationHours),
+ twoFa = Boolean(draft.values.twoFaEnabled),
+ length = String(draft.values.passwordMinLength);
+ const setEmail = (value: boolean) =>
+ draft.setValues((old) => ({ ...old, emailVerificationRequired: value }));
+ const setHours = (value: string) =>
+ draft.setValues((old) => ({ ...old, sessionDurationHours: value }));
+ const setTwoFa = (value: boolean) => draft.setValues((old) => ({ ...old, twoFaEnabled: value }));
+ const setLength = (value: string) =>
+ draft.setValues((old) => ({ ...old, passwordMinLength: value }));
+ const [confirmed, setConfirmed] = useState(false),
[saved, setSaved] = useState(false),
key = useRef(null),
mutation = useAccountAction("auth-policy", {
viewKey: active.revision,
onSuccess: () => {
+ draft.acknowledge(submitted.current);
setConfirmed(false);
setSaved(true);
},
@@ -206,6 +232,23 @@ function PolicyForm({ view }: { view: ReturnType
+ {!!draft.conflicts.length && (
+ edit(() => draft.keepEdits())}>Keep my edits}
+ >
+ The active policy changed. Review or discard your draft before applying.
+
+ )}
+ {draft.dirty && (
+ edit(() => draft.discard())}
+ >
+ Discard policy edits
+
+ )}
{mutation.error && {mutation.error.message}}
{saved && (
@@ -215,11 +258,12 @@ function PolicyForm({ view }: { view: ReturnType {
if (!parsed.success) return;
key.current ??= crypto.randomUUID();
const requestKey = key.current;
+ submitted.current = { ...draft.values };
mutation.mutate((client) => client.applyPolicy(parsed.data, active.revision, requestKey));
}}
>
diff --git a/apps/web/src/components/admin/settings/EmailSettingsSection.test.tsx b/apps/web/src/components/admin/settings/EmailSettingsSection.test.tsx
new file mode 100644
index 000000000..147fe8409
--- /dev/null
+++ b/apps/web/src/components/admin/settings/EmailSettingsSection.test.tsx
@@ -0,0 +1,88 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { cleanup, createQueryWrapper, fireEvent, render, screen, waitFor } from "@/test";
+import { savedEmailProvider } from "./EmailSettingsSection";
+import { SystemSettings } from "./SystemSettings";
+
+vi.mock("@/integration-api/runtime/EnvProvider", () => ({
+ useEnv: () => ({ apiUrl: "http://api.test" }),
+}));
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+const fields = [
+ ["smtpFromAddress", "common", "From Address", "sender@example.test", false],
+ ["emailLabsAppKey", "emaillabs", "App Key", "***", true],
+ ["emailLabsSecretKey", "emaillabs", "Secret Key", "***", true],
+ ["emailLabsSmtpAccount", "emaillabs", "SMTP Account", "account", false],
+ ["lettermintApiToken", "lettermint", "API Token", "***", true],
+ ["smtpHost", "smtp", "Host", "smtp.example.test", false],
+].map(([key, subgroup, label, value, secret]) => ({
+ group: "email",
+ key,
+ subgroup,
+ label,
+ value,
+ secret,
+ type: "string",
+ source: "database",
+ envOverride: false,
+}));
+function mount(focusKey?: string) {
+ const fetch = vi.fn(async (_input: unknown, init: unknown) =>
+ (init as RequestInit | undefined)?.method === "PATCH"
+ ? Response.json({ ok: true })
+ : Response.json({ groups: [{ id: "email", label: "Email", settings: fields }] }),
+ );
+ vi.stubGlobal("fetch", fetch);
+ render(
+ ,
+ { wrapper: createQueryWrapper() },
+ );
+ return fetch;
+}
+it("keeps the existing provider priority without an exclusive provider setting", () => {
+ expect(
+ savedEmailProvider({
+ emailLabsAppKey: "***",
+ emailLabsSecretKey: "***",
+ emailLabsSmtpAccount: "account",
+ lettermintApiToken: "***",
+ }),
+ ).toBe("emaillabs");
+ expect(
+ savedEmailProvider({
+ emailLabsAppKey: "***",
+ emailLabsSecretKey: "***",
+ lettermintApiToken: "***",
+ }),
+ ).toBe("lettermint");
+ expect(savedEmailProvider({})).toBe("smtp");
+});
+it("opens a collapsed SMTP provider and focuses its search result", async () => {
+ mount("smtpHost");
+ await waitFor(() => expect(screen.getByLabelText("Host")).toHaveFocus());
+ expect(screen.getByText(/Saved provider: EmailLabs/)).toBeInTheDocument();
+});
+it("preserves explicit edits when a provider closes and skips unchanged masked credentials", async () => {
+ const fetch = mount();
+ fireEvent.change(await screen.findByLabelText("App Key"), { target: { value: "typed-key" } });
+ fireEvent.click(screen.getByText("EmailLabs (priority 1)"));
+ fireEvent.click(screen.getByRole("button", { name: "Save Email" }));
+ await waitFor(() =>
+ expect(
+ fetch.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === "PATCH"),
+ ).toBe(true),
+ );
+ const request = fetch.mock.calls.find(
+ (call) => (call[1] as RequestInit | undefined)?.method === "PATCH",
+ );
+ expect(JSON.parse(String((request?.[1] as RequestInit).body))).toEqual({
+ emailLabsAppKey: "typed-key",
+ });
+});
diff --git a/apps/web/src/components/admin/settings/EmailSettingsSection.tsx b/apps/web/src/components/admin/settings/EmailSettingsSection.tsx
new file mode 100644
index 000000000..251a25bf6
--- /dev/null
+++ b/apps/web/src/components/admin/settings/EmailSettingsSection.tsx
@@ -0,0 +1,88 @@
+"use client";
+import Box from "@mui/material/Box";
+import Chip from "@mui/material/Chip";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { type ReactNode, useEffect, useState } from "react";
+export type EmailProvider = "emaillabs" | "lettermint" | "smtp";
+export const EMAIL_PROVIDER_NAMES = {
+ emaillabs: "EmailLabs",
+ lettermint: "Lettermint",
+ smtp: "SMTP",
+};
+const present = (value: unknown) => typeof value === "string" && value.length > 0;
+export function configuredEmailProvider(
+ provider: string | undefined,
+ values: Record,
+) {
+ if (provider === "emaillabs")
+ return ["emailLabsAppKey", "emailLabsSecretKey", "emailLabsSmtpAccount"].every((key) =>
+ present(values[key]),
+ );
+ if (provider === "lettermint") return present(values.lettermintApiToken);
+ return provider === "smtp" && present(values.smtpHost);
+}
+/** Mirrors saved-field precedence, never a delivery-health claim or a provider switch. */
+export function savedEmailProvider(values: Record): EmailProvider {
+ return configuredEmailProvider("emaillabs", values)
+ ? "emaillabs"
+ : configuredEmailProvider("lettermint", values)
+ ? "lettermint"
+ : "smtp";
+}
+export function EmailSettingsSection({
+ provider,
+ label,
+ description,
+ focused,
+ selected,
+ configured,
+ children,
+}: {
+ provider?: string;
+ label?: string;
+ description?: string;
+ focused: boolean;
+ selected: boolean;
+ configured: boolean;
+ children: ReactNode;
+}) {
+ const [expanded, setExpanded] = useState(selected);
+ useEffect(() => {
+ if (focused) setExpanded(true);
+ }, [focused]);
+ if (!provider || !["emaillabs", "lettermint", "smtp"].includes(provider))
+ return {children};
+ return (
+ setExpanded((event.currentTarget as HTMLDetailsElement).open)}
+ sx={{ border: 1, borderColor: "divider", borderRadius: 1.5, p: 1.5 }}
+ >
+
+ {label}{" "}
+
+
+ {description && (
+
+ {description}
+
+ )}
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx b/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx
index 55b96930c..13a2dc713 100644
--- a/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx
+++ b/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx
@@ -1,7 +1,11 @@
import { QueryClientProvider } from "@tanstack/react-query";
+import { useContext } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { AdminDraftContext, AdminDraftProvider } from "@/lib/admin/AdminDraftProvider";
import { cleanup, createTestQueryClient, fireEvent, render, screen, waitFor } from "@/test";
import { PlatformConfiguration } from "./PlatformConfiguration";
+import { PlatformPreviewLifecycle } from "./PlatformPreviewLifecycle";
+import type { PlatformScope } from "./platformScope";
const mocks = vi.hoisted(() => ({ get: vi.fn(), preview: vi.fn(), apply: vi.fn(), job: vi.fn() }));
vi.mock("@openmapx/core", async () => ({
@@ -77,12 +81,12 @@ const preview = {
previewFingerprint: "b".repeat(64),
};
const caches: ReturnType[] = [];
-function mount() {
+function mount(scope: PlatformScope = "deployment") {
const cache = createTestQueryClient();
caches.push(cache);
return render(
-
+
,
);
}
@@ -107,7 +111,7 @@ afterEach(() => {
});
});
it("locks host fields until explicit adoption, previews without saving and binds apply to that preview", async () => {
- mount();
+ mount("retention");
const retention = await screen.findByLabelText("Backup retention (days)");
expect(retention).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Manage Backup retention (days) in UI" }));
@@ -129,6 +133,110 @@ it("locks host fields until explicit adoption, previews without saving and binds
});
expect(await screen.findByText("Job original-job")).toBeVisible();
});
+it("exposes only the fields owned by retention and refuses a hidden domain change", async () => {
+ mocks.preview.mockResolvedValue({
+ status: 200,
+ data: {
+ preview: { ...preview, values: { ...values, domain: "unexpected.example.test" } },
+ expiresAt: new Date(Date.now() + 600000).toISOString(),
+ },
+ });
+ mount("retention");
+ await screen.findByLabelText("Backup retention (days)");
+ expect(screen.queryByLabelText("Domain")).toBeNull();
+ expect(screen.queryByLabelText("Release selector")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Preview changes" }));
+ await screen.findByText(/Preview unavailable/);
+ expect(screen.queryByRole("button", { name: "Apply reviewed changes" })).toBeNull();
+});
+it("does not preview an empty adopted default region", async () => {
+ mount();
+ fireEvent.click(await screen.findByRole("button", { name: "Manage Default region in UI" }));
+ expect(screen.getByLabelText("Default region")).toHaveValue("");
+ expect(screen.getByRole("button", { name: "Preview changes" })).toBeDisabled();
+ expect(mocks.preview).not.toHaveBeenCalled();
+});
+it("does not start a platform write after leaving during the fresh-baseline read", async () => {
+ let resolve!: (value: unknown) => void;
+ let reads = 0;
+ mocks.get.mockImplementation(() =>
+ ++reads === 1
+ ? Promise.resolve({ status: 200, data: { ...initial, pending: [] } })
+ : new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ const view = mount("retention");
+ fireEvent.click(await screen.findByRole("button", { name: "Preview changes" }));
+ await waitFor(() => expect(mocks.get).toHaveBeenCalledTimes(2));
+ view.unmount();
+ resolve({ status: 200, data: { ...initial, pending: [] } });
+ await new Promise((done) => setTimeout(done, 0));
+ expect(mocks.preview).not.toHaveBeenCalled();
+});
+it("removes an old unscoped preview instead of loading it in a scoped owner", async () => {
+ const key = "openmapx-platform-preview:http://admin.test:admin-a";
+ sessionStorage.setItem(
+ key,
+ JSON.stringify({ preview, expiresAt: new Date(Date.now() + 600000).toISOString() }),
+ );
+ mount("retention");
+ await screen.findByLabelText("Backup retention (days)");
+ expect(sessionStorage.getItem(key)).toBeNull();
+ expect(screen.queryByText("Review platform changes")).toBeNull();
+});
+it("reuses the same apply intent after an unconfirmed transport outcome", async () => {
+ mocks.apply.mockRejectedValueOnce(Error("offline"));
+ mount();
+ await screen.findByLabelText("Domain");
+ fireEvent.click(screen.getByRole("button", { name: "Preview changes" }));
+ fireEvent.click(await screen.findByRole("button", { name: "Apply reviewed changes" }));
+ await screen.findByText(/Apply was not confirmed/);
+ fireEvent.click(screen.getByRole("button", { name: "Apply reviewed changes" }));
+ await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(2));
+ expect(mocks.apply.mock.calls[0][1]).toEqual(mocks.apply.mock.calls[1][1]);
+});
+it("copies the fresh full baseline before overlaying a retention edit", async () => {
+ const fresh = {
+ ...initial,
+ pending: [],
+ configuration: { version: 1, domain: { source: "database", value: "fresh.example.test" } },
+ values: { ...values, domain: "fresh.example.test" },
+ sources: { ...sources, domain: "database" },
+ };
+ let reads = 0;
+ mocks.get.mockImplementation(async () => ({
+ status: 200,
+ data: ++reads === 1 ? { ...initial, pending: [] } : fresh,
+ }));
+ mocks.preview.mockResolvedValue({
+ status: 200,
+ data: {
+ preview: {
+ ...preview,
+ ...fresh,
+ configuration: {
+ ...fresh.configuration,
+ backupRetentionDays: { source: "database", value: 60 },
+ },
+ oldOrigin: "https://fresh.example.test",
+ newOrigin: "https://fresh.example.test",
+ },
+ expiresAt: new Date(Date.now() + 600000).toISOString(),
+ },
+ });
+ mount("retention");
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Manage Backup retention (days) in UI" }),
+ );
+ fireEvent.change(screen.getByLabelText("Backup retention (days)"), { target: { value: "60" } });
+ fireEvent.click(screen.getByRole("button", { name: "Preview changes" }));
+ await screen.findByText("Review platform changes");
+ expect((mocks.preview.mock.calls[0][0] as { changes: unknown }).changes).toEqual({
+ ...fresh.configuration,
+ backupRetentionDays: { source: "database", value: 60 },
+ });
+});
it("invalidates a preview on edit and preserves the draft on conflict", async () => {
mocks.preview.mockResolvedValue({ status: 409, data: { error: "changed" } });
mount();
@@ -211,3 +319,77 @@ it("retains the exact unexpired actor-bound preview across the alternate-login r
expect(await screen.findByText("Review platform changes")).toBeVisible();
expect(mocks.preview).toHaveBeenCalledTimes(1);
});
+
+it.each([false, true])(
+ "clears retained platform previews on global discard while the form is hidden=%s",
+ async (hide) => {
+ function Discard() {
+ const store = useContext(AdminDraftContext);
+ return (
+ store?.discard()}>
+ Discard all
+
+ );
+ }
+ const cache = createTestQueryClient();
+ caches.push(cache);
+ const tree = (show: boolean) => (
+
+
+
+
+ {show && }
+
+
+ );
+ const view = render(tree(true));
+ fireEvent.click(await screen.findByRole("button", { name: "Preview changes" }));
+ await screen.findByText("Review platform changes");
+ const key = "openmapx-platform-preview:http://admin.test:admin-a:retention";
+ expect(sessionStorage.getItem(key)).not.toBeNull();
+ if (hide) view.rerender(tree(false));
+ fireEvent.click(screen.getByRole("button", { name: "Discard all" }));
+ expect(sessionStorage.getItem(key)).toBeNull();
+ if (hide) view.rerender(tree(true));
+ expect(screen.queryByRole("button", { name: "Apply reviewed changes" })).toBeNull();
+ },
+);
+it("does not revive a preview that arrives after global discard", async () => {
+ let resolve!: (value: unknown) => void;
+ mocks.preview.mockImplementation(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ function Discard() {
+ const store = useContext(AdminDraftContext);
+ return (
+ store?.discard()}>
+ Discard all
+
+ );
+ }
+ const cache = createTestQueryClient();
+ caches.push(cache);
+ render(
+
+
+
+
+
+ ,
+ );
+ fireEvent.click(await screen.findByRole("button", { name: "Preview changes" }));
+ await waitFor(() => expect(mocks.preview).toHaveBeenCalledTimes(1));
+ fireEvent.click(screen.getByRole("button", { name: "Discard all" }));
+ resolve({
+ status: 200,
+ data: { preview, expiresAt: new Date(Date.now() + 600000).toISOString() },
+ });
+ await new Promise((done) => setTimeout(done, 0));
+ expect(screen.queryByRole("button", { name: "Apply reviewed changes" })).toBeNull();
+ expect(
+ sessionStorage.getItem("openmapx-platform-preview:http://admin.test:admin-a:retention"),
+ ).toBeNull();
+});
diff --git a/apps/web/src/components/admin/settings/PlatformConfiguration.tsx b/apps/web/src/components/admin/settings/PlatformConfiguration.tsx
index 5d83eb015..8dd208ffd 100644
--- a/apps/web/src/components/admin/settings/PlatformConfiguration.tsx
+++ b/apps/web/src/components/admin/settings/PlatformConfiguration.tsx
@@ -1,5 +1,8 @@
"use client";
+import Accordion from "@mui/material/Accordion";
+import AccordionDetails from "@mui/material/AccordionDetails";
+import AccordionSummary from "@mui/material/AccordionSummary";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import Checkbox from "@mui/material/Checkbox";
@@ -16,21 +19,38 @@ import {
} from "@openmapx/core/api-admin-sdk";
import {
type AdminPlatformPreview,
+ adminPlatformPreviewInputSchema,
adminPlatformPreviewRetainedSchema,
} from "@openmapx/core/api-contracts";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import Link from "next/link";
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 { useAdminDraft } from "@/lib/admin/useAdminDraft";
+import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner";
+import {
+ activityJobQueryOptions,
+ hasUnresolvedJobObservation,
+ useActivityClient,
+} from "../activity/activityQueries";
import { JobDetail } from "../activity/JobDetail";
import { AdminReauthenticationDialog } from "../users/AdminReauthenticationDialog";
+import { usePlatformPreviewDiscard } from "./PlatformPreviewLifecycle";
+import {
+ buildPlatformCandidate,
+ PLATFORM_SCOPE_FIELDS,
+ PLATFORM_SCOPE_HREF,
+ type PlatformDraft,
+ type PlatformScope,
+ platformPreviewWithinScope,
+} from "./platformScope";
type View = Awaited> & { status: 200 };
type Configuration = AdminPlatformPreview["configuration"];
type Field = keyof AdminPlatformPreview["values"];
-const fields: { key: Field; label: string; help: string }[] = [
+const FIELD_DEFINITIONS: { key: Field; label: string; help: string }[] = [
{
key: "domain",
label: "Domain",
@@ -69,7 +89,13 @@ const emptyConfirmations = {
};
/** Effective ownership and retained job links stay available after a browser reconnect. */
-export function PlatformConfiguration() {
+class PlatformScopeError extends Error {}
+const TITLES = {
+ deployment: "Deployment configuration",
+ release: "Release selection",
+ retention: "Backup retention",
+};
+export function PlatformConfiguration({ scope = "deployment" }: { scope?: PlatformScope } = {}) {
const { actorId } = useAccountAdmin(),
{ client, apiUrl } = useActivityClient();
const query = useQuery({
@@ -80,8 +106,10 @@ export function PlatformConfiguration() {
queryFn: async () => unwrapAdminResponse(await getAdminPlatformConfiguration({ client })),
});
return (
-
- Platform configuration
+
+
+ {TITLES[scope]}
+
{query.isError && (
void query.refetch()}
/>
) : (
!query.isError && Loading platform configuration…
@@ -103,35 +133,96 @@ export function PlatformConfiguration() {
);
}
-function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string }) {
+function PlatformForm({
+ view,
+ actorId,
+ scope,
+ onReload,
+}: {
+ view: View["data"];
+ actorId: string;
+ scope: PlatformScope;
+ onReload: () => void;
+}) {
const register = useOptionalAdminJobRegistration();
const queryClient = useQueryClient();
const { client, apiUrl } = useActivityClient(),
- storageKey = `openmapx-platform-preview:${apiUrl}:${actorId}`;
+ storageKey = `openmapx-platform-preview:${apiUrl}:${actorId}:${scope}`;
+ const owner = useAdminMutationOwner(
+ client,
+ JSON.stringify([actorId, scope, view.expectedCurrentRevisionId]),
+ );
+ const scopeFields: readonly Field[] = PLATFORM_SCOPE_FIELDS[scope];
+ useEffect(() => {
+ const id = window.location.hash.slice(1);
+ if (
+ !(
+ (scope === "retention" && id === "backup-retention") ||
+ (scope === "release" && id === "release-selector")
+ )
+ )
+ return;
+ const timer = setTimeout(() => {
+ const field = document.getElementById(id);
+ field?.scrollIntoView?.({ block: "center" });
+ (
+ field?.querySelector("input:not(:disabled),button:not(:disabled)") ?? field
+ )?.focus();
+ }, 0);
+ return () => clearTimeout(timer);
+ }, [scope]);
const [retained, setRetained] = useState | null>(() => {
try {
+ sessionStorage.removeItem(`openmapx-platform-preview:${apiUrl}:${actorId}`);
const raw = sessionStorage.getItem(storageKey);
if (!raw) return null;
const parsed = adminPlatformPreviewRetainedSchema.parse(JSON.parse(raw));
- return parsed.preview.actorId === actorId &&
+ const valid =
+ parsed.preview.actorId === actorId &&
parsed.preview.expectedCurrentRevisionId === view.expectedCurrentRevisionId &&
- Date.parse(parsed.expiresAt) > Date.now()
- ? parsed
- : null;
+ Date.parse(parsed.expiresAt) > Date.now() &&
+ platformPreviewWithinScope(
+ parsed.preview,
+ view,
+ buildPlatformCandidate(view.configuration, parsed.preview.configuration, scope),
+ scope,
+ );
+ if (!valid) sessionStorage.removeItem(storageKey);
+ return valid ? parsed : null;
} catch {
return null;
}
});
- const [draft, setDraft] = useState(
- retained?.preview.configuration ?? view.configuration,
- ),
- [confirmations, setConfirmations] = useState(emptyConfirmations),
+ const ordinaryDraft = useAdminDraft({
+ id: `platform:${scope}`,
+ baseline: Object.fromEntries(scopeFields.map((key) => [key, view.configuration[key] ?? null])),
+ editableKeys: scopeFields,
+ signature: "platform-v1",
+ });
+ const draft = buildPlatformCandidate(
+ view.configuration,
+ ordinaryDraft.values as PlatformDraft,
+ scope,
+ );
+ const initialized = useRef(false);
+ useEffect(() => {
+ if (initialized.current) return;
+ initialized.current = true;
+ if (retained && !ordinaryDraft.dirty)
+ ordinaryDraft.setValues(
+ Object.fromEntries(
+ scopeFields.map((key) => [key, retained.preview.configuration[key] ?? null]),
+ ),
+ );
+ }, [retained, ordinaryDraft, scopeFields]);
+ const [confirmations, setConfirmations] = useState(emptyConfirmations),
[proof, setProof] = useState(),
[reauth, setReauth] = useState(false),
[accepted, setAccepted] = useState(),
[, tick] = useState(0);
+ const previewEpoch = useRef(0);
const previewKey = useRef(null),
applyKey = useRef(null);
const acceptedJob = useQuery({
@@ -140,34 +231,84 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string }
});
const observedTerminal =
!!acceptedJob.data?.finishedAt &&
- ["success", "failed", "canceled"].includes(acceptedJob.data.status);
+ ["success", "failed", "canceled"].includes(acceptedJob.data.status) &&
+ !hasUnresolvedJobObservation(acceptedJob.data.result);
const pending = view.pending ?? [],
busy = pending.length > 0 || (!!accepted && !observedTerminal);
const clearPreview = () => {
+ previewEpoch.current++;
setRetained(null);
setConfirmations(emptyConfirmations);
setProof(undefined);
setReauth(false);
- sessionStorage.removeItem(storageKey);
+ try {
+ sessionStorage.removeItem(storageKey);
+ } catch {
+ /* Storage is optional for local discard. */
+ }
previewKey.current = null;
applyKey.current = null;
};
+ const freshView = async () => {
+ let fresh: View["data"];
+ try {
+ fresh = unwrapAdminResponse(await getAdminPlatformConfiguration({ client }));
+ } catch {
+ throw new PlatformScopeError(
+ "Platform state could not be refreshed. Reload before reviewing changes.",
+ );
+ }
+ if (!owner.active)
+ throw new PlatformScopeError("The configuration view changed. Reopen it before continuing.");
+ if (fresh.expectedCurrentRevisionId !== view.expectedCurrentRevisionId)
+ throw new PlatformScopeError(
+ "Saved platform configuration changed. Reload and review your draft.",
+ );
+ if (fresh.pending.length)
+ throw new PlatformScopeError(
+ "An original platform operation is still pending. Inspect its outcome first.",
+ );
+ return fresh;
+ };
const previewMutation = useMutation({
retry: false,
mutationFn: async () => {
+ const epoch = previewEpoch.current;
+ const fresh = await freshView();
+ if (epoch !== previewEpoch.current)
+ throw new PlatformScopeError("Draft changed before the preview started.");
+ const edits = Object.fromEntries(
+ ordinaryDraft.dirtyKeys.map((key) => [key, ordinaryDraft.values[key]]),
+ ) as PlatformDraft;
+ const candidate = buildPlatformCandidate(fresh.configuration, edits, scope);
+ const parsed = adminPlatformPreviewInputSchema.safeParse({
+ expectedCurrentRevisionId: fresh.expectedCurrentRevisionId,
+ changes: candidate,
+ });
+ if (!parsed.success)
+ throw new PlatformScopeError(
+ "Complete the visible fields with valid values before previewing.",
+ );
previewKey.current ??= crypto.randomUUID();
- return unwrapAdminResponse(
+ const value = unwrapAdminResponse(
await previewAdminPlatformConfiguration(
- { expectedCurrentRevisionId: view.expectedCurrentRevisionId, changes: draft },
+ parsed.data,
{ "idempotency-key": previewKey.current },
{ client },
),
);
+ if (!platformPreviewWithinScope(value.preview, fresh, candidate, scope))
+ throw new PlatformScopeError(
+ "The preview changes a field owned by another topic. Reload before continuing.",
+ );
+ return { value, fresh, epoch };
},
onError: () => {
- previewKey.current = null;
+ if (owner.active) previewKey.current = null;
},
- onSuccess: (value) => {
+ onSuccess: ({ value, fresh, epoch }) => {
+ if (!owner.active || epoch !== previewEpoch.current) return;
+ queryClient.setQueryData(["admin", "platform", apiUrl, actorId], fresh);
setRetained(value);
sessionStorage.setItem(storageKey, JSON.stringify(value));
applyKey.current = null;
@@ -177,6 +318,16 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string }
retry: false,
mutationFn: async () => {
if (!retained || Date.parse(retained.expiresAt) <= Date.now()) throw Error("Preview expired");
+ const fresh = await freshView();
+ const candidate = buildPlatformCandidate(
+ fresh.configuration,
+ retained.preview.configuration,
+ scope,
+ );
+ if (!platformPreviewWithinScope(retained.preview, fresh, candidate, scope))
+ throw new PlatformScopeError(
+ "The preview no longer preserves another topic's configuration. Reload and review again.",
+ );
applyKey.current ??= crypto.randomUUID();
const accepted = await unwrapAdminResponse(
await applyAdminPlatformConfiguration(
@@ -196,16 +347,22 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string }
jobId: accepted.jobId,
label: "Platform configuration",
kind: "platform",
- returnTo: "/admin/system/deployment",
+ returnTo: PLATFORM_SCOPE_HREF[scope],
onSettled: () => queryClient.invalidateQueries({ queryKey: ["admin", "platform", apiUrl] }),
});
return accepted;
},
onSuccess: (value) => {
+ if (!owner.active) return;
setAccepted(value.jobId);
clearPreview();
},
});
+ usePlatformPreviewDiscard(scope, () => {
+ clearPreview();
+ previewMutation.reset();
+ applyMutation.reset();
+ });
useEffect(() => {
if (!retained) return;
const timeout = setTimeout(
@@ -219,80 +376,145 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string }
clearPreview();
previewMutation.reset();
applyMutation.reset();
- setDraft((old) => ({ ...old, [key]: value }));
+ ordinaryDraft.setValues((old) => ({ ...old, [key]: value }));
};
+ const validInput = adminPlatformPreviewInputSchema.safeParse({
+ expectedCurrentRevisionId: view.expectedCurrentRevisionId,
+ changes: draft,
+ }).success;
+ const previewInScope =
+ !retained ||
+ platformPreviewWithinScope(
+ retained.preview,
+ view,
+ buildPlatformCandidate(view.configuration, retained.preview.configuration, scope),
+ scope,
+ );
const preview = retained?.preview,
expired = !!retained && Date.parse(retained.expiresAt) <= Date.now();
return (
+ {scope === "deployment" && (
+
+ Release selector: {view.values.releaseSelector}
+
+ Manage release selection
+
+
+ )}
Host-owned values are locked until you explicitly adopt them. Returning a field to the host
uses its current environment value. Previewing does not save or restart services.
- {fields.map(({ key, label, help }) => {
- const field = draft[key],
- managed = field?.source === "database";
- const value = managed ? field.value : view.values[key];
- return (
-
-
- edit(key, {
- source: "database",
- value: key === "backupRetentionDays" ? Number(e.target.value) : e.target.value,
- } as Configuration[Field])
- }
- />
-
- edit(
- key,
- managed
- ? { source: "host" }
- : ({
- source: "database",
- value: view.values[key] ?? "",
- } as Configuration[Field]),
- )
+ {FIELD_DEFINITIONS.filter(({ key }) => scopeFields.includes(key)).map(
+ ({ key, label, help }) => {
+ const field = draft[key],
+ managed = field?.source === "database";
+ const value = managed ? field.value : view.values[key];
+ return (
+
- {managed ? `Return ${label} to host` : `Manage ${label} in UI`}
-
-
- );
- })}
+
+ edit(key, {
+ source: "database",
+ value: key === "backupRetentionDays" ? Number(e.target.value) : e.target.value,
+ } as Configuration[Field])
+ }
+ />
+
+ edit(
+ key,
+ managed
+ ? { source: "host" }
+ : ({
+ source: "database",
+ value: view.values[key] ?? "",
+ } as Configuration[Field]),
+ )
+ }
+ >
+ {managed ? `Return ${label} to host` : `Manage ${label} in UI`}
+
+
+ );
+ },
+ )}
+ {ordinaryDraft.conflicts.length > 0 && (
+ ordinaryDraft.keepEdits()}>Keep my edits}
+ >
+ Saved ownership changed for {ordinaryDraft.conflicts.join(", ")}. Review or discard your
+ edits before previewing.
+
+ )}
+ {!validInput && (
+
+ Complete the managed fields before previewing. A default region must name a valid,
+ nonempty region.
+
+ )}
+ {!previewInScope && (
+ Reload platform state}>
+ This preview no longer preserves configuration owned by another topic. Reload before
+ applying.
+
+ )}
{previewMutation.isError && (
-
+ Reload platform state}>
Preview unavailable. The revision, input or source may have changed. Reload before
- retrying; your draft is retained.
+ retrying; your draft is retained.{" "}
+ {previewMutation.error instanceof PlatformScopeError ? previewMutation.error.message : ""}
)}
{applyMutation.isError && (
-
- Apply was not confirmed. Inspect Activity before retrying with the same request. If the
- preview or policy changed, reload and review a new preview.
+ Reload platform state}>
+ {applyMutation.error instanceof PlatformScopeError
+ ? `Apply was not started. ${applyMutation.error.message}`
+ : "Apply was not confirmed. Inspect Activity before retrying with the same request. If the preview or policy changed, reload and review a new preview."}
)}
- previewMutation.mutate()}>
- Preview changes
-
+
+ 0}
+ onClick={() => previewMutation.mutate()}
+ >
+ Preview changes
+
+ {ordinaryDraft.dirty && (
+ {
+ clearPreview();
+ ordinaryDraft.discard();
+ previewMutation.reset();
+ applyMutation.reset();
+ }}
+ >
+ Discard
+
+ )}
+
{preview && (
Review platform changes
@@ -349,6 +571,7 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string }
working ||
busy ||
expired ||
+ !previewInScope ||
Object.entries(preview.requiredConfirmation).some(
([key, required]) => required && !confirmations[key as keyof typeof confirmations],
) ||
@@ -386,18 +609,25 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string }
),
)}
-
-
- Host recovery: inspect the retained operation in Activity and the host ops-agent journal. Do
- not delete locks or replay an uncertain update. Bootstrap secrets, Docker installation and
- offline agent recovery require host access.
-
+
+ Host recovery commands
+
+
+
+ Host recovery: inspect the retained operation in Activity and the host ops-agent
+ journal. Do not delete locks or replay an uncertain update. Bootstrap secrets, Docker
+ installation and offline agent recovery require host access.
+
+
+
);
}
diff --git a/apps/web/src/components/admin/settings/PlatformPreviewLifecycle.tsx b/apps/web/src/components/admin/settings/PlatformPreviewLifecycle.tsx
new file mode 100644
index 000000000..f5ed06e9e
--- /dev/null
+++ b/apps/web/src/components/admin/settings/PlatformPreviewLifecycle.tsx
@@ -0,0 +1,36 @@
+"use client";
+import { useContext, useEffect } from "react";
+import { AdminDraftContext } from "@/lib/admin/AdminDraftProvider";
+import { PLATFORM_SCOPE_FIELDS, type PlatformScope } from "./platformScope";
+
+const entryId = (scope: PlatformScope) => JSON.stringify([`platform:${scope}`, "platform-v1"]);
+/** Retained metadata belongs to its ordinary draft, including while its topic is closed. */
+export function PlatformPreviewLifecycle({ apiUrl, actorId }: { apiUrl: string; actorId: string }) {
+ const store = useContext(AdminDraftContext);
+ useEffect(
+ () =>
+ store?.subscribeDiscard(({ id, reason }) => {
+ if (reason === "reauthentication") return;
+ for (const scope of Object.keys(PLATFORM_SCOPE_FIELDS) as PlatformScope[])
+ if (!id || id === entryId(scope)) {
+ try {
+ sessionStorage.removeItem(`openmapx-platform-preview:${apiUrl}:${actorId}:${scope}`);
+ } catch {
+ /* A missing storage area cannot keep a local reviewed form alive. */
+ }
+ }
+ }),
+ [store, apiUrl, actorId],
+ );
+ return null;
+}
+export function usePlatformPreviewDiscard(scope: PlatformScope, discard: () => void) {
+ const store = useContext(AdminDraftContext);
+ useEffect(
+ () =>
+ store?.subscribeDiscard(({ id, reason }) => {
+ if (reason !== "reauthentication" && (!id || id === entryId(scope))) discard();
+ }),
+ [store, scope, discard],
+ );
+}
diff --git a/apps/web/src/components/admin/settings/SettingsWorkspace.test.tsx b/apps/web/src/components/admin/settings/SettingsWorkspace.test.tsx
index 5df52795c..5c9a5a250 100644
--- a/apps/web/src/components/admin/settings/SettingsWorkspace.test.tsx
+++ b/apps/web/src/components/admin/settings/SettingsWorkspace.test.tsx
@@ -80,7 +80,7 @@ it("migrates the legacy retention fragment to the field's current owner", async
const replace = vi.spyOn(adminTestHistory, "replace");
render();
await waitFor(() =>
- expect(replace).toHaveBeenCalledWith("/admin/system/deployment#backup-retention"),
+ expect(replace).toHaveBeenCalledWith("/admin/system/backups#backup-retention"),
);
window.history.replaceState(null, "", "/admin");
});
diff --git a/apps/web/src/components/admin/settings/SettingsWorkspace.tsx b/apps/web/src/components/admin/settings/SettingsWorkspace.tsx
index b3807b74c..8ddac6b98 100644
--- a/apps/web/src/components/admin/settings/SettingsWorkspace.tsx
+++ b/apps/web/src/components/admin/settings/SettingsWorkspace.tsx
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import type { SETTINGS_TOPICS } from "@/lib/admin/adminNavigation";
+import { useAdminNavigationAccess } from "../shared/AdminNavigationContext";
import { AdminPageHeader } from "../shared/AdminPageHeader";
import { SystemSettings } from "./SystemSettings";
@@ -19,6 +20,7 @@ const GROUPS = {
};
export function SettingsWorkspace({ topic }: { topic: (typeof SETTINGS_TOPICS)[number] }) {
const t = useTranslations("adminNavigation");
+ const { selfHosted } = useAdminNavigationAccess();
const params = useSearchParams();
const field = params.get("field");
return (
@@ -31,15 +33,18 @@ export function SettingsWorkspace({ topic }: { topic: (typeof SETTINGS_TOPICS)[n
- {t("destinations.deployment.label")}
-
+ selfHosted !== false ? (
+
+ {t("destinations.deployment.label")}
+
+ ) : undefined
}
>
{t("applicationUrlHelp")}
)}
{
router.replace(
window.location.hash === "#backup-retention"
- ? "/admin/system/deployment#backup-retention"
+ ? "/admin/system/backups#backup-retention"
: "/admin/settings/general",
);
}, [router]);
diff --git a/apps/web/src/components/admin/settings/SystemSettings.tsx b/apps/web/src/components/admin/settings/SystemSettings.tsx
index 437cfce92..fdfb68408 100644
--- a/apps/web/src/components/admin/settings/SystemSettings.tsx
+++ b/apps/web/src/components/admin/settings/SystemSettings.tsx
@@ -40,6 +40,12 @@ import { useActivityClient } from "../activity/activityQueries";
import { AdminPageHeader } from "../shared/AdminPageHeader";
import { AuthPolicySettings } from "./AuthPolicySettings";
import { ConfigurationMigrationPanel } from "./ConfigurationMigrationPanel";
+import {
+ configuredEmailProvider,
+ EMAIL_PROVIDER_NAMES,
+ EmailSettingsSection,
+ savedEmailProvider,
+} from "./EmailSettingsSection";
export interface ResolvedSetting {
group: string;
@@ -409,6 +415,7 @@ function SettingsGroupPanel({
anchorId,
defaultExpanded,
focusKey,
+ flat = false,
subgroupMeta = SUBGROUP_META,
text,
}: {
@@ -420,6 +427,7 @@ function SettingsGroupPanel({
anchorId?: string;
defaultExpanded?: boolean;
focusKey?: string;
+ flat?: boolean;
subgroupMeta?: Record;
text: SettingsEditorText;
}) {
@@ -502,6 +510,8 @@ function SettingsGroupPanel({
},
});
+ const emailGroup = group.settings.some((setting) => setting.group === "email");
+ const emailProvider = savedEmailProvider(serverValues);
const visibleSettings = group.settings.filter((s) => isVisible(s, renderedValues));
// Reflect only the settings the operator can actually see: a setting hidden
// by its showWhen predicate (e.g. the MapTiler key while the provider is
@@ -509,6 +519,201 @@ function SettingsGroupPanel({
// contradicts an otherwise-editable field.
const hasEnvOverrides = visibleSettings.some((s) => s.envOverride);
+ const contents = (
+ <>
+ {focusKey &&
+ group.settings.some(
+ (setting) => setting.key === focusKey && !isVisible(setting, renderedValues),
+ ) && (
+
+ This setting is hidden by the current policy or provider selection. Change that
+ selection only if you intend to change the effective configuration.
+
+ )}
+
+ {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.
+
+ )}
+ {emailGroup && (
+
+ Saved provider: {EMAIL_PROVIDER_NAMES[emailProvider]}. Priority: EmailLabs → Lettermint
+ → SMTP. Other configured providers remain available as fallbacks; send a test message to
+ check delivery.
+
+ )}
+ {groupBySubgroup(visibleSettings).map((section) => (
+ setting.key === focusKey)}
+ selected={emailGroup && section.subgroup === emailProvider}
+ configured={configuredEmailProvider(section.subgroup, serverValues)}
+ >
+ {(!emailGroup ||
+ !["emaillabs", "lettermint", "smtp"].includes(section.subgroup ?? "")) &&
+ section.subgroup &&
+ subgroupMeta[section.subgroup] && (
+
+
+ {subgroupMeta[section.subgroup].label}
+
+ {subgroupMeta[section.subgroup].description && (
+
+ {subgroupMeta[section.subgroup].description}
+
+ )}
+
+ )}
+
+ {section.settings.map((s) => (
+
+
+
+ s.secret
+ ? setSecretValues((previous) => ({ ...previous, [s.key]: v }))
+ : draft.setValues((previous) => ({ ...previous, [s.key]: v }))
+ }
+ onValidityChange={(valid) =>
+ setInvalidKeys((current) => {
+ const next = new Set(current);
+ if (valid) next.delete(s.key);
+ else next.add(s.key);
+ return next;
+ })
+ }
+ text={text}
+ />
+ {s.envOverride && s.envVar && (
+
+ )}
+
+ {s.source === "database" && !s.envOverride && (
+
+ {text.databaseSource}
+
+ )}
+
+ ))}
+
+
+ ))}
+
+
+ :
+ }
+ 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 && (
+ {
+ draft.discard(group.settings.map((setting) => setting.key));
+ setSecretValues({});
+ setInvalidKeys(new Set());
+ }}
+ >
+ Discard
+
+ )}
+ {extra}
+ {save.isError && (
+
+ {text.saveFailed}
+
+ )}
+
+
+ >
+ );
+ if (flat)
+ return (
+
+ {contents}
+
+ );
return (
- {focusKey &&
- group.settings.some(
- (setting) => setting.key === focusKey && !isVisible(setting, renderedValues),
- ) && (
-
- This setting is hidden by the current policy or provider selection. Change that
- selection only if you intend to change the effective configuration.
-
- )}
-
- {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) => (
-
- {section.subgroup && subgroupMeta[section.subgroup] && (
-
-
- {subgroupMeta[section.subgroup].label}
-
- {subgroupMeta[section.subgroup].description && (
-
- {subgroupMeta[section.subgroup].description}
-
- )}
-
- )}
-
- {section.settings.map((s) => (
-
-
-
- s.secret
- ? setSecretValues((previous) => ({ ...previous, [s.key]: v }))
- : draft.setValues((previous) => ({ ...previous, [s.key]: v }))
- }
- onValidityChange={(valid) =>
- setInvalidKeys((current) => {
- const next = new Set(current);
- if (valid) next.delete(s.key);
- else next.add(s.key);
- return next;
- })
- }
- text={text}
- />
- {s.envOverride && s.envVar && (
-
- )}
-
- {s.source === "database" && !s.envOverride && (
-
- {text.databaseSource}
-
- )}
-
- ))}
-
-
- ))}
-
-
- :
- }
- 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 && (
- {
- draft.discard(group.settings.map((setting) => setting.key));
- setSecretValues({});
- setInvalidKeys(new Set());
- }}
- >
- Discard
-
- )}
- {extra}
- {save.isError && (
-
- {text.saveFailed}
-
- )}
-
-
+ {contents}
);
@@ -956,6 +990,7 @@ export function SystemSettings({
text = DEFAULT_TEXT,
onSettingsSaved,
focusKey,
+ flatSections = false,
}: {
sections?: readonly SettingsEditorSection[];
showHeader?: boolean;
@@ -965,6 +1000,7 @@ export function SystemSettings({
text?: SettingsEditorText;
onSettingsSaved?: () => void | Promise;
focusKey?: string;
+ flatSections?: boolean;
} = {}) {
const env = useEnv();
const draftStore = useContext(AdminDraftContext);
@@ -1082,6 +1118,7 @@ export function SystemSettings({
description={section?.description}
defaultExpanded={section?.defaultExpanded}
focusKey={focusKey}
+ flat={flatSections}
subgroupMeta={subgroupMeta}
text={text}
extra={
diff --git a/apps/web/src/components/admin/settings/platformScope.test.ts b/apps/web/src/components/admin/settings/platformScope.test.ts
new file mode 100644
index 000000000..27c84e64c
--- /dev/null
+++ b/apps/web/src/components/admin/settings/platformScope.test.ts
@@ -0,0 +1,86 @@
+import type { AdminPlatformPreview } from "@openmapx/core/api-contracts";
+import { expect, it } from "vitest";
+import { buildPlatformCandidate, platformPreviewWithinScope } from "./platformScope";
+
+const baseline = {
+ configuration: { version: 1, domain: { source: "database", value: "maps.example.test" } },
+ values: {
+ domain: "maps.example.test",
+ acmeEmail: "admin@example.test",
+ releaseSelector: "main",
+ defaultRegion: null,
+ backupRetentionDays: 30,
+ },
+ sources: {
+ domain: "database",
+ acmeEmail: "host",
+ releaseSelector: "host",
+ defaultRegion: "host",
+ backupRetentionDays: "host",
+ },
+} as const;
+const candidate = buildPlatformCandidate(
+ baseline.configuration,
+ {
+ backupRetentionDays: { source: "database", value: 60 },
+ domain: { source: "database", value: "evil.test" },
+ },
+ "retention",
+);
+const preview = {
+ ...baseline,
+ configuration: candidate,
+ values: { ...baseline.values, backupRetentionDays: 60 },
+ sources: { ...baseline.sources, backupRetentionDays: "database" },
+ oldOrigin: "https://maps.example.test",
+ newOrigin: "https://maps.example.test",
+} as AdminPlatformPreview;
+it("overlays only a scope's edited keys onto the freshly captured full configuration", () => {
+ expect(candidate).toEqual({
+ ...baseline.configuration,
+ backupRetentionDays: { source: "database", value: 60 },
+ });
+ expect(Object.hasOwn(candidate, "defaultRegion")).toBe(false);
+ expect(platformPreviewWithinScope(preview, baseline, candidate, "retention")).toBe(true);
+});
+it.each(["configuration", "values", "sources"] as const)(
+ "refuses hidden-field changes in %s",
+ (part) => {
+ const changed = structuredClone(preview);
+ if (part === "configuration") changed.configuration.domain = { source: "host" };
+ if (part === "values") changed.values.domain = "other.example.test";
+ if (part === "sources") changed.sources.domain = "host";
+ expect(platformPreviewWithinScope(changed, baseline, candidate, "retention")).toBe(false);
+ },
+);
+it("refuses an unrelated origin change or release selection in retention", () => {
+ expect(
+ platformPreviewWithinScope(
+ { ...preview, newOrigin: "https://other.test" },
+ baseline,
+ candidate,
+ "retention",
+ ),
+ ).toBe(false);
+ expect(
+ platformPreviewWithinScope(
+ { ...preview, release: {} } as AdminPlatformPreview,
+ baseline,
+ candidate,
+ "retention",
+ ),
+ ).toBe(false);
+});
+it("keeps omitted fields omitted and supports explicitly returning only retention to the host", () => {
+ expect(
+ buildPlatformCandidate(
+ { ...candidate, defaultRegion: { source: "database", value: "europe/germany" } },
+ { backupRetentionDays: { source: "host" } },
+ "retention",
+ ),
+ ).toEqual({
+ ...baseline.configuration,
+ defaultRegion: { source: "database", value: "europe/germany" },
+ backupRetentionDays: { source: "host" },
+ });
+});
diff --git a/apps/web/src/components/admin/settings/platformScope.ts b/apps/web/src/components/admin/settings/platformScope.ts
new file mode 100644
index 000000000..0cf32159c
--- /dev/null
+++ b/apps/web/src/components/admin/settings/platformScope.ts
@@ -0,0 +1,50 @@
+import type { AdminPlatformPreview } from "@openmapx/core/api-contracts";
+export type PlatformScope = "deployment" | "release" | "retention";
+export type PlatformFieldName = keyof AdminPlatformPreview["values"];
+export type PlatformConfigurationValue = AdminPlatformPreview["configuration"];
+export type PlatformDraft = Partial<
+ Record
+>;
+export const PLATFORM_SCOPE_FIELDS = {
+ deployment: ["domain", "acmeEmail", "defaultRegion"],
+ release: ["releaseSelector"],
+ retention: ["backupRetentionDays"],
+} as const satisfies Record;
+export const PLATFORM_SCOPE_HREF = {
+ deployment: "/admin/system/deployment",
+ release: "/admin/system/updates",
+ retention: "/admin/system/backups",
+} as const;
+export function buildPlatformCandidate(
+ baseline: PlatformConfigurationValue,
+ edits: PlatformDraft,
+ scope: PlatformScope,
+): PlatformConfigurationValue {
+ const candidate = { ...baseline };
+ for (const key of PLATFORM_SCOPE_FIELDS[scope])
+ if (Object.hasOwn(edits, key) && edits[key] != null)
+ Object.assign(candidate, { [key]: edits[key] });
+ return candidate;
+}
+const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
+/** Every hidden ownership/value remains exactly as captured, even if the server normalizes input. */
+export function platformPreviewWithinScope(
+ preview: AdminPlatformPreview,
+ baseline: Pick,
+ candidate: PlatformConfigurationValue,
+ scope: PlatformScope,
+): boolean {
+ const fields: readonly PlatformFieldName[] = PLATFORM_SCOPE_FIELDS[scope];
+ for (const key of Object.keys(baseline.values) as PlatformFieldName[]) {
+ if (fields.includes(key)) continue;
+ if (
+ !equal(preview.configuration[key], candidate[key]) ||
+ !equal(preview.values[key], baseline.values[key]) ||
+ !equal(preview.sources[key], baseline.sources[key])
+ )
+ return false;
+ }
+ if (scope !== "deployment" && preview.oldOrigin !== preview.newOrigin) return false;
+ if (scope !== "release" && preview.release !== undefined) return false;
+ return true;
+}
diff --git a/apps/web/src/components/admin/shared/AdminNavigationContext.tsx b/apps/web/src/components/admin/shared/AdminNavigationContext.tsx
new file mode 100644
index 000000000..f3e02c695
--- /dev/null
+++ b/apps/web/src/components/admin/shared/AdminNavigationContext.tsx
@@ -0,0 +1,9 @@
+"use client";
+import { createContext, useContext } from "react";
+import type { AdminNavigationAccess } from "@/lib/admin/adminNavigation";
+/** Server-resolved presentation availability; API guards still authorize every operation. */
+export const AdminNavigationContext = createContext({
+ role: "admin",
+ selfHosted: null,
+});
+export const useAdminNavigationAccess = () => useContext(AdminNavigationContext);
diff --git a/apps/web/src/components/admin/shared/AdminPageHeader.tsx b/apps/web/src/components/admin/shared/AdminPageHeader.tsx
index 9fea6a04b..04dfb1289 100644
--- a/apps/web/src/components/admin/shared/AdminPageHeader.tsx
+++ b/apps/web/src/components/admin/shared/AdminPageHeader.tsx
@@ -10,6 +10,7 @@ interface AdminPageHeaderProps {
subtitle?: ReactNode;
/** Right-aligned action(s): buttons, refresh icon, status chip. */
actions?: ReactNode;
+ badges?: ReactNode;
/** Optional parent route for detail and workflow pages. */
backHref?: string;
backLabel?: string;
@@ -19,6 +20,7 @@ export function AdminPageHeader({
title,
subtitle,
actions,
+ badges,
backHref,
backLabel = "Back",
}: AdminPageHeaderProps) {
@@ -40,9 +42,12 @@ export function AdminPageHeader({
)}
-
- {title}
-
+
+
+ {title}
+
+ {badges}
+
{subtitle && (
{subtitle}
diff --git a/apps/web/src/components/admin/shared/AdminThemeProvider.tsx b/apps/web/src/components/admin/shared/AdminThemeProvider.tsx
index b8e4b0288..6062dc808 100644
--- a/apps/web/src/components/admin/shared/AdminThemeProvider.tsx
+++ b/apps/web/src/components/admin/shared/AdminThemeProvider.tsx
@@ -1,5 +1,6 @@
"use client";
+import GlobalStyles from "@mui/material/GlobalStyles";
import { alpha, createTheme, type Theme, ThemeProvider, useTheme } from "@mui/material/styles";
import { type ReactNode, useMemo } from "react";
@@ -29,7 +30,12 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
MuiButton: {
defaultProps: { size: "small", disableElevation: true },
styleOverrides: {
- root: { minHeight: 34, borderRadius: 8, paddingInline: 12 },
+ root: {
+ minHeight: 34,
+ borderRadius: 8,
+ paddingInline: 12,
+ "@media (pointer: coarse)": { minHeight: 44 },
+ },
},
},
MuiCard: {
@@ -54,7 +60,29 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
},
},
MuiDialog: {
- styleOverrides: { paper: { borderRadius: 16 } },
+ styleOverrides: {
+ paper: {
+ borderRadius: 16,
+ "@media (prefers-reduced-motion: reduce)": {
+ transition: "none !important",
+ animation: "none !important",
+ },
+ },
+ root: {
+ "@media (prefers-reduced-motion: reduce)": {
+ "& *": { transition: "none !important", animation: "none !important" },
+ },
+ },
+ },
+ },
+ MuiPopover: {
+ styleOverrides: {
+ root: {
+ "@media (prefers-reduced-motion: reduce)": {
+ "& *": { transition: "none !important", animation: "none !important" },
+ },
+ },
+ },
},
MuiDialogActions: {
styleOverrides: { root: { padding: "8px 20px 16px" } },
@@ -66,9 +94,30 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
styleOverrides: { root: { padding: "18px 20px 8px", fontSize: "1.125rem" } },
},
MuiFormControl: { defaultProps: { size: "small" } },
+ MuiFormControlLabel: {
+ styleOverrides: { root: { "@media (pointer: coarse)": { minHeight: 44 } } },
+ },
+ MuiInputBase: {
+ styleOverrides: { root: { "@media (pointer: coarse)": { minHeight: 44 } } },
+ },
+ MuiListItemButton: {
+ styleOverrides: { root: { "@media (pointer: coarse)": { minHeight: 44 } } },
+ },
+ MuiDrawer: {
+ styleOverrides: {
+ paper: {
+ "@media (prefers-reduced-motion: reduce)": { transition: "none !important" },
+ },
+ },
+ },
MuiIconButton: {
defaultProps: { size: "small" },
- styleOverrides: { root: { borderRadius: 8 } },
+ styleOverrides: {
+ root: {
+ borderRadius: 8,
+ "@media (pointer: coarse)": { minHeight: 44, minWidth: 44 },
+ },
+ },
},
MuiPaper: {
defaultProps: { elevation: 0 },
@@ -153,5 +202,20 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
[parentTheme],
);
- return {children};
+ return (
+
+
+ {children}
+
+ );
}
diff --git a/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx b/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx
index 20646d045..83f9a6749 100644
--- a/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx
+++ b/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx
@@ -39,10 +39,16 @@ export function AdminTopicNavigation({
size="small"
fullWidth
label={t("topic")}
- value={entries.find((entry) => entry.id === active)?.href ?? entries[0].href}
+ slotProps={{ select: { displayEmpty: true }, inputLabel: { shrink: true } }}
+ value={entries.find((entry) => entry.id === active)?.href ?? ""}
onChange={(event) => router.push(event.target.value)}
sx={{ display: { lg: "none" }, mb: 1 }}
>
+ {!entries.some((entry) => entry.id === active) && (
+
+ )}
{entries.map((entry) => (