From dc53192db2f821e74d29b133b72c627b27a8bcfd Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 12:55:21 +0100 Subject: [PATCH 1/3] feat(admin): organize workspaces, settings topics and navigation search --- .../app/admin/(full-admin)/aliases.test.tsx | 30 + .../src/app/admin/(full-admin)/cache/page.tsx | 15 +- .../app/admin/(full-admin)/coverage/page.tsx | 15 +- .../src/app/admin/(full-admin)/data/page.tsx | 10 + .../admin/(full-admin)/data/transit/page.tsx | 6 + .../privacy-setup/PrivacySetupPage.test.tsx | 8 +- .../privacy-setup/PrivacySetupPage.tsx | 26 +- .../admin/(full-admin)/services/[id]/page.tsx | 22 +- .../(full-admin)/services/backups/page.tsx | 15 +- .../(full-admin)/services/compose/page.tsx | 15 +- .../admin/(full-admin)/services/data/page.tsx | 15 +- .../app/admin/(full-admin)/services/page.tsx | 15 +- .../(full-admin)/settings/[topic]/page.tsx | 9 + .../app/admin/(full-admin)/settings/page.tsx | 17 +- .../(full-admin)/system/backups/page.tsx | 6 + .../admin/(full-admin)/system/cache/page.tsx | 6 + .../system/configuration-transfer/page.tsx | 4 + .../(full-admin)/system/deployment/page.tsx | 11 + .../(full-admin)/system/diagnostics/page.tsx | 4 + .../app/admin/(full-admin)/system/layout.tsx | 4 + .../app/admin/(full-admin)/system/page.tsx | 17 +- .../system/services/[id]/page.tsx | 16 + .../(full-admin)/system/services/page.tsx | 6 + .../(full-admin)/system/updates/page.tsx | 4 + .../app/admin/(full-admin)/transit/page.tsx | 15 +- .../components/admin/AdminDraftSummary.tsx | 18 + apps/web/src/components/admin/AdminLayout.tsx | 39 +- .../src/components/admin/AdminSearch.test.tsx | 117 ++++ apps/web/src/components/admin/AdminSearch.tsx | 256 ++++++++ .../components/admin/AdminSidebar.test.tsx | 12 +- .../web/src/components/admin/AdminSidebar.tsx | 406 ++----------- apps/web/src/components/admin/AdminTopBar.tsx | 83 +-- .../admin/overview/AdminOverview.tsx | 4 +- .../privacy/PrivacyReadinessChecklist.tsx | 10 +- .../admin/services/BackupPruneControls.tsx | 2 +- .../admin/services/DataWorkflowsPage.tsx | 30 +- .../admin/services/ServiceCatalog.tsx | 2 +- .../admin/services/ServiceCredentials.tsx | 5 +- .../admin/services/ServiceDetail.tsx | 2 +- .../__tests__/BackupPruneControls.test.tsx | 2 +- .../settings/AuthPolicySettings.test.tsx | 9 +- .../admin/settings/AuthPolicySettings.tsx | 8 +- .../admin/settings/PlatformConfiguration.tsx | 10 +- .../admin/settings/SettingsWorkspace.test.tsx | 86 +++ .../admin/settings/SettingsWorkspace.tsx | 75 +++ .../admin/settings/SystemSettings.tsx | 60 +- .../admin/shared/AdminTopicNavigation.tsx | 74 +++ .../admin/shared/HostedDataTopics.tsx | 24 + .../SystemMaintenance.behavior.test.tsx | 32 +- .../SystemMaintenance.navigation.test.tsx | 105 ++++ .../admin/system/SystemMaintenance.tsx | 566 +++++++++++------- .../admin}/transit/CurrentJobCard.tsx | 0 .../admin}/transit/FeedsBreakdownChart.tsx | 0 .../admin}/transit/FeedsTable.tsx | 0 .../admin}/transit/LockfileCard.tsx | 0 .../admin}/transit/ProviderHealthTable.tsx | 0 .../admin}/transit/RecentJobsTable.tsx | 0 .../admin}/transit/TransitPipelinePage.tsx | 0 .../transit/__tests__/FeedsTable.test.tsx | 0 .../transit/__tests__/LockfileCard.test.tsx | 0 .../__tests__/ProviderHealthTable.test.tsx | 0 .../admin}/transit/__tests__/page.test.tsx | 0 .../AdminReauthenticationDialog.test.tsx | 25 + .../src/lib/admin/AdminDraftProvider.test.tsx | 37 +- apps/web/src/lib/admin/AdminDraftProvider.tsx | 9 + apps/web/src/lib/admin/adminDraftStore.ts | 1 + apps/web/src/lib/admin/adminJobNavigation.ts | 63 +- .../web/src/lib/admin/adminNavigation.test.ts | 96 +++ apps/web/src/lib/admin/adminNavigation.ts | 307 ++++++++++ .../web/src/lib/admin/adminNavigationState.ts | 9 + .../src/lib/admin/adminReauthentication.ts | 16 +- apps/web/src/lib/admin/adminSearch.test.ts | 60 ++ apps/web/src/lib/admin/adminSearch.ts | 143 +++++ apps/web/src/lib/admin/serviceAdmin.ts | 2 +- apps/web/src/lib/admin/useAdminFieldFocus.ts | 17 + docs/docs/administration/admin-panel.md | 27 +- .../docs/administration/coverage-freshness.md | 2 +- docs/docs/administration/monitoring.md | 6 +- .../administration/services-administration.md | 8 +- packages/i18n/locales/de.json | 127 +++- packages/i18n/locales/en.json | 127 +++- 81 files changed, 2594 insertions(+), 836 deletions(-) create mode 100644 apps/web/src/app/admin/(full-admin)/aliases.test.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/data/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/data/transit/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/settings/[topic]/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/system/backups/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/system/cache/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/system/configuration-transfer/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/system/deployment/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/system/diagnostics/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/system/layout.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/system/services/[id]/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/system/services/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/system/updates/page.tsx create mode 100644 apps/web/src/components/admin/AdminDraftSummary.tsx create mode 100644 apps/web/src/components/admin/AdminSearch.test.tsx create mode 100644 apps/web/src/components/admin/AdminSearch.tsx create mode 100644 apps/web/src/components/admin/settings/SettingsWorkspace.test.tsx create mode 100644 apps/web/src/components/admin/settings/SettingsWorkspace.tsx create mode 100644 apps/web/src/components/admin/shared/AdminTopicNavigation.tsx create mode 100644 apps/web/src/components/admin/shared/HostedDataTopics.tsx create mode 100644 apps/web/src/components/admin/system/SystemMaintenance.navigation.test.tsx rename apps/web/src/{app/admin/(full-admin) => components/admin}/transit/CurrentJobCard.tsx (100%) rename apps/web/src/{app/admin/(full-admin) => components/admin}/transit/FeedsBreakdownChart.tsx (100%) rename apps/web/src/{app/admin/(full-admin) => components/admin}/transit/FeedsTable.tsx (100%) rename apps/web/src/{app/admin/(full-admin) => components/admin}/transit/LockfileCard.tsx (100%) rename apps/web/src/{app/admin/(full-admin) => components/admin}/transit/ProviderHealthTable.tsx (100%) rename apps/web/src/{app/admin/(full-admin) => components/admin}/transit/RecentJobsTable.tsx (100%) rename apps/web/src/{app/admin/(full-admin) => components/admin}/transit/TransitPipelinePage.tsx (100%) rename apps/web/src/{app/admin/(full-admin) => components/admin}/transit/__tests__/FeedsTable.test.tsx (100%) rename apps/web/src/{app/admin/(full-admin) => components/admin}/transit/__tests__/LockfileCard.test.tsx (100%) rename apps/web/src/{app/admin/(full-admin) => components/admin}/transit/__tests__/ProviderHealthTable.test.tsx (100%) rename apps/web/src/{app/admin/(full-admin) => components/admin}/transit/__tests__/page.test.tsx (100%) create mode 100644 apps/web/src/lib/admin/adminNavigation.test.ts create mode 100644 apps/web/src/lib/admin/adminNavigation.ts create mode 100644 apps/web/src/lib/admin/adminSearch.test.ts create mode 100644 apps/web/src/lib/admin/adminSearch.ts create mode 100644 apps/web/src/lib/admin/useAdminFieldFocus.ts diff --git a/apps/web/src/app/admin/(full-admin)/aliases.test.tsx b/apps/web/src/app/admin/(full-admin)/aliases.test.tsx new file mode 100644 index 000000000..788a7ef75 --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/aliases.test.tsx @@ -0,0 +1,30 @@ +import { expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ redirect: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: mocks.redirect })); +it.each([ + [() => import("./services/page"), "/admin/system/services"], + [() => import("./services/compose/page"), "/admin/system/deployment"], + [() => import("./services/backups/page"), "/admin/system/backups"], + [() => import("./services/data/page"), "/admin/data"], + [() => import("./transit/page"), "/admin/data/transit"], + [() => import("./coverage/page"), "/admin/data/coverage"], + [() => import("./cache/page"), "/admin/system/cache"], +] as const)( + "preserves supported navigation state through the legacy route", + async (load, target) => { + const { default: Page } = await load(); + await Page({ + searchParams: Promise.resolve({ tab: "credentials", page: "2", token: "private" }), + }); + expect(mocks.redirect).toHaveBeenLastCalledWith(`${target}?tab=credentials&page=2`); + }, +); +it("encodes service IDs while redirecting details", async () => { + const { default: Page } = await import("./services/[id]/page"); + await Page({ + params: Promise.resolve({ id: "service id" }), + searchParams: Promise.resolve({ tab: "logs" }), + }); + expect(mocks.redirect).toHaveBeenLastCalledWith("/admin/system/services/service%20id?tab=logs"); +}); diff --git a/apps/web/src/app/admin/(full-admin)/cache/page.tsx b/apps/web/src/app/admin/(full-admin)/cache/page.tsx index 45dc5e11b..a8cc54934 100644 --- a/apps/web/src/app/admin/(full-admin)/cache/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/cache/page.tsx @@ -1,8 +1,9 @@ -import type { Metadata } from "next"; -import { CacheManager } from "@/components/admin/cache/CacheManager"; - -export const metadata: Metadata = { title: "Cache — Admin — OpenMapX" }; - -export default function AdminCachePage() { - return ; +import { redirect } from "next/navigation"; +import { legacyAdminHref } from "@/lib/admin/adminNavigation"; +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + redirect(legacyAdminHref("/admin/cache", await searchParams)); } diff --git a/apps/web/src/app/admin/(full-admin)/coverage/page.tsx b/apps/web/src/app/admin/(full-admin)/coverage/page.tsx index 64914cc31..7174d3a92 100644 --- a/apps/web/src/app/admin/(full-admin)/coverage/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/coverage/page.tsx @@ -1,8 +1,9 @@ -import type { Metadata } from "next"; -import { CoveragePage } from "@/components/admin/coverage/CoveragePage"; - -export const metadata: Metadata = { title: "Coverage & freshness — Admin — OpenMapX" }; - -export default function AdminCoveragePage() { - return ; +import { redirect } from "next/navigation"; +import { legacyAdminHref } from "@/lib/admin/adminNavigation"; +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + redirect(legacyAdminHref("/admin/coverage", await searchParams)); } diff --git a/apps/web/src/app/admin/(full-admin)/data/page.tsx b/apps/web/src/app/admin/(full-admin)/data/page.tsx new file mode 100644 index 000000000..fbe14df87 --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/data/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { DataWorkflowsPage } from "@/components/admin/services/DataWorkflowsPage"; +import { HostedDataTopics } from "@/components/admin/shared/HostedDataTopics"; +import { getSelfHosted } from "@/lib/admin/serverDeployment"; +export const metadata: Metadata = { title: "Data workflows — Admin — OpenMapX" }; +export default async function Page() { + const selfHosted = await getSelfHosted((await cookies()).toString()); + return selfHosted === false ? : ; +} diff --git a/apps/web/src/app/admin/(full-admin)/data/transit/page.tsx b/apps/web/src/app/admin/(full-admin)/data/transit/page.tsx new file mode 100644 index 000000000..40c13d6fa --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/data/transit/page.tsx @@ -0,0 +1,6 @@ +import type { Metadata } from "next"; +import { TransitPipelinePage } from "@/components/admin/transit/TransitPipelinePage"; +export const metadata: Metadata = { title: "Transit — Admin — OpenMapX" }; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/admin/(full-admin)/privacy-setup/PrivacySetupPage.test.tsx b/apps/web/src/app/admin/(full-admin)/privacy-setup/PrivacySetupPage.test.tsx index 9e6dcc591..b2533af32 100644 --- a/apps/web/src/app/admin/(full-admin)/privacy-setup/PrivacySetupPage.test.tsx +++ b/apps/web/src/app/admin/(full-admin)/privacy-setup/PrivacySetupPage.test.tsx @@ -122,9 +122,11 @@ it("groups setup fields, preserves environment locks, and links failed readiness screen.getByRole("heading", { name: /privacySetup.steps.retention.title/ }), ).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: /privacySetup.steps.notifications.title/ })); - const fromAddress = await screen.findByLabelText("privacySetup.settings.smtpFromAddress.label"); - expect(fromAddress).toBeDisabled(); + expect(screen.queryByLabelText("privacySetup.settings.smtpFromAddress.label")).toBeNull(); + expect(screen.getByRole("link", { name: "privacySetup.openEmailSettings" })).toHaveAttribute( + "href", + "/admin/settings/email", + ); expect( await screen.findByRole("link", { diff --git a/apps/web/src/app/admin/(full-admin)/privacy-setup/PrivacySetupPage.tsx b/apps/web/src/app/admin/(full-admin)/privacy-setup/PrivacySetupPage.tsx index 19323f81b..224e9d6df 100644 --- a/apps/web/src/app/admin/(full-admin)/privacy-setup/PrivacySetupPage.tsx +++ b/apps/web/src/app/admin/(full-admin)/privacy-setup/PrivacySetupPage.tsx @@ -10,6 +10,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useMemo } from "react"; import { PrivacyApprovalForm } from "@/components/admin/privacy/PrivacyApprovalForm"; @@ -31,6 +32,7 @@ const STEP_IDS = ["operator", "notifications", "retention", "reviews"] as const; export function PrivacySetupPage() { const t = useTranslations("privacySetup"); const env = useEnv(); + const field = useSearchParams()?.get("field"); const sections = useMemo( () => [ { @@ -41,13 +43,6 @@ export function PrivacySetupPage() { description: t("steps.operator.description"), defaultExpanded: true, }, - { - id: "notifications", - groupId: "email", - label: t("steps.notifications.title"), - description: t("steps.notifications.description"), - includeTestEmail: true, - }, { id: "retention", groupId: "legal", @@ -131,15 +126,28 @@ export function PrivacySetupPage() { - {STEP_IDS.map((id, index) => ( + {STEP_IDS.map((id) => ( ))} + + + {t("steps.notifications.title")} + + + {t("notificationsOwner")} + + + + ; -} - -export async function generateMetadata({ params }: Props): Promise { - const { id } = await params; - return { title: `${id} — Services — Admin — OpenMapX` }; -} - -export default async function AdminServiceDetailPage({ params }: Props) { + searchParams: Promise>; +}) { const { id } = await params; - return ; + redirect(legacyAdminHref(`/admin/services/${encodeURIComponent(id)}`, await searchParams)); } diff --git a/apps/web/src/app/admin/(full-admin)/services/backups/page.tsx b/apps/web/src/app/admin/(full-admin)/services/backups/page.tsx index cb72bd082..fab1b733e 100644 --- a/apps/web/src/app/admin/(full-admin)/services/backups/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/services/backups/page.tsx @@ -1,8 +1,9 @@ -import type { Metadata } from "next"; -import { BackupsPage } from "@/components/admin/services/BackupsPage"; - -export const metadata: Metadata = { title: "Backups — Admin — OpenMapX" }; - -export default function AdminBackupsPage() { - return ; +import { redirect } from "next/navigation"; +import { legacyAdminHref } from "@/lib/admin/adminNavigation"; +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + redirect(legacyAdminHref("/admin/services/backups", await searchParams)); } diff --git a/apps/web/src/app/admin/(full-admin)/services/compose/page.tsx b/apps/web/src/app/admin/(full-admin)/services/compose/page.tsx index af370d8e9..8e4fc0e95 100644 --- a/apps/web/src/app/admin/(full-admin)/services/compose/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/services/compose/page.tsx @@ -1,8 +1,9 @@ -import type { Metadata } from "next"; -import { ComposePreview } from "@/components/admin/services/ComposePreview"; - -export const metadata: Metadata = { title: "Compose Preview — Admin — OpenMapX" }; - -export default function ComposePreviewPage() { - return ; +import { redirect } from "next/navigation"; +import { legacyAdminHref } from "@/lib/admin/adminNavigation"; +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + redirect(legacyAdminHref("/admin/services/compose", await searchParams)); } diff --git a/apps/web/src/app/admin/(full-admin)/services/data/page.tsx b/apps/web/src/app/admin/(full-admin)/services/data/page.tsx index c335140b1..db58de303 100644 --- a/apps/web/src/app/admin/(full-admin)/services/data/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/services/data/page.tsx @@ -1,8 +1,9 @@ -import type { Metadata } from "next"; -import { DataWorkflowsPage } from "@/components/admin/services/DataWorkflowsPage"; - -export const metadata: Metadata = { title: "Data Workflows — Admin — OpenMapX" }; - -export default function AdminDataWorkflowsPage() { - return ; +import { redirect } from "next/navigation"; +import { legacyAdminHref } from "@/lib/admin/adminNavigation"; +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + redirect(legacyAdminHref("/admin/services/data", await searchParams)); } diff --git a/apps/web/src/app/admin/(full-admin)/services/page.tsx b/apps/web/src/app/admin/(full-admin)/services/page.tsx index 99549ba49..d5b873ce5 100644 --- a/apps/web/src/app/admin/(full-admin)/services/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/services/page.tsx @@ -1,8 +1,9 @@ -import type { Metadata } from "next"; -import { ServiceCatalog } from "@/components/admin/services/ServiceCatalog"; - -export const metadata: Metadata = { title: "Services — Admin — OpenMapX" }; - -export default function AdminServicesPage() { - return ; +import { redirect } from "next/navigation"; +import { legacyAdminHref } from "@/lib/admin/adminNavigation"; +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + redirect(legacyAdminHref("/admin/services", await searchParams)); } diff --git a/apps/web/src/app/admin/(full-admin)/settings/[topic]/page.tsx b/apps/web/src/app/admin/(full-admin)/settings/[topic]/page.tsx new file mode 100644 index 000000000..e7fd9797c --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/settings/[topic]/page.tsx @@ -0,0 +1,9 @@ +import { notFound } from "next/navigation"; +import { SettingsWorkspace } from "@/components/admin/settings/SettingsWorkspace"; +import { SETTINGS_TOPICS } from "@/lib/admin/adminNavigation"; +export default async function Page({ params }: { params: Promise<{ topic: string }> }) { + const { topic } = await params; + const valid = SETTINGS_TOPICS.find((value) => value === topic); + if (!valid) notFound(); + return ; +} diff --git a/apps/web/src/app/admin/(full-admin)/settings/page.tsx b/apps/web/src/app/admin/(full-admin)/settings/page.tsx index d21e54944..2b1b326f3 100644 --- a/apps/web/src/app/admin/(full-admin)/settings/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/settings/page.tsx @@ -1,15 +1,4 @@ -import Stack from "@mui/material/Stack"; -import type { Metadata } from "next"; -import { PlatformConfiguration } from "@/components/admin/settings/PlatformConfiguration"; -import { SystemSettings } from "@/components/admin/settings/SystemSettings"; - -export const metadata: Metadata = { title: "Settings — Admin — OpenMapX" }; - -export default function AdminSettingsPage() { - return ( - - - - - ); +import { LegacySettingsRedirect } from "@/components/admin/settings/SettingsWorkspace"; +export default function Page() { + return ; } 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 new file mode 100644 index 000000000..126b161c7 --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/system/backups/page.tsx @@ -0,0 +1,6 @@ +import type { Metadata } from "next"; +import { BackupsPage } from "@/components/admin/services/BackupsPage"; +export const metadata: Metadata = { title: "Backups — Admin — OpenMapX" }; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/admin/(full-admin)/system/cache/page.tsx b/apps/web/src/app/admin/(full-admin)/system/cache/page.tsx new file mode 100644 index 000000000..dcd135cf0 --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/system/cache/page.tsx @@ -0,0 +1,6 @@ +import type { Metadata } from "next"; +import { CacheManager } from "@/components/admin/cache/CacheManager"; +export const metadata: Metadata = { title: "Cache — Admin — OpenMapX" }; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/admin/(full-admin)/system/configuration-transfer/page.tsx b/apps/web/src/app/admin/(full-admin)/system/configuration-transfer/page.tsx new file mode 100644 index 000000000..15fea9bfc --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/system/configuration-transfer/page.tsx @@ -0,0 +1,4 @@ +import { SettingsTransfer } from "@/components/admin/settings/SystemSettings"; +export default function Page() { + 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 new file mode 100644 index 000000000..5fd81e44a --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/system/deployment/page.tsx @@ -0,0 +1,11 @@ +import Stack from "@mui/material/Stack"; +import { ComposePreview } from "@/components/admin/services/ComposePreview"; +import { PlatformConfiguration } from "@/components/admin/settings/PlatformConfiguration"; +export default function Page() { + return ( + + + + + ); +} diff --git a/apps/web/src/app/admin/(full-admin)/system/diagnostics/page.tsx b/apps/web/src/app/admin/(full-admin)/system/diagnostics/page.tsx new file mode 100644 index 000000000..31163e9e5 --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/system/diagnostics/page.tsx @@ -0,0 +1,4 @@ +import { SystemMaintenance } from "@/components/admin/system/SystemMaintenance"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/admin/(full-admin)/system/layout.tsx b/apps/web/src/app/admin/(full-admin)/system/layout.tsx new file mode 100644 index 000000000..cddfacb38 --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/system/layout.tsx @@ -0,0 +1,4 @@ +import type { PropsWithChildren } from "react"; +export default function Layout({ children }: PropsWithChildren) { + return children; +} diff --git a/apps/web/src/app/admin/(full-admin)/system/page.tsx b/apps/web/src/app/admin/(full-admin)/system/page.tsx index fff71dbd3..f9e71bb0e 100644 --- a/apps/web/src/app/admin/(full-admin)/system/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/system/page.tsx @@ -1,5 +1,14 @@ -import { SystemMaintenance } from "@/components/admin/system/SystemMaintenance"; - -export default function AdminSystemPage() { - return ; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { legacyAdminHref } from "@/lib/admin/adminNavigation"; +import { getSelfHosted } from "@/lib/admin/serverDeployment"; +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + const jar = await cookies(); + redirect( + legacyAdminHref("/admin/system", await searchParams, await getSelfHosted(jar.toString())), + ); } diff --git a/apps/web/src/app/admin/(full-admin)/system/services/[id]/page.tsx b/apps/web/src/app/admin/(full-admin)/system/services/[id]/page.tsx new file mode 100644 index 000000000..66045838d --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/system/services/[id]/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { ServiceDetail } from "@/components/admin/services/ServiceDetail"; + +interface Props { + params: Promise<{ id: string }>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { id } = await params; + return { title: `${id} — Services — Admin — OpenMapX` }; +} + +export default async function AdminServiceDetailPage({ params }: Props) { + const { id } = await params; + return ; +} diff --git a/apps/web/src/app/admin/(full-admin)/system/services/page.tsx b/apps/web/src/app/admin/(full-admin)/system/services/page.tsx new file mode 100644 index 000000000..481051c33 --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/system/services/page.tsx @@ -0,0 +1,6 @@ +import type { Metadata } from "next"; +import { ServiceCatalog } from "@/components/admin/services/ServiceCatalog"; +export const metadata: Metadata = { title: "Services — Admin — OpenMapX" }; +export default function Page() { + return ; +} 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 new file mode 100644 index 000000000..67f344009 --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/system/updates/page.tsx @@ -0,0 +1,4 @@ +import { SystemMaintenance } from "@/components/admin/system/SystemMaintenance"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/admin/(full-admin)/transit/page.tsx b/apps/web/src/app/admin/(full-admin)/transit/page.tsx index a2d8da824..e7c768004 100644 --- a/apps/web/src/app/admin/(full-admin)/transit/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/transit/page.tsx @@ -1,8 +1,9 @@ -import type { Metadata } from "next"; -import { TransitPipelinePage } from "./TransitPipelinePage"; - -export const metadata: Metadata = { title: "Transit pipeline — Admin — OpenMapX" }; - -export default function AdminTransitPage() { - return ; +import { redirect } from "next/navigation"; +import { legacyAdminHref } from "@/lib/admin/adminNavigation"; +export default async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + redirect(legacyAdminHref("/admin/transit", await searchParams)); } diff --git a/apps/web/src/components/admin/AdminDraftSummary.tsx b/apps/web/src/components/admin/AdminDraftSummary.tsx new file mode 100644 index 000000000..a148c85dd --- /dev/null +++ b/apps/web/src/components/admin/AdminDraftSummary.tsx @@ -0,0 +1,18 @@ +"use client"; +import Button from "@mui/material/Button"; +import Tooltip from "@mui/material/Tooltip"; +import { useTranslations } from "next-intl"; +import { useAdminDraftCount, useAdminDraftExit } from "@/lib/admin/AdminDraftProvider"; +export function AdminDraftSummary() { + const count = useAdminDraftCount(); + const requestExit = useAdminDraftExit(); + const t = useTranslations("adminNavigation"); + if (!count) return null; + return ( + + + + ); +} diff --git a/apps/web/src/components/admin/AdminLayout.tsx b/apps/web/src/components/admin/AdminLayout.tsx index af78422a0..703773b5d 100644 --- a/apps/web/src/components/admin/AdminLayout.tsx +++ b/apps/web/src/components/admin/AdminLayout.tsx @@ -3,16 +3,20 @@ import Box from "@mui/material/Box"; import CircularProgress from "@mui/material/CircularProgress"; import useMediaQuery from "@mui/material/useMediaQuery"; +import { usePathname } from "next/navigation"; 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 { adminWorkspaceForPath } from "@/lib/admin/adminNavigation"; import { useHydrated } from "@/lib/useHydrated"; import { AdminSidebar } from "./AdminSidebar"; import { AdminTopBar } from "./AdminTopBar"; import { TOPBAR_HEIGHT } from "./layoutConstants"; import { AdminThemeProvider } from "./shared/AdminThemeProvider"; import { AdminToastProvider } from "./shared/AdminToast"; +import { AdminTopicNavigation } from "./shared/AdminTopicNavigation"; +import { SystemMaintenanceProvider } from "./system/SystemMaintenance"; interface AdminUser { id: string; @@ -28,8 +32,16 @@ interface AdminLayoutProps { selfHosted?: boolean | null; } -export function AdminLayout({ children, user, selfHosted = false }: AdminLayoutProps) { +export function AdminLayout(props: AdminLayoutProps) { + return ( + + + + ); +} +function AdminLayoutContent({ children, user, selfHosted = false }: AdminLayoutProps) { const { apiUrl } = useEnv(); + const workspace = adminWorkspaceForPath(usePathname()); useBindAdminDraftOwner({ apiUrl, actorId: user.id, role: user.role ?? "admin" }); const hydrated = useHydrated(); const desktop = useMediaQuery("(min-width:900px)", { defaultMatches: true }); @@ -64,7 +76,12 @@ export function AdminLayout({ children, user, selfHosted = false }: AdminLayoutP minWidth: 0, }} > - + {hydrated ? ( - children + + {workspace && ( + + )} + {children} + ) : ( (await import("@/test/adminNavigation")).adminNavigationMock, +); +vi.mock("@/integration-api/runtime/EnvProvider", () => ({ + useEnv: () => ({ apiUrl: "http://api.test" }), +})); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +function mount(role = "admin") { + return render( + + + , + { wrapper: createQueryWrapper() }, + ); +} +it("loads metadata only on open and navigates by keyboard without executing an operation", async () => { + const fetch = vi.fn(async (input: unknown) => + String(input).endsWith("/settings") + ? Response.json({ + groups: [ + { + settings: [ + { group: "email", key: "smtpHost", label: "SMTP host", value: "private-host" }, + ], + }, + ], + }) + : Response.json({ error: "unavailable" }, { status: 503 }), + ); + vi.stubGlobal("fetch", fetch); + adminTestHistory.reset("/admin"); + const navigate = vi.spyOn(adminTestHistory, "push"); + mount(); + expect(fetch).not.toHaveBeenCalled(); + fireEvent.keyDown(window, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox"); + fireEvent.change(input, { target: { value: "SMTP host" } }); + await screen.findByRole("option", { name: /SMTP host/ }); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => + expect(navigate).toHaveBeenCalledWith("/admin/settings/email?field=smtpHost"), + ); + expect( + fetch.mock.calls.every( + (call) => call.length === 1 || !call[1] || (call[1] as RequestInit).method !== "POST", + ), + ).toBe(true); + expect(screen.queryByText("private-host")).toBeNull(); +}); +it("offers no search or metadata requests for a privacy administrator", () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + mount("privacy_admin"); + fireEvent.keyDown(window, { key: "k", ctrlKey: true }); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(screen.queryByRole("button", { name: "Search administration" })).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); +}); +it("removes search before requesting metadata after a confirmed role change", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + function Shell() { + useBindAdminDraftOwner({ apiUrl: "http://api.test", actorId: "actor-a", role: "admin" }); + const store = useContext(AdminDraftContext); + return ( + <> + + + + ); + } + render( + + + + + , + { wrapper: createQueryWrapper() }, + ); + expect(await screen.findByRole("button", { name: "Search administration" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Change role" })); + fireEvent.keyDown(window, { key: "k", ctrlKey: true }); + expect(screen.queryByRole("button", { name: "Search administration" })).toBeNull(); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/admin/AdminSearch.tsx b/apps/web/src/components/admin/AdminSearch.tsx new file mode 100644 index 000000000..14d300f17 --- /dev/null +++ b/apps/web/src/components/admin/AdminSearch.tsx @@ -0,0 +1,256 @@ +"use client"; +import SearchIcon from "@mui/icons-material/Search"; +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import List from "@mui/material/List"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemText from "@mui/material/ListItemText"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { + listAdminIntegrations, + listAdminOperations, + listAdminServices, + unwrapAdminResponse, +} from "@openmapx/core/api-admin-sdk"; +import { useQuery } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; +import { useAdminDraftAuthority } from "@/lib/admin/AdminDraftProvider"; +import type { AdminNavigationAccess } from "@/lib/admin/adminNavigation"; +import { + type AdminSearchMetadata, + buildAdminSearchEntries, + searchAdminEntries, +} from "@/lib/admin/adminSearch"; +import { useActivityClient } from "./activity/activityQueries"; + +export function AdminSearch({ + role, + selfHosted, + actorId, +}: AdminNavigationAccess & { actorId: string }) { + const { apiUrl, client } = useActivityClient(); + const authority = useAdminDraftAuthority(); + const fullAdmin = + role === "admin" && + (!authority.present || + (authority.owner?.role === "admin" && + authority.owner.actorId === actorId && + authority.owner.apiUrl === apiUrl)); + const t = useTranslations("adminNavigation"); + const router = useRouter(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(0); + const input = useRef(null); + const enabled = open && fullAdmin; + useEffect(() => { + const key = (event: KeyboardEvent) => { + if ( + fullAdmin && + (event.ctrlKey || event.metaKey) && + !event.altKey && + event.key.toLowerCase() === "k" + ) { + event.preventDefault(); + event.stopImmediatePropagation(); + setOpen((value) => !value); + } + }; + window.addEventListener("keydown", key, true); + return () => window.removeEventListener("keydown", key, true); + }, [fullAdmin]); + const scope = JSON.stringify([apiUrl, actorId, role, authority.generation]); + const lastScope = useRef(scope); + useEffect(() => { + if (lastScope.current === scope) return; + lastScope.current = scope; + setOpen(false); + setQuery(""); + setSelected(0); + }, [scope]); + const metadata = useQuery({ + queryKey: ["admin", "search", apiUrl, actorId, role, selfHosted, authority.generation], + enabled, + staleTime: 60_000, + retry: false, + queryFn: async ({ signal }) => { + const results = await Promise.allSettled([ + (async () => { + const response = await fetch(`${apiUrl}/api/admin/settings`, { + credentials: "include", + signal, + }); + if (!response.ok) throw Error("Settings metadata unavailable"); + const data = (await response.json()) as { + groups: { settings: NonNullable }[]; + }; + return data.groups.flatMap((group) => + group.settings.map(({ key, group: groupId, subgroup, label, description }) => ({ + key, + group: groupId, + subgroup, + label, + description, + })), + ); + })(), + selfHosted === false + ? Promise.resolve([]) + : listAdminServices({ client, signal }) + .then(unwrapAdminResponse) + .then((data) => data.services.map(({ id, name }) => ({ id, name }))), + listAdminIntegrations({ client, signal }) + .then(unwrapAdminResponse) + .then((data) => data.map(({ id, name }) => ({ id, name }))), + listAdminOperations({ client, signal }) + .then(unwrapAdminResponse) + .then((data) => + data.operations.map(({ id, title, description, fields }) => ({ + id, + title, + description, + fields: fields.map(({ label }) => ({ label })), + })), + ), + ]); + const [settings, services, integrations, operations] = results; + return { + settings: settings.status === "fulfilled" ? settings.value : [], + services: services.status === "fulfilled" ? services.value : [], + integrations: integrations.status === "fulfilled" ? integrations.value : [], + operations: operations.status === "fulfilled" ? operations.value : [], + partial: results.some((result) => result.status === "rejected"), + }; + }, + }); + const entries = useMemo( + () => + buildAdminSearchEntries({ role, selfHosted }, metadata.data).map((entry) => + entry.id.includes(":") || + ["email-smtp", "transit-sources", "backup-retention", "release-selector"].includes(entry.id) + ? entry + : { + ...entry, + title: t(`destinations.${entry.id}.label`), + description: t(`destinations.${entry.id}.description`), + }, + ), + [role, selfHosted, metadata.data, t], + ); + const results = searchAdminEntries(entries, query); + useEffect(() => { + if (open) + document.getElementById(`admin-search-${selected}`)?.scrollIntoView?.({ block: "nearest" }); + }, [open, selected]); + const choose = (index: number) => { + const entry = results[index]; + if (!entry) return; + setOpen(false); + router.push(entry.href); + }; + if (!fullAdmin) return null; + return ( + <> + + setOpen(false)} + fullWidth + maxWidth="sm" + slotProps={{ transition: { onEntered: () => input.current?.focus() } }} + > + {t("search")} + + { + setQuery(event.target.value.slice(0, 200)); + setSelected(0); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + setSelected((value) => + Math.max( + 0, + Math.min(results.length - 1, value + (event.key === "ArrowDown" ? 1 : -1)), + ), + ); + } + if (event.key === "Enter") { + event.preventDefault(); + choose(selected); + } + }} + /> + {metadata.data?.partial && ( + void metadata.refetch()}>{t("retry")}} + > + {t("partialSearch")} + + )} + + {results.map((entry, index) => ( + choose(index)} + onMouseMove={() => setSelected(index)} + > + + + ))} + + {!results.length && ( + {t("noSearchResults")} + )} + + + + ); +} diff --git a/apps/web/src/components/admin/AdminSidebar.test.tsx b/apps/web/src/components/admin/AdminSidebar.test.tsx index 7bef22d35..1f9d30fb3 100644 --- a/apps/web/src/components/admin/AdminSidebar.test.tsx +++ b/apps/web/src/components/admin/AdminSidebar.test.tsx @@ -24,8 +24,10 @@ it.each([true, null])( {}} selfHosted={selfHosted} /> , ); - expect(screen.getAllByText("Services").length).toBeGreaterThan(0); - expect(screen.getAllByText("Maintenance").length).toBeGreaterThan(0); + expect(screen.getAllByRole("link", { name: "System" })[0]).toHaveAttribute( + "href", + "/admin/system/services", + ); }, ); it.each(["admin", "privacy_admin"])( @@ -61,9 +63,9 @@ it("shows privacy setup to full administrators", () => { {}} {...{ role: "admin" }} /> , ); - expect(screen.getAllByRole("link", { name: "Privacy setup" })[0]).toHaveAttribute( + expect(screen.getAllByRole("link", { name: "Privacy" })[0]).toHaveAttribute( "href", - "/admin/privacy-setup", + "/admin/privacy", ); }); it("does not offer privacy setup to privacy administrators", () => { @@ -83,6 +85,6 @@ it("closes mobile navigation on an ordinary destination selection", () => { , ); document.addEventListener("click", (event) => event.preventDefault(), { once: true }); - screen.getAllByRole("link", { name: "Catalog" })[0].click(); + screen.getAllByRole("link", { name: "System" })[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 c994f3a8c..4e24eff29 100644 --- a/apps/web/src/components/admin/AdminSidebar.tsx +++ b/apps/web/src/components/admin/AdminSidebar.tsx @@ -1,28 +1,17 @@ "use client"; - import ActivityIcon from "@mui/icons-material/Assessment"; -import ComposeIcon from "@mui/icons-material/Code"; import OverviewIcon from "@mui/icons-material/Dashboard"; -import ServicesIcon from "@mui/icons-material/Dns"; +import SystemIcon from "@mui/icons-material/Dns"; import IntegrationsIcon from "@mui/icons-material/Extension"; -import CatalogIcon from "@mui/icons-material/GridView"; -import CacheIcon from "@mui/icons-material/LayersClear"; -import CoverageIcon from "@mui/icons-material/MapOutlined"; import UsersIcon from "@mui/icons-material/People"; import PrivacyIcon from "@mui/icons-material/PrivacyTip"; -import BackupIcon from "@mui/icons-material/Restore"; -import PrivacySetupIcon from "@mui/icons-material/Rule"; import SettingsIcon from "@mui/icons-material/Settings"; import DataIcon from "@mui/icons-material/Storage"; -import StoreIcon from "@mui/icons-material/Store"; -import SystemUpdateIcon from "@mui/icons-material/SystemUpdateAlt"; -import TransitIcon from "@mui/icons-material/Train"; +import ExtensionsIcon from "@mui/icons-material/Store"; import Box from "@mui/material/Box"; -import Collapse from "@mui/material/Collapse"; import Divider from "@mui/material/Divider"; import Drawer from "@mui/material/Drawer"; import List from "@mui/material/List"; -import ListItem from "@mui/material/ListItem"; import ListItemButton from "@mui/material/ListItemButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; @@ -32,268 +21,40 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; +import { + type AdminWorkspace, + adminPrimaryNavigation, + adminWorkspaceForPath, +} from "@/lib/admin/adminNavigation"; import { SIDEBAR_WIDTH, TOPBAR_HEIGHT } from "./layoutConstants"; -const SERVICES_SUB_ITEMS = [ - { - label: "Catalog", - href: "/admin/services", - icon: , - exact: true, - }, - { - label: "Compose preview", - href: "/admin/services/compose", - icon: , - }, - { - label: "Data workflows", - href: "/admin/services/data", - icon: , - }, - { - label: "Backups", - href: "/admin/services/backups", - icon: , - }, -] as const; - -const BASE_NAV_ITEMS = [ - { - label: "Overview", - href: "/admin", - icon: , - exact: true, - selfHostedOnly: false, - privacyOnly: false, - }, - { - label: "Privacy requests", - href: "/admin/privacy", - icon: , - exact: true, - selfHostedOnly: false, - privacyOnly: true, - }, - { - label: "Privacy setup", - href: "/admin/privacy-setup", - icon: , - selfHostedOnly: false, - privacyOnly: false, - }, - { - label: "Users", - href: "/admin/users", - icon: , - selfHostedOnly: false, - privacyOnly: false, - }, - { - label: "Integrations", - href: "/admin/integrations", - icon: , - selfHostedOnly: false, - privacyOnly: false, - }, - { - label: "Extensions", - href: "/admin/extensions", - icon: , - selfHostedOnly: false, - privacyOnly: false, - }, - { - label: "Services", - href: "/admin/services", - icon: , - selfHostedOnly: true, - privacyOnly: false, - }, - { - label: "Transit", - href: "/admin/transit", - icon: , - selfHostedOnly: false, - privacyOnly: false, - }, - { - label: "Coverage & freshness", - href: "/admin/coverage", - icon: , - selfHostedOnly: false, - privacyOnly: false, - }, - { - label: "Activity", - href: "/admin/activity", - icon: , - selfHostedOnly: false, - privacyOnly: false, - }, - { - label: "Settings", - href: "/admin/settings", - icon: , - selfHostedOnly: false, - privacyOnly: false, - }, - { - label: "Maintenance", - href: "/admin/system", - icon: , - selfHostedOnly: true, - privacyOnly: false, - }, - { - label: "Cache", - href: "/admin/cache", - icon: , - selfHostedOnly: false, - privacyOnly: false, - }, -] as const; - -type NavItem = (typeof BASE_NAV_ITEMS)[number]; - -const NAV_GROUPS = [ - { label: "Manage", hrefs: ["/admin", "/admin/users"] }, - { label: "Privacy", hrefs: ["/admin/privacy", "/admin/privacy-setup"] }, - { label: "Platform", hrefs: ["/admin/integrations", "/admin/extensions"] }, - { - label: "Operations", - hrefs: [ - "/admin/services", - "/admin/transit", - "/admin/coverage", - "/admin/activity", - "/admin/cache", - ], - }, - { label: "System", hrefs: ["/admin/settings", "/admin/system"] }, -] as const; - +const ICONS: Record = { + overview: OverviewIcon, + integrations: IntegrationsIcon, + extensions: ExtensionsIcon, + data: DataIcon, + users: UsersIcon, + privacy: PrivacyIcon, + activity: ActivityIcon, + settings: SettingsIcon, + system: SystemIcon, +}; interface AdminSidebarProps { open: boolean; onClose: () => void; selfHosted?: boolean | null; role?: string; } - -function NavLink({ - item, - active, - sectionActive = false, - label = item.label, - onNavigate, -}: { - item: NavItem; - active: boolean; - /** - * True when this entry is the parent of an active sub-item but not itself - * the deepest match. Renders as a muted "section header" (bold text, no - * filled pill) so the active sub-item below stays the visual focus instead - * of stacking two identical pills. - */ - 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 - // what we explicitly want to avoid for `sectionActive`. - const showFilled = active && !sectionActive; - return ( - - - {item.icon} - - - - ); -} - -type SubItem = (typeof SERVICES_SUB_ITEMS)[number]; - -function SubNavLink({ - item, - active, - onNavigate, -}: { - item: SubItem; - active: boolean; - onNavigate: (event: React.MouseEvent) => void; -}) { - return ( - - - {item.icon} - - - - ); -} - export function AdminSidebar({ open, onClose, - selfHosted = false, + selfHosted = null, role = "admin", }: AdminSidebarProps) { const pathname = usePathname(); const desktop = useMediaQuery("(min-width:900px)", { defaultMatches: true }); + const t = useTranslations("adminNavigation"); + const active = adminWorkspaceForPath(pathname); const onNavigate = (event: React.MouseEvent) => { if ( !event.defaultPrevented && @@ -306,118 +67,55 @@ export function AdminSidebar({ ) onClose(); }; - const privacySetup = useTranslations("privacySetup"); - - const navItems = BASE_NAV_ITEMS.filter( - (item) => - (!item.selfHostedOnly || selfHosted !== false) && - (!item.privacyOnly || role === "admin" || role === "privacy_admin"), - ).filter((item) => role === "admin" || item.privacyOnly); - - const isActive = (item: NavItem) => { - if ("exact" in item && item.exact) return pathname === item.href; - return pathname === item.href || pathname.startsWith(`${item.href}/`); - }; - - const isSubActive = (item: SubItem) => { - if ("exact" in item && item.exact) return pathname === item.href; - return pathname === item.href || pathname.startsWith(`${item.href}/`); - }; - - const servicesExpanded = selfHosted !== false && pathname.startsWith("/admin/services"); - const drawerContent = ( <> - - - - - + + + OpenMapX - - Administration + + {t("administration")} - - {NAV_GROUPS.map((group) => { - const groupItems = navItems.filter((item) => - (group.hrefs as readonly string[]).includes(item.href), - ); - if (groupItems.length === 0) return null; + + {adminPrimaryNavigation({ role, selfHosted }).map((entry) => { + const Icon = ICONS[entry.workspace]; return ( - - + - {group.label} - - {groupItems.map((item) => { - const hasActiveChild = - item.href === "/admin/services" && - selfHosted !== false && - SERVICES_SUB_ITEMS.some(isSubActive); - return ( - - - {item.href === "/admin/services" && selfHosted !== false && ( - - - {SERVICES_SUB_ITEMS.map((sub) => ( - - ))} - - - )} - - ); - })} - + + + + ); })} ); - return ( = { - admin: "Admin", - users: "Users", - integrations: "Integrations", - store: "Store", - services: "Services", - data: "Data", - status: "Status", - activity: "Activity", - settings: "Settings", - cache: "Cache", - sources: "Sources", -}; - -function labelForSegment(seg: string): string { - if (SEGMENT_LABELS[seg]) return SEGMENT_LABELS[seg]; - // UUID → "Detail" - if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(seg)) return "Detail"; - // kebab-case slug (e.g. integration ID) → title case - if (seg.includes("-")) - return seg - .split("-") - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" "); - return seg.charAt(0).toUpperCase() + seg.slice(1); -} - interface AdminTopBarProps { - user: { name: string; email: string; image?: string; role?: string }; + user: { id: string; name: string; email: string; image?: string; role?: string }; + selfHosted?: boolean | null; sidebarOpen: boolean; onToggleSidebar: () => void; } - -function useBreadcrumbs(): { label: string; href: string }[] { +function useBreadcrumbs(access: AdminNavigationAccess): { label: string; href: string }[] { const pathname = usePathname(); - const segments = pathname.split("/").filter(Boolean); - return segments.map((seg, i) => ({ - label: labelForSegment(seg), - href: `/${segments.slice(0, i + 1).join("/")}`, - })); + const t = useTranslations("adminNavigation"); + const workspace = adminWorkspaceForPath(pathname); + const topic = adminDestinationForPath(pathname); + const primary = adminPrimaryNavigation(access).find((entry) => entry.workspace === workspace); + const crumbs = [{ label: t("administration"), href: "/admin" }]; + if (primary && workspace !== "overview") + crumbs.push({ label: t(`primary.${workspace}`), href: primary.href }); + if (topic && topic.href !== primary?.href) + crumbs.push({ label: t(`destinations.${topic.id}.label`), href: topic.href }); + if (topic && pathname.startsWith(`${topic.href}/`)) { + let label = pathname.slice(topic.href.length + 1); + try { + label = decodeURIComponent(label); + } catch { + label = "Detail"; + } + crumbs.push({ label, href: pathname }); + } + return crumbs; } -export function AdminTopBar({ user, sidebarOpen, onToggleSidebar }: AdminTopBarProps) { - const breadcrumbs = useBreadcrumbs(); +export function AdminTopBar({ user, selfHosted, sidebarOpen, onToggleSidebar }: AdminTopBarProps) { + const breadcrumbs = useBreadcrumbs({ role: user.role, selfHosted }); const avatarSrc = user.image ? proxyImageUrl(user.image) : undefined; return ( @@ -107,6 +103,10 @@ export function AdminTopBar({ user, sidebarOpen, onToggleSidebar }: AdminTopBarP sx={{ color: "text.primary", fontWeight: 600, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: { xs: 110, sm: 260 }, }} > {crumb.label} @@ -129,7 +129,13 @@ export function AdminTopBar({ user, sidebarOpen, onToggleSidebar }: AdminTopBarP })} - {user.role === "admin" && } + {user.role === "admin" && ( + <> + + + + + )} {data.services ? ( - } href="/admin/services"> + } href="/admin/system/services"> = { "notification-health": "/admin/privacy-setup#notifications", "cleanup-retention": "/admin/privacy-setup#retention", "registry-completeness": "/admin/privacy-setup#retention", - "encryption-key": "/admin/system", - "artifact-storage": "/admin/system", - "artifact-storage-backup": "/admin/services/backups", - "backup-review-capability": "/admin/services/backups", - "request-sla-monitor": "/admin/system", + "encryption-key": "/admin/system/diagnostics", + "artifact-storage": "/admin/system/diagnostics", + "artifact-storage-backup": "/admin/system/backups", + "backup-review-capability": "/admin/system/backups", + "request-sla-monitor": "/admin/system/diagnostics", "preservation-enforcement": "/admin/privacy-setup#reviews", "source-streaming": "/admin/privacy-setup#reviews", "assisted-workflow": "/admin/privacy-setup#reviews", diff --git a/apps/web/src/components/admin/services/BackupPruneControls.tsx b/apps/web/src/components/admin/services/BackupPruneControls.tsx index 180513c1b..9f3ba64b2 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 - diff --git a/apps/web/src/components/admin/services/DataWorkflowsPage.tsx b/apps/web/src/components/admin/services/DataWorkflowsPage.tsx index 4162ca0bb..ba1abe718 100644 --- a/apps/web/src/components/admin/services/DataWorkflowsPage.tsx +++ b/apps/web/src/components/admin/services/DataWorkflowsPage.tsx @@ -145,7 +145,7 @@ function DataOperationsSection({ apiUrl }: { apiUrl: string }) { {(catalog.data?.operations ?? []) .filter((operation) => DATA_OPERATION_GROUPS.has(operation.group)) .map((operation) => ( - + - @@ -448,11 +453,24 @@ function DataWorkflowsView() { )} - - +
+ {["overture-pull", "overture-ingest", "overture-extract", "overture-conflate"].map( + (id) => ( + + ), + )} + +
+
+ +
- - +
+ +
+
+ +
diff --git a/apps/web/src/components/admin/services/ServiceCatalog.tsx b/apps/web/src/components/admin/services/ServiceCatalog.tsx index af64475ed..110739019 100644 --- a/apps/web/src/components/admin/services/ServiceCatalog.tsx +++ b/apps/web/src/components/admin/services/ServiceCatalog.tsx @@ -974,7 +974,7 @@ function ServiceCatalogView() { > )} {cred.managedBy === "transit.credentials" && ( - + )} {cred.updatedAt && ( diff --git a/apps/web/src/components/admin/services/ServiceDetail.tsx b/apps/web/src/components/admin/services/ServiceDetail.tsx index 26cad87bc..5c9284eba 100644 --- a/apps/web/src/components/admin/services/ServiceDetail.tsx +++ b/apps/web/src/components/admin/services/ServiceDetail.tsx @@ -768,7 +768,7 @@ function ServiceDetailView({ id }: ServiceDetailProps) { )} {["app-api", "traefik", "ops-agent"].includes(manifest.id) && ( - )} 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 2f004b7e1..3bb059fab 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/settings#backup-retention", + "/admin/system/deployment#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 b9c98281a..fa02cb0f5 100644 --- a/apps/web/src/components/admin/settings/AuthPolicySettings.test.tsx +++ b/apps/web/src/components/admin/settings/AuthPolicySettings.test.tsx @@ -44,13 +44,13 @@ const view = { }, }; const caches: ReturnType[] = []; -async function mount() { +async function mount(focusKey?: string) { const { AuthPolicySettings } = await import("./AuthPolicySettings"), cache = createTestQueryClient(); caches.push(cache); return render( - + , ); } @@ -63,6 +63,11 @@ afterEach(() => { cleanup(); for (const c of caches.splice(0)) c.clear(); }); +it("focuses the addressed policy field without applying it", async () => { + await mount("passwordMinLength"); + await waitFor(() => expect(screen.getByLabelText("Minimum password length")).toHaveFocus()); + 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 a03dac7c6..df5570214 100644 --- a/apps/web/src/components/admin/settings/AuthPolicySettings.tsx +++ b/apps/web/src/components/admin/settings/AuthPolicySettings.tsx @@ -11,7 +11,8 @@ 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"; -export function AuthPolicySettings() { +import { useAdminFieldFocus } from "@/lib/admin/useAdminFieldFocus"; +export function AuthPolicySettings({ focusKey }: { focusKey?: string } = {}) { const scope = useAccountAdmin(), query = useQuery({ queryKey: scope.keys.policy, @@ -19,6 +20,7 @@ export function AuthPolicySettings() { enabled: !!scope.actorId, retry: false, }); + useAdminFieldFocus(focusKey, !!query.data); return ( Authentication policy @@ -145,6 +147,7 @@ function PolicyForm({ view }: { view: ReturnType )} queryClient.invalidateQueries({ queryKey: ["admin", "platform", apiUrl] }), }); return accepted; @@ -236,7 +236,13 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string } return ( (await import("@/test/adminNavigation")).adminNavigationMock, +); +vi.mock("@/integration-api/runtime/EnvProvider", () => ({ + useEnv: () => ({ apiUrl: "http://api.test" }), +})); +vi.mock("./AuthPolicySettings", () => ({ + AuthPolicySettings: () =>
Effective authentication policy
, +})); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +const groups = ["general", "auth", "email", "map", "policy", "legal"].map((group) => ({ + id: group, + label: group, + settings: [ + { + group, + key: `${group}Field`, + label: `${group} field`, + value: "saved", + type: "string", + secret: false, + source: "default", + envOverride: false, + }, + ], +})); +it.each([ + ["general", "general"], + ["authentication", "auth"], + ["email", "email"], + ["map", "map"], + ["data-use", "policy"], +] as const)("renders only the owning ordinary settings for %s", async (topic, group) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ groups })), + ); + render( + + + , + { wrapper: createQueryWrapper() }, + ); + await screen.findByLabelText(`${group} field`); + for (const other of groups.filter((entry) => entry.id !== group)) + expect(screen.queryByLabelText(`${other.id} field`)).toBeNull(); + expect(screen.queryAllByText("Effective authentication policy")).toHaveLength( + topic === "authentication" ? 1 : 0, + ); + expect(screen.queryByText("Platform configuration")).toBeNull(); + expect(screen.queryByText("Export settings")).toBeNull(); +}); +it("reveals and focuses an addressable setting", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ groups })), + ); + adminTestHistory.reset("/admin/settings/email?field=emailField"); + render( + + + , + { wrapper: createQueryWrapper() }, + ); + await waitFor(() => expect(screen.getByLabelText("email field")).toHaveFocus()); +}); +it("migrates the legacy retention fragment to the field's current owner", async () => { + window.history.replaceState(null, "", "/admin/settings#backup-retention"); + const replace = vi.spyOn(adminTestHistory, "replace"); + render(); + await waitFor(() => + expect(replace).toHaveBeenCalledWith("/admin/system/deployment#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 new file mode 100644 index 000000000..b3807b74c --- /dev/null +++ b/apps/web/src/components/admin/settings/SettingsWorkspace.tsx @@ -0,0 +1,75 @@ +"use client"; +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useEffect } from "react"; +import type { SETTINGS_TOPICS } from "@/lib/admin/adminNavigation"; +import { AdminPageHeader } from "../shared/AdminPageHeader"; +import { SystemSettings } from "./SystemSettings"; + +const GROUPS = { + general: "general", + authentication: "auth", + email: "email", + map: "map", + "data-use": "policy", +}; +export function SettingsWorkspace({ topic }: { topic: (typeof SETTINGS_TOPICS)[number] }) { + const t = useTranslations("adminNavigation"); + const params = useSearchParams(); + const field = params.get("field"); + return ( + + + {topic === "general" && ( + + {t("destinations.deployment.label")} + + } + > + {t("applicationUrlHelp")} + + )} + + {topic === "data-use" && ( + + )} + + ); +} +export function LegacySettingsRedirect() { + const router = useRouter(); + useEffect(() => { + router.replace( + window.location.hash === "#backup-retention" + ? "/admin/system/deployment#backup-retention" + : "/admin/settings/general", + ); + }, [router]); + return null; +} diff --git a/apps/web/src/components/admin/settings/SystemSettings.tsx b/apps/web/src/components/admin/settings/SystemSettings.tsx index a5d0131c9..437cfce92 100644 --- a/apps/web/src/components/admin/settings/SystemSettings.tsx +++ b/apps/web/src/components/admin/settings/SystemSettings.tsx @@ -408,6 +408,7 @@ function SettingsGroupPanel({ description, anchorId, defaultExpanded, + focusKey, subgroupMeta = SUBGROUP_META, text, }: { @@ -418,6 +419,7 @@ function SettingsGroupPanel({ description?: string; anchorId?: string; defaultExpanded?: boolean; + focusKey?: string; subgroupMeta?: Record; text: SettingsEditorText; }) { @@ -430,6 +432,17 @@ function SettingsGroupPanel({ ); const [secretValues, setSecretValues] = useState>({}); const [invalidKeys, setInvalidKeys] = useState>(() => new Set()); + const [expanded, setExpanded] = useState(defaultExpanded ?? group.id === "general"); + useEffect(() => { + if (!focusKey || !group.settings.some((setting) => setting.key === focusKey)) return; + setExpanded(true); + const timer = setTimeout(() => { + const element = document.getElementById(`setting-${focusKey}`); + element?.scrollIntoView?.({ block: "center" }); + element?.querySelector("input, textarea, button, [role=combobox]")?.focus(); + }, 0); + return () => clearTimeout(timer); + }, [focusKey, group.settings]); const renderedValues = { ...serverValues, ...draft.values, ...secretValues }; const dirtyKeys = group.settings .filter( @@ -499,7 +512,8 @@ function SettingsGroupPanel({ return ( setExpanded(value)} variant="outlined" disableGutters sx={{ @@ -538,6 +552,15 @@ function SettingsGroupPanel({
+ {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. + + )} {section.settings.map((s) => ( - + ; text?: SettingsEditorText; onSettingsSaved?: () => void | Promise; + focusKey?: string; } = {}) { const env = useEnv(); const draftStore = useContext(AdminDraftContext); @@ -1040,7 +1069,9 @@ export function SystemSettings({ > {showHeader && } {(!sections || sections.some((section) => section.groupId === "auth")) && - data.groups.some((group) => group.id === "auth") && } + data.groups.some((group) => group.id === "auth") && ( + + )} {panels.map(({ group, section }) => ( ); } + +/** Transfer utilities intentionally fetch no settings values until an operator exports. */ +export function SettingsTransfer() { + const [message, setMessage] = useState<{ text: string; severity: "success" | "error" } | null>( + null, + ); + return ( + + + {message && ( + setMessage(null)}> + {message.text} + + )} + setMessage({ text, severity })} /> + + + ); +} diff --git a/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx b/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx new file mode 100644 index 000000000..20646d045 --- /dev/null +++ b/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx @@ -0,0 +1,74 @@ +"use client"; +import Box from "@mui/material/Box"; +import List from "@mui/material/List"; +import ListItemButton from "@mui/material/ListItemButton"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { + type AdminNavigationAccess, + type AdminWorkspace, + adminDestinationForPath, + adminDestinations, +} from "@/lib/admin/adminNavigation"; + +export function AdminTopicNavigation({ + workspace, + role, + selfHosted, +}: AdminNavigationAccess & { workspace: AdminWorkspace }) { + const pathname = usePathname(); + const router = useRouter(); + const t = useTranslations("adminNavigation"); + const entries = adminDestinations({ role, selfHosted }).filter( + (entry) => entry.workspace === workspace, + ); + const active = adminDestinationForPath(pathname)?.id; + if (entries.length < 2) return null; + return ( + + entry.id === active)?.href ?? entries[0].href} + onChange={(event) => router.push(event.target.value)} + sx={{ display: { lg: "none" }, mb: 1 }} + > + {entries.map((entry) => ( + + {t(`destinations.${entry.id}.label`)} + + ))} + + + + {t(`primary.${workspace}`)} + + {entries.map((entry) => ( + + {t(`destinations.${entry.id}.label`)} + + ))} + + + ); +} diff --git a/apps/web/src/components/admin/shared/HostedDataTopics.tsx b/apps/web/src/components/admin/shared/HostedDataTopics.tsx new file mode 100644 index 000000000..6f79330eb --- /dev/null +++ b/apps/web/src/components/admin/shared/HostedDataTopics.tsx @@ -0,0 +1,24 @@ +"use client"; +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { AdminPageHeader } from "./AdminPageHeader"; +export function HostedDataTopics() { + const t = useTranslations("adminNavigation"); + return ( + + + {t("hostedData")} + + + + + + ); +} diff --git a/apps/web/src/components/admin/system/SystemMaintenance.behavior.test.tsx b/apps/web/src/components/admin/system/SystemMaintenance.behavior.test.tsx index 7b64f7298..cca9473b2 100644 --- a/apps/web/src/components/admin/system/SystemMaintenance.behavior.test.tsx +++ b/apps/web/src/components/admin/system/SystemMaintenance.behavior.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; import { job } from "../activity/__tests__/fixtures"; -import { SystemMaintenance } from "./SystemMaintenance"; +import { SystemMaintenance, SystemMaintenanceProvider } from "./SystemMaintenance"; vi.mock("@/integration-api/runtime/EnvProvider", () => ({ useEnv: () => ({ apiUrl: "http://api.test" }), @@ -14,6 +14,36 @@ afterEach(() => { vi.useRealTimers(); }); +it("keeps a queued update blocking Diagnostics across topic remounts", async () => { + vi.stubGlobal("fetch", async (input: string, init?: RequestInit) => { + if (init?.method === "POST") return Response.json({ jobId: "check-job" }); + if (input.includes("/check-job?source=application")) + return Response.json({ ...job({ id: "check-job" }), status: "running" }); + return Response.json({ deployment: { maintenanceReady: true }, images: [] }); + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = (section: "updates" | "diagnostics") => ( + + + + + + ); + const mounted = render(view("updates")); + await waitFor(() => + expect(screen.getByRole("button", { name: "Check for updates" })).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole("button", { name: "Check for updates" })); + await screen.findByText("running"); + mounted.rerender(view("diagnostics")); + expect(await screen.findByRole("button", { name: "Run diagnostics" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Check for updates" })).toBeNull(); + expect(screen.getByRole("link", { name: /View job/ })).toHaveAttribute( + "href", + expect.stringContaining("job=check-job"), + ); +}); + it("continues maintenance polling through cancel_pending until terminal completion", async () => { vi.useFakeTimers(); let reads = 0; diff --git a/apps/web/src/components/admin/system/SystemMaintenance.navigation.test.tsx b/apps/web/src/components/admin/system/SystemMaintenance.navigation.test.tsx new file mode 100644 index 000000000..e2c1ffe87 --- /dev/null +++ b/apps/web/src/components/admin/system/SystemMaintenance.navigation.test.tsx @@ -0,0 +1,105 @@ +import { en } from "@openmapx/i18n"; +import { usePathname } from "next/navigation"; +import { NextIntlClientProvider } from "next-intl"; +import { afterEach, expect, it, vi } from "vitest"; +import SystemLayout from "@/app/admin/(full-admin)/system/layout"; +import { + act, + cleanup, + createQueryWrapper, + createTestQueryClient, + fireEvent, + render, + screen, + waitFor, +} from "@/test"; +import { adminTestHistory } from "@/test/adminNavigation"; +import { AdminLayout } from "../AdminLayout"; +import { job } from "../activity/__tests__/fixtures"; +import { SystemMaintenance } from "./SystemMaintenance"; + +vi.mock( + "next/navigation", + async () => (await import("@/test/adminNavigation")).adminNavigationMock, +); +vi.mock("@/integration-api/runtime/EnvProvider", () => ({ + useEnv: () => ({ apiUrl: "http://api.test" }), +})); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +it("retains the rejected release digest when Activity disposes the System route", async () => { + adminTestHistory.reset("/admin/system/updates"); + let failed = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "POST") return Response.json({ jobId: "apply-job" }); + if (String(input).includes("/jobs/")) + return Response.json( + job({ + id: "apply-job", + status: failed ? "failed" : "running", + result: failed ? { errorCode: "host_update_required" } : null, + }), + ); + return Response.json({ + deployment: { maintenanceReady: true }, + release: { + currentReleaseId: "old", + availableReleaseId: "release", + availableDigest: "a".repeat(64), + }, + images: [ + { + id: "ops-agent", + name: "Agent", + image: "agent", + containerState: "running", + runningImageId: "a", + localImageId: "a", + updateAvailable: false, + status: "up-to-date", + }, + ], + }); + }); + function Harness() { + const path = usePathname(); + return ( + + + {path.startsWith("/admin/system") ? ( + + + + ) : ( +
Activity
+ )} +
+
+ ); + } + const client = createTestQueryClient(); + render(, { wrapper: createQueryWrapper(client) }); + await waitFor(() => + expect(screen.getByRole("button", { name: "Update OpenMapX" })).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole("button", { name: "Update OpenMapX" })); + fireEvent.change(screen.getByRole("textbox", { name: /Type UPDATE OPENMAPX/ }), { + target: { value: "UPDATE OPENMAPX" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Update application" })); + await screen.findByText("running"); + act(() => adminTestHistory.push("/admin/activity")); + failed = true; + await act(() => client.invalidateQueries({ queryKey: ["admin", "jobs", "detail"] })); + act(() => adminTestHistory.push("/admin/system/updates")); + await waitFor(() => + expect(screen.getByRole("button", { name: "Update OpenMapX" })).toBeDisabled(), + ); + expect(screen.getByText("Host update required")).toBeInTheDocument(); + client.clear(); +}); diff --git a/apps/web/src/components/admin/system/SystemMaintenance.tsx b/apps/web/src/components/admin/system/SystemMaintenance.tsx index b4e47b9ce..1adc67d19 100644 --- a/apps/web/src/components/admin/system/SystemMaintenance.tsx +++ b/apps/web/src/components/admin/system/SystemMaintenance.tsx @@ -29,12 +29,18 @@ import Typography from "@mui/material/Typography"; 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 { createContext, type PropsWithChildren, useContext, useEffect, useState } from "react"; import { AdminJobLink } from "@/components/admin/activity/AdminJobLink"; import Dialog from "@/components/ui/Dialog"; -import { useOptionalAdminJobRegistration } from "@/lib/admin/adminJobEffects"; +import { useEnv } from "@/integration-api/runtime/EnvProvider"; +import { useAdminDraftAuthority } from "@/lib/admin/AdminDraftProvider"; +import { useAdminObservedJobs, useOptionalAdminJobRegistration } from "@/lib/admin/adminJobEffects"; import { serviceOrThrow } from "@/lib/admin/serviceAdmin"; -import { activityJobQueryOptions, useActivityClient } from "../activity/activityQueries"; +import { + activityJobQueryOptions, + hasUnresolvedJobObservation, + useActivityClient, +} from "../activity/activityQueries"; import { AdminPageHeader } from "../shared/AdminPageHeader"; import { AdminTableSurface } from "../shared/AdminTableSurface"; import { useAdminToast } from "../shared/AdminToast"; @@ -81,6 +87,7 @@ interface SystemStatus { interface TrackedJob { path: string; + expectedDigest?: string; id: string; label: string; } @@ -171,7 +178,37 @@ function imageStatusChip(image: ImageStatus) { return ; } -export function SystemMaintenance() { +function useMaintenanceState() { + const [trackedJob, setTrackedJob] = useState(null); + const [blockedCandidate, setBlockedCandidate] = useState(null); + const [rejections, setRejections] = useState< + Partial> + >({}); + const [pending, setPending] = useState(0); + return { + trackedJob, + setTrackedJob, + blockedCandidate, + setBlockedCandidate, + rejections, + setRejections, + pending, + setPending, + }; +} +const MaintenanceContext = createContext | null>(null); +function MaintenanceStateProvider({ children }: PropsWithChildren) { + const state = useMaintenanceState(); + return {children}; +} +export function SystemMaintenanceProvider({ children }: PropsWithChildren) { + const { apiUrl } = useEnv(); + const { generation } = useAdminDraftAuthority(); + return ( + {children} + ); +} +export function SystemMaintenance({ section }: { section?: "updates" | "diagnostics" } = {}) { const { apiUrl, client } = useActivityClient(); const queryClient = useQueryClient(); const register = useOptionalAdminJobRegistration(); @@ -189,12 +226,28 @@ export function SystemMaintenance() { const [clearConfirmation, setClearConfirmation] = useState(""); const [confirmation, setConfirmation] = useState(""); const [createBackup, setCreateBackup] = useState(true); - const [trackedJob, setTrackedJob] = useState(null); - const [blockedCandidate, setBlockedCandidate] = useState(null); - // Latest refusal per action, so one action's feedback and wait never replace another's. - const [rejections, setRejections] = useState< - Partial> - >({}); + const local = useMaintenanceState(); + const shared = useContext(MaintenanceContext) ?? local; + const { + setTrackedJob, + blockedCandidate, + setBlockedCandidate, + rejections, + setRejections, + pending, + setPending, + } = shared; + const observed = useAdminObservedJobs(); + const accepted = observed.find((job) => !job.settled && job.kind?.startsWith("maintenance:")); + const trackedJob = + shared.trackedJob ?? + (accepted + ? { + id: accepted.jobId, + label: accepted.label, + path: accepted.kind?.slice("maintenance:".length) ?? "", + } + : null); useEffect(() => { const timers = SUBMISSION_PATHS.flatMap((path) => { @@ -214,7 +267,7 @@ export function SystemMaintenance() { return () => { for (const timer of timers) clearTimeout(timer); }; - }, [rejections]); + }, [rejections, setRejections]); const statusQuery = useQuery({ queryKey: ["admin", "system", apiUrl], @@ -234,14 +287,20 @@ export function SystemMaintenance() { useEffect(() => { const job = jobQuery.data; - if (!trackedJob || !job || !["success", "failed", "canceled"].includes(job.status)) return; + if ( + !trackedJob || + !job || + !["success", "failed", "canceled"].includes(job.status) || + hasUnresolvedJobObservation(job.result) + ) + return; if ( job.result && typeof job.result === "object" && !Array.isArray(job.result) && job.result.errorCode === "host_update_required" ) { - setBlockedCandidate(candidateToApply?.expectedDigest ?? null); + setBlockedCandidate(trackedJob.expectedDigest ?? candidateToApply?.expectedDigest ?? null); } if (job.status === "success") { if (trackedJob.path === "updates/check") setBlockedCandidate(null); @@ -250,7 +309,15 @@ export function SystemMaintenance() { void queryClient.invalidateQueries({ queryKey: ["admin", "system"] }); void queryClient.invalidateQueries({ queryKey: ["admin", "jobs"] }); setTrackedJob(null); - }, [candidateToApply, jobQuery.data, queryClient, showToast, trackedJob]); + }, [ + candidateToApply, + jobQuery.data, + queryClient, + showToast, + trackedJob, + setBlockedCandidate, + setTrackedJob, + ]); const queueJob = useMutation({ // Maintenance actions are never repeated automatically, whatever the client defaults are. @@ -289,17 +356,27 @@ export function SystemMaintenance() { apiUrl, jobId: result.jobId, label, - kind: "maintenance", - returnTo: "/admin/system", + kind: `maintenance:${path}`, + returnTo: path === "diagnostics" ? "/admin/system/diagnostics" : "/admin/system/updates", onSettled: () => queryClient.invalidateQueries({ queryKey: ["admin", "system", apiUrl] }), }); - return { id: result.jobId, label, path }; + const job = { + id: result.jobId, + label, + path, + expectedDigest: typeof body?.expectedDigest === "string" ? body.expectedDigest : undefined, + }; + setTrackedJob(job); + return job; }, - onMutate: ({ path }) => + onSettled: () => setPending((count) => Math.max(0, count - 1)), + onMutate: ({ path }) => { + setPending((count) => count + 1); setRejections((current) => { const { [path]: _cleared, ...others } = current; return others; - }), + }); + }, onSuccess: (job) => { setTrackedJob(job); if (job.path === "updates/apply") { @@ -329,6 +406,8 @@ export function SystemMaintenance() { }); const clearPin = useMutation({ + onMutate: () => setPending((count) => count + 1), + onSettled: () => setPending((count) => Math.max(0, count - 1)), mutationFn: async (fixed: NonNullable) => { if (fixed.origin !== apiUrl) throw Error("API origin changed; inspect the release selection again"); @@ -344,11 +423,13 @@ export function SystemMaintenance() { apiUrl, jobId: result.jobId, label: "Release pin clear", - kind: "maintenance", - returnTo: "/admin/system", + kind: "maintenance:release/clear", + returnTo: "/admin/system/updates", onSettled: () => queryClient.invalidateQueries({ queryKey: ["admin", "system", apiUrl] }), }); - return { id: result.jobId, label: "Release pin clear", path: "release/clear" }; + const job = { id: result.jobId, label: "Release pin clear", path: "release/clear" }; + setTrackedJob(job); + return job; }, onSuccess: (job) => { setTrackedJob(job); @@ -360,7 +441,7 @@ export function SystemMaintenance() { showToast(error instanceof Error ? error.message : "Unable to clear release pin", "error"), }); const status = statusQuery.data; - const active = Boolean(trackedJob || queueJob.isPending || clearPin.isPending); + const active = Boolean(trackedJob || pending || queueJob.isPending || clearPin.isPending); const waiting = (path: SubmissionPath) => Boolean(rejections[path]?.until); const rejectionAlert = (rejection: SubmissionRejection) => ( @@ -423,7 +504,13 @@ export function SystemMaintenance() { return ( @@ -439,89 +526,96 @@ export function SystemMaintenance() { )} - - Release selection - - Configured channel: {status?.release?.configuredSelector ?? "unknown"} - - - Current release: {status?.release?.currentReleaseId ?? "unknown"} - - - Selected pin: {status?.release?.selectedReleaseId ?? "none or unavailable"} - - - Checked release: {status?.release?.availableReleaseId ?? "not checked"} - - {status?.release?.availableDigest && ( - - Manifest: {status.release.availableDigest} - - )} - - Check reads the configured release manifest without downloading images or restarting - services. Update downloads that exact checked release before replacing any container. - Change the configured channel under Platform configuration in Settings. Clearing the pin - preserves that channel and all running containers; the next configuration action may - resolve the configured channel again. - - - - - {hostUpdateRequired && ( - - Host update required - - {agentReplacementFailed - ? "The operations agent could not be updated from the admin panel. The previous agent keeps running." - : "The operations agent or its backup helper could not be verified for this release. Updating from the admin panel is blocked."}{" "} - Run these commands from an updated OpenMapX checkout on the host. - - - {HOST_UPDATE_COMMANDS} - - - - )} + {section !== "diagnostics" && ( + <> + + Release selection + + Configured channel: {status?.release?.configuredSelector ?? "unknown"} + + + Current release: {status?.release?.currentReleaseId ?? "unknown"} + + + Selected pin: {status?.release?.selectedReleaseId ?? "none or unavailable"} + + + Checked release: {status?.release?.availableReleaseId ?? "not checked"} + + {status?.release?.availableDigest && ( + + Manifest: {status.release.availableDigest} + + )} + + Check reads the configured release manifest without downloading images or restarting + services. Update downloads that exact checked release before replacing any container. + Change the configured channel under Platform configuration in Settings. Clearing the + pin preserves that channel and all running containers; the next configuration action + may resolve the configured channel again. + + + + + {hostUpdateRequired && ( + + Host update required + + {agentReplacementFailed + ? "The operations agent could not be updated from the admin panel. The previous agent keeps running." + : "The operations agent or its backup helper could not be verified for this release. Updating from the admin panel is blocked."}{" "} + Run these commands from an updated OpenMapX checkout on the host. + + + {HOST_UPDATE_COMMANDS} + + + + )} - {replacesAgent && ( - - This release also updates the operations agent. The update replaces it first and checks - that the new agent starts. If it does not, the previous agent is restored and the update - stops before any other service changes. - + {replacesAgent && ( + + This release also updates the operations agent. The update replaces it first and + checks that the new agent starts. If it does not, the previous agent is restored and + the update stops before any other service changes. + + )} + )} {pageRejections.map(rejectionAlert)} @@ -542,136 +636,144 @@ export function SystemMaintenance() {
)} - - {updateCount > 0 && ( - - )} - - - - } - > - - - - - Component - Image - Running - Downloaded - Status - - - - {(status?.images ?? []).map((image) => ( - - - - {image.name} - - - {image.id} · {image.containerState} - - - {image.image ?? "—"} - - {digest(image.runningImageId)} - - - {digest(image.localImageId)} - - {imageStatusChip(image)} - - ))} - -
-
-
- - - queueJob.mutate({ path: "updates/check", label: "Update check" })} + > + Check for updates + + + + } > - - - - Deep diagnostics - - - Runs the CLI-equivalent container health checks and in-network HTTP probes. Detailed - output is retained in Activity. + + + + + Component + Image + Running + Downloaded + Status + + + + {(status?.images ?? []).map((image) => ( + + + + {image.name} + + + {image.id} · {image.containerState} + + + {image.image ?? "—"} + + {digest(image.runningImageId)} + + + {digest(image.localImageId)} + + {imageStatusChip(image)} + + ))} + +
+
+ + )} + + {section !== "updates" && ( + <> + + + + + + Deep diagnostics + + + Runs the CLI-equivalent container health checks and in-network HTTP probes. + Detailed output is retained in Activity. + + + + + + + + + Host-control readiness -
- - -
- - - - Host-control readiness - - - {[ - ["Docker", status?.deployment.dockerAvailable], - ["Compose rendered", status?.deployment.composeRendered], - ["Host checkout mounted", status?.deployment.hostControlConfigured], - ].map(([label, ok]) => ( - : undefined} - color={ok ? "success" : "warning"} - variant="outlined" - label={label} - /> - ))} - - + + {[ + ["Docker", status?.deployment.dockerAvailable], + ["Compose rendered", status?.deployment.composeRendered], + ["Host checkout mounted", status?.deployment.hostControlConfigured], + ].map(([label, ok]) => ( + : undefined} + color={ok ? "success" : "warning"} + variant="outlined" + label={label} + /> + ))} + + + + )} ({ const first = "11111111-1111-4111-8111-111111111111", second = "22222222-2222-4222-8222-222222222222"; beforeEach(() => { + window.history.replaceState(null, "", "/admin"); vi.clearAllMocks(); sessionStorage.clear(); mocks.complete.mockResolvedValue({ @@ -42,6 +45,28 @@ beforeEach(() => { headers: new Headers(), })); }); +it.each([ + "/admin/system/deployment", + "/admin/system/configuration-transfer", + "/admin/data/transit?tab=credentials", + "/admin/transit", +])("preserves the reauthentication return route %s", async (path) => { + window.history.replaceState(null, "", path); + await startStoredAdminReauthentication("http://api.test", [ + { purpose: "account.verify", targetId: "subject-a" }, + ]); + expect(readStoredAdminReauthentication("http://api.test")?.returnPath).toBe(path); + await expect(completeStoredAdminReauthentication("http://api.test")).resolves.toBe(path); +}); +it("captures the allowed tab while dropping unrelated public or private query keys", async () => { + window.history.replaceState(null, "", "/admin/data/transit?tab=credentials&token=private"); + await startStoredAdminReauthentication("http://api.test", [ + { purpose: "account.verify", targetId: "subject-a" }, + ]); + expect(readStoredAdminReauthentication("http://api.test")?.returnPath).toBe( + "/admin/data/transit?tab=credentials", + ); +}); afterEach(cleanup); function mount(child: React.ReactNode) { return render( diff --git a/apps/web/src/lib/admin/AdminDraftProvider.test.tsx b/apps/web/src/lib/admin/AdminDraftProvider.test.tsx index 9e758c1a7..73118a9b9 100644 --- a/apps/web/src/lib/admin/AdminDraftProvider.test.tsx +++ b/apps/web/src/lib/admin/AdminDraftProvider.test.tsx @@ -1,6 +1,11 @@ import { afterEach, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@/test"; -import { AdminDraftProvider, useAdminDraftAuthority } from "./AdminDraftProvider"; +import { cleanup, fireEvent, render, screen, waitFor } from "@/test"; +import { + AdminDraftProvider, + useAdminDraftAuthority, + useAdminDraftCount, + useAdminDraftExit, +} from "./AdminDraftProvider"; import { allowAdminDocumentExit } from "./adminDocumentExit"; import { useAdminDraft } from "./useAdminDraft"; @@ -42,6 +47,34 @@ it("retains only ordinary allowlisted values across tab and soft map navigation" fireEvent.click(screen.getByText("Discard")); expect(exit()).toBe(false); }); +it("can discard an orphaned draft from the shell after its form disappears", async () => { + function Summary() { + const count = useAdminDraftCount(); + const discard = useAdminDraftExit(); + return ( + + ); + } + const view = (show: boolean) => ( + + + {show &&
} + + ); + const { rerender } = render(view(true)); + fireEvent.change(screen.getByLabelText("Label"), { target: { value: "orphaned edit" } }); + rerender(view(false)); + fireEvent.click(screen.getByRole("button", { name: "1 drafts" })); + fireEvent.click(screen.getByRole("button", { name: "Stay" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(exit()).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "1 drafts" })); + fireEvent.click(screen.getByRole("button", { name: "Discard and continue" })); + expect(await screen.findByRole("button", { name: "0 drafts" })).toBeInTheDocument(); + expect(exit()).toBe(false); +}); it("bypasses only the immediate download and restores warning for later real exits", () => { render( diff --git a/apps/web/src/lib/admin/AdminDraftProvider.tsx b/apps/web/src/lib/admin/AdminDraftProvider.tsx index 6553ba1a2..64d32252c 100644 --- a/apps/web/src/lib/admin/AdminDraftProvider.tsx +++ b/apps/web/src/lib/admin/AdminDraftProvider.tsx @@ -140,6 +140,15 @@ export function useAdminDraftExit() { const request = useContext(ExitContext); return request ?? (() => Promise.resolve(true)); } +const noDirtyDrafts = () => 0; +export function useAdminDraftCount() { + const store = useContext(AdminDraftContext); + return useSyncExternalStore( + store?.subscribe ?? noopSubscribe, + store?.dirtyCount ?? noDirtyDrafts, + noDirtyDrafts, + ); +} const noopSubscribe = () => () => {}; const emptySnapshot = () => "none"; diff --git a/apps/web/src/lib/admin/adminDraftStore.ts b/apps/web/src/lib/admin/adminDraftStore.ts index c221ae9ea..f9de15543 100644 --- a/apps/web/src/lib/admin/adminDraftStore.ts +++ b/apps/web/src/lib/admin/adminDraftStore.ts @@ -53,6 +53,7 @@ export class AdminDraftStore { get dirty() { return [...this.entries.values()].some((entry) => entry.edits.size > 0); } + dirtyCount = () => [...this.entries.values()].filter((entry) => entry.edits.size > 0).length; get cleanCount() { return [...this.entries.values()].filter((entry) => entry.edits.size === 0).length; } diff --git a/apps/web/src/lib/admin/adminJobNavigation.ts b/apps/web/src/lib/admin/adminJobNavigation.ts index 68203cbe8..9b1743926 100644 --- a/apps/web/src/lib/admin/adminJobNavigation.ts +++ b/apps/web/src/lib/admin/adminJobNavigation.ts @@ -1,71 +1,16 @@ 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", -]); +import { safeAdminNavigationReturn } from "./adminNavigation"; + 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; - } + return safeAdminNavigationReturn(value); } + export function parseAdminJobSelection( params: Pick, ): { id: string; source: AdminActivityJobSource; returnTo: string | null } | null { diff --git a/apps/web/src/lib/admin/adminNavigation.test.ts b/apps/web/src/lib/admin/adminNavigation.test.ts new file mode 100644 index 000000000..de36b2168 --- /dev/null +++ b/apps/web/src/lib/admin/adminNavigation.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "vitest"; +import { + adminDestinations, + adminPrimaryNavigation, + adminWorkspaceForPath, + legacyAdminHref, + safeAdminNavigationReturn, +} from "./adminNavigation"; + +it.each([true, null])("keeps nine primary workspaces for deployment %s", (selfHosted) => { + const nav = adminPrimaryNavigation({ role: "admin", selfHosted }); + expect(nav.map((entry) => entry.id)).toEqual([ + "overview", + "integrations", + "extensions", + "data", + "users", + "privacy", + "activity", + "settings", + "system", + ]); + expect(nav.at(-1)?.href).toBe("/admin/system/services"); + expect( + adminDestinations({ role: "admin", selfHosted }).some((entry) => entry.id === "diagnostics"), + ).toBe(true); +}); +it("keeps hosted System tools and restricts privacy administrators to casework", () => { + expect(adminPrimaryNavigation({ role: "admin", selfHosted: false }).at(-1)?.href).toBe( + "/admin/system/cache", + ); + expect( + adminDestinations({ role: "admin", selfHosted: false }) + .filter((entry) => entry.workspace === "system") + .map((entry) => entry.id), + ).toEqual(["cache", "configuration-transfer"]); + expect( + adminPrimaryNavigation({ role: "privacy_admin", selfHosted: null }).map((entry) => entry.href), + ).toEqual(["/admin/privacy"]); + expect(adminDestinations({ role: "user", selfHosted: true })).toEqual([]); +}); +it("registers only existing B-stage Data topics and assigns one active workspace", () => { + expect( + adminDestinations({ role: "admin", selfHosted: true }) + .filter((entry) => entry.workspace === "data") + .map((entry) => entry.href), + ).toEqual(["/admin/data", "/admin/data/transit", "/admin/data/coverage"]); + for (const path of [ + "/admin/services/api", + "/admin/system/services/api", + "/admin/services/compose", + ]) + expect(adminWorkspaceForPath(path)).toBe("system"); + expect(adminWorkspaceForPath("/admin/services/data")).toBe("data"); + expect(adminWorkspaceForPath("/admin/privacy-setup")).toBe("privacy"); +}); +it.each([ + ["/admin/services", "/admin/system/services"], + ["/admin/services/compose", "/admin/system/deployment"], + ["/admin/services/data", "/admin/data"], + ["/admin/services/backups", "/admin/system/backups"], + ["/admin/services/api", "/admin/system/services/api"], + ["/admin/transit", "/admin/data/transit"], + ["/admin/coverage", "/admin/data/coverage"], + ["/admin/cache", "/admin/system/cache"], +])("redirects %s retaining only public query state", (path, target) => { + expect(legacyAdminHref(path, { tab: "configuration", page: "2", token: "secret" })).toBe( + `${target}?tab=configuration&page=2`, + ); +}); +it("distinguishes the System primary from the Maintenance root alias", () => { + expect(legacyAdminHref("/admin/system", {}, true)).toBe("/admin/system/updates"); + expect(legacyAdminHref("/admin/system", {}, false)).toBe("/admin/system/cache"); +}); +it.each([ + "/admin/system/deployment", + "/admin/system/configuration-transfer", + "/admin/data/transit?tab=credentials", + "/admin/transit", + "/admin/users/a?tab=accounts", + "/admin/system/services/api?tab=configuration", +])("accepts a canonical or legacy reauth destination %s", (path) => { + expect(safeAdminNavigationReturn(path)).toBe(path); +}); +it.each([ + "/admin/data/transit?tab=credentials&token=private", + "/admin/settings/general?tab=logs", + "/admin/system/services/%2fetc", + "/admin/users/../settings", + "//evil.test/admin", + "/admin/unknown", + "/admin/users/a?tab=profile&tab=sessions", + "/admin/users/a#password", +])("rejects unsupported return state %s", (path) => + expect(safeAdminNavigationReturn(path)).toBeNull(), +); diff --git a/apps/web/src/lib/admin/adminNavigation.ts b/apps/web/src/lib/admin/adminNavigation.ts new file mode 100644 index 000000000..b8da1f817 --- /dev/null +++ b/apps/web/src/lib/admin/adminNavigation.ts @@ -0,0 +1,307 @@ +import { adminQueryHref } from "./adminNavigationState"; +export const SETTINGS_TOPICS = ["general", "authentication", "email", "map", "data-use"] as const; + +export type AdminWorkspace = + | "overview" + | "integrations" + | "extensions" + | "data" + | "users" + | "privacy" + | "activity" + | "settings" + | "system"; +export interface AdminDestination { + id: string; + label: string; + description: string; + href: string; + workspace: AdminWorkspace; + access: "admin" | "privacy"; + hostOnly?: boolean; + keywords?: readonly string[]; +} +export interface AdminNavigationAccess { + role?: string; + selfHosted?: boolean | null; +} +const destination = ( + id: string, + label: string, + description: string, + href: string, + workspace: AdminWorkspace, + hostOnly = false, +): AdminDestination => ({ id, label, description, href, workspace, hostOnly, access: "admin" }); +/** Literal destinations are also the allowlist for privileged return navigation. */ +export const ADMIN_DESTINATIONS: readonly AdminDestination[] = [ + destination("overview", "Overview", "Attention, health and current work", "/admin", "overview"), + destination( + "integrations", + "Integrations", + "Providers, configuration and credentials", + "/admin/integrations", + "integrations", + ), + destination( + "extensions", + "Extensions", + "Discover and manage installed extensions", + "/admin/extensions", + "extensions", + ), + destination( + "data", + "Data workflows", + "Acquire, build and maintain map data", + "/admin/data", + "data", + ), + destination( + "transit", + "Transit", + "Pipeline, catalog and feed credentials", + "/admin/data/transit", + "data", + ), + destination( + "coverage", + "Coverage & freshness", + "Inspect available data and its evidence", + "/admin/data/coverage", + "data", + ), + destination("users", "Users", "Accounts, sessions and recovery", "/admin/users", "users"), + { + ...destination( + "privacy", + "Privacy requests", + "Review and fulfil privacy cases", + "/admin/privacy", + "privacy", + ), + access: "privacy", + }, + destination( + "privacy-setup", + "Privacy setup", + "Operator, policies and readiness reviews", + "/admin/privacy-setup", + "privacy", + ), + destination( + "activity", + "Activity", + "Jobs, audit events and application logs", + "/admin/activity", + "activity", + ), + destination( + "general", + "General", + "Application identity and public instance URL", + "/admin/settings/general", + "settings", + ), + destination( + "authentication", + "Authentication", + "Sign-in, sessions and effective access policy", + "/admin/settings/authentication", + "settings", + ), + destination( + "email", + "Email", + "Senders, delivery providers and a test message", + "/admin/settings/email", + "settings", + ), + destination( + "map", + "Map", + "Map presentation and provider settings", + "/admin/settings/map", + "settings", + ), + destination( + "data-use", + "Data use", + "Application data-use policy", + "/admin/settings/data-use", + "settings", + ), + destination( + "services", + "Services", + "Inspect and operate application services", + "/admin/system/services", + "system", + true, + ), + destination( + "deployment", + "Deployment", + "Persistent service selection, origin and host configuration", + "/admin/system/deployment", + "system", + true, + ), + destination( + "updates", + "Updates", + "Check and update the OpenMapX release", + "/admin/system/updates", + "system", + true, + ), + destination( + "backups", + "Backups", + "Create, inspect and restore backups", + "/admin/system/backups", + "system", + true, + ), + destination( + "diagnostics", + "Diagnostics", + "Inspect host readiness and run deep checks", + "/admin/system/diagnostics", + "system", + true, + ), + destination( + "cache", + "Cache", + "Inspect and clear application caches", + "/admin/system/cache", + "system", + ), + destination( + "configuration-transfer", + "Configuration transfer", + "Export, import and migrate configuration", + "/admin/system/configuration-transfer", + "system", + ), +]; +export function adminDestinations({ role = "admin", selfHosted = null }: AdminNavigationAccess) { + return ADMIN_DESTINATIONS.filter( + (entry) => + (role === "admin" || (role === "privacy_admin" && entry.access === "privacy")) && + (!entry.hostOnly || selfHosted !== false), + ); +} +export function adminPrimaryNavigation(access: AdminNavigationAccess) { + const visible = adminDestinations(access); + return ( + [ + "overview", + "integrations", + "extensions", + "data", + "users", + "privacy", + "activity", + "settings", + "system", + ] as const + ).flatMap((workspace) => { + const entry = visible.find((item) => item.workspace === workspace); + return entry + ? [{ ...entry, id: workspace, label: workspace[0].toUpperCase() + workspace.slice(1) }] + : []; + }); +} +const LEGACY_PATHS: Record = { + "/admin/services": "/admin/system/services", + "/admin/services/compose": "/admin/system/deployment", + "/admin/services/data": "/admin/data", + "/admin/services/backups": "/admin/system/backups", + "/admin/transit": "/admin/data/transit", + "/admin/coverage": "/admin/data/coverage", + "/admin/cache": "/admin/system/cache", + "/admin/settings": "/admin/settings/general", +}; +export function legacyAdminHref( + path: string, + query: Record = {}, + selfHosted: boolean | null = null, +) { + const target = + path === "/admin/system" + ? selfHosted === false + ? "/admin/system/cache" + : "/admin/system/updates" + : (LEGACY_PATHS[path] ?? path.replace(/^\/admin\/services\//, "/admin/system/services/")); + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) + if (typeof value === "string") params.set(key, value); + return adminQueryHref(target, params, {}); +} +export function adminWorkspaceForPath(path: string): AdminWorkspace | null { + const canonical = legacyAdminHref(path); + return ( + [...ADMIN_DESTINATIONS] + .sort((a, b) => b.href.length - a.href.length) + .find( + (entry) => + canonical === entry.href || + (entry.href !== "/admin" && canonical.startsWith(`${entry.href}/`)), + )?.workspace ?? null + ); +} +export function adminDestinationForPath(path: string) { + return [...ADMIN_DESTINATIONS] + .sort((a, b) => b.href.length - a.href.length) + .find( + (entry) => + path === entry.href || (entry.href !== "/admin" && path.startsWith(`${entry.href}/`)), + ); +} +const SERVICE_TABS = ["overview", "configuration", "credentials", "logs", "manifest"]; +export function adminTabsForPath(path: string): readonly string[] { + if (/^\/admin\/(system\/)?services\/[^/]+$/.test(path)) return SERVICE_TABS; + if (/^\/admin\/users\/[^/]+$/.test(path)) return ["profile", "sessions", "accounts"]; + if (/^\/admin\/integrations\/[^/]+$/.test(path)) + return ["overview", "configuration", "credentials", "health", "sources"]; + if (path === "/admin/activity") return ["jobs", "audit", "logs"]; + if (path === "/admin/extensions") return ["browse", "installed", "sources", "local"]; + if (path === "/admin/data/transit" || path === "/admin/transit") + return ["sources", "catalog", "credentials", "pipeline", "mobility"]; + return []; +} +export function safeAdminNavigationReturn(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\/(?:system\/services|services|integrations|users)\/([^/]+)$/.exec( + url.pathname, + ); + const id = detail ? decodeURIComponent(detail[1]) : null; + const known = + ADMIN_DESTINATIONS.some((entry) => entry.href === url.pathname) || + Object.hasOwn(LEGACY_PATHS, url.pathname) || + url.pathname === "/admin/system" || + url.pathname === "/admin/integrations/bulk"; + if ( + !known && + (!id || id.length > 128 || !/^[a-zA-Z0-9_.~-]+$/.test(id) || id === "." || id === "..") + ) + return null; + if ( + [...url.searchParams.keys()].some((key) => key !== "tab") || + url.searchParams.getAll("tab").length > 1 + ) + return null; + const tab = url.searchParams.get("tab"); + if (tab !== null && !adminTabsForPath(url.pathname).includes(tab)) return null; + return url.pathname + url.search; + } catch { + return null; + } +} diff --git a/apps/web/src/lib/admin/adminNavigationState.ts b/apps/web/src/lib/admin/adminNavigationState.ts index 8f6a3a3df..1be6a64f1 100644 --- a/apps/web/src/lib/admin/adminNavigationState.ts +++ b/apps/web/src/lib/admin/adminNavigationState.ts @@ -13,6 +13,15 @@ const QUERY_KEYS = new Set([ "filter", "region", "service", + "regionId", + "snapshotId", + "domain", + "attention", + "enabled", + "assessment", + "offset", + "limit", + "regionOffset", ]); export function adminQueryHref( pathname: string, diff --git a/apps/web/src/lib/admin/adminReauthentication.ts b/apps/web/src/lib/admin/adminReauthentication.ts index 20c636dcb..bf0ec2e14 100644 --- a/apps/web/src/lib/admin/adminReauthentication.ts +++ b/apps/web/src/lib/admin/adminReauthentication.ts @@ -14,15 +14,12 @@ import { export type { AdminReauthIntent, StoredAdminReauth } from "@openmapx/core/api-contracts"; +import { adminTabsForPath, safeAdminNavigationReturn } from "./adminNavigation"; import { browserAdminApiBase } from "./browserAdminApiBase"; export const adminReauthStorageKey = (apiUrl: string) => `openmapx-admin-reauth:${browserAdminApiBase(apiUrl)}`; export function safeAdminReturnPath(path: string) { - return ( - ["/admin", "/admin/users", "/admin/settings", "/admin/integrations", "/admin/transit"].includes( - path, - ) || /^\/admin\/users\/[a-zA-Z0-9_-]{1,128}$/.test(path) - ); + return safeAdminNavigationReturn(path) !== null; } function validRequests(requests: AdminReauthIntent[]) { if (requests.length === 2) { @@ -71,7 +68,14 @@ export async function startStoredAdminReauthentication( challengeIds.push(result.challengeId); expiry = Math.min(expiry, Date.parse(result.expiresAt)); } - const path = window.location.pathname, + const pathname = window.location.pathname; + const params = new URLSearchParams(window.location.search); + const tab = params.get("tab"); + const path = + pathname + + (tab && params.getAll("tab").length === 1 && adminTabsForPath(pathname).includes(tab) + ? `?${new URLSearchParams({ tab })}` + : ""), value: StoredAdminReauth = { phase: "login", requests: parsed, diff --git a/apps/web/src/lib/admin/adminSearch.test.ts b/apps/web/src/lib/admin/adminSearch.test.ts new file mode 100644 index 000000000..284fb9522 --- /dev/null +++ b/apps/web/src/lib/admin/adminSearch.test.ts @@ -0,0 +1,60 @@ +import { expect, it } from "vitest"; +import { adminSettingHref, buildAdminSearchEntries, searchAdminEntries } from "./adminSearch"; + +const access = { role: "admin", selfHosted: true }; +it("addresses the owning legal section without creating a duplicate policy editor", () => { + expect( + adminSettingHref({ group: "legal", subgroup: "retention", key: "legalRetentionDays" }), + ).toBe("/admin/privacy-setup?field=legalRetentionDays#retention"); + expect( + adminSettingHref({ group: "legal", subgroup: "operator", key: "legalControllerName" }), + ).toBe("/admin/privacy-setup?field=legalControllerName#operator"); +}); +it.each([ + ["SMTP", "/admin/settings/email?field=smtpHost"], + ["GTFS", "/admin/data#transit-sources"], + ["backup expiry", "/admin/system/deployment#backup-retention"], + ["release channel", "/admin/system/deployment#release-selector"], +])("finds the current owning control for %s", (query, href) => { + expect(searchAdminEntries(buildAdminSearchEntries(access), query)[0]?.href).toBe(href); +}); +it("indexes only public metadata, never effective or secret values", () => { + const entries = buildAdminSearchEntries(access, { + settings: [ + { + key: "smtpHost", + group: "email", + label: "SMTP host", + value: "private-host", + secret: false, + } as never, + { + key: "smtpPassword", + group: "email", + label: "SMTP password", + value: "private-password", + secret: true, + } as never, + ], + }); + expect(searchAdminEntries(entries, "private")).toEqual([]); + expect(searchAdminEntries(entries, "SMTP host")[0]?.href).toBe( + "/admin/settings/email?field=smtpHost", + ); + expect(JSON.stringify(entries)).not.toMatch(/private-host|private-password|value|secret/); +}); +it("retains narrower access and bounds query and results", () => { + const entries = buildAdminSearchEntries({ role: "privacy_admin", selfHosted: true }); + expect(entries.map((entry) => entry.href)).toEqual(["/admin/privacy"]); + expect(searchAdminEntries(buildAdminSearchEntries(access), "").length).toBeLessThanOrEqual(30); + expect(searchAdminEntries(buildAdminSearchEntries(access), "x".repeat(201))).toEqual([]); +}); + +it("does not offer an ops-agent catalog action on hosted instances", () => { + expect( + buildAdminSearchEntries( + { role: "admin", selfHosted: false }, + { operations: [{ id: "download-osm", title: "Download OSM", description: "Download" }] }, + ).some((entry) => entry.id === "operation:download-osm"), + ).toBe(false); +}); diff --git a/apps/web/src/lib/admin/adminSearch.ts b/apps/web/src/lib/admin/adminSearch.ts new file mode 100644 index 000000000..f1469def7 --- /dev/null +++ b/apps/web/src/lib/admin/adminSearch.ts @@ -0,0 +1,143 @@ +import { SCORE_CUTOFF, scoreCommand } from "@openmapx/command-palette"; +import { type AdminNavigationAccess, adminDestinations } from "./adminNavigation"; +export interface AdminSearchEntry { + id: string; + title: string; + description: string; + href: string; + keywords?: readonly string[]; +} +export interface AdminSearchMetadata { + settings?: readonly { + key: string; + group: string; + subgroup?: string; + label: string; + description?: string; + }[]; + services?: readonly { id: string; name: string }[]; + integrations?: readonly { id: string; name: string }[]; + operations?: readonly { + id: string; + title: string; + description: string; + fields?: readonly { label: string }[]; + }[]; +} +const SETTINGS_OWNERS: Record = { + general: "general", + auth: "authentication", + email: "email", + map: "map", + policy: "data-use", +}; +export function adminSettingHref(setting: { group: string; subgroup?: string; key: string }) { + if (!/^[a-zA-Z0-9_-]{1,128}$/.test(setting.key)) return null; + if (setting.group === "legal") + return `/admin/privacy-setup?field=${encodeURIComponent(setting.key)}#${setting.subgroup === "retention" || setting.subgroup === "sources" ? "retention" : "operator"}`; + const owner = SETTINGS_OWNERS[setting.group]; + return owner ? `/admin/settings/${owner}?field=${encodeURIComponent(setting.key)}` : null; +} +export function buildAdminSearchEntries( + access: AdminNavigationAccess, + metadata: AdminSearchMetadata = {}, +): AdminSearchEntry[] { + const entries: AdminSearchEntry[] = adminDestinations(access).map((entry) => ({ + id: entry.id, + title: entry.label, + description: entry.description, + href: entry.href, + keywords: entry.keywords, + })); + if (access.role !== "admin") return entries; + entries.push({ + id: "email-smtp", + title: "SMTP", + description: "Email delivery provider", + href: "/admin/settings/email?field=smtpHost", + keywords: ["mail server", "email", "E-Mail"], + }); + if (access.selfHosted !== false) + entries.push({ + id: "transit-sources", + title: "GTFS", + description: "Transit source acquisition", + href: "/admin/data#transit-sources", + keywords: ["transit feeds", "sources", "Fahrplandaten"], + }); + if (access.selfHosted !== false) + entries.push( + { + id: "backup-retention", + title: "Backup retention", + description: "Backup expiry and retention days", + href: "/admin/system/deployment#backup-retention", + keywords: ["backup expiry", "Aufbewahrung", "Sicherung"], + }, + { + id: "release-selector", + title: "Release channel", + description: "Release selector and update channel", + href: "/admin/system/deployment#release-selector", + keywords: ["release channel", "Version", "Kanal"], + }, + ); + for (const setting of metadata.settings ?? []) { + const href = adminSettingHref(setting); + if (href) + entries.push({ + id: `setting:${setting.key}`, + title: setting.label, + description: setting.description ?? setting.group, + href, + keywords: [setting.key], + }); + } + for (const entry of metadata.integrations ?? []) + entries.push({ + id: `integration:${entry.id}`, + title: entry.name, + description: "Integration", + href: `/admin/integrations/${encodeURIComponent(entry.id)}`, + keywords: [entry.id], + }); + if (access.selfHosted !== false) + for (const entry of metadata.services ?? []) + entries.push({ + id: `service:${entry.id}`, + title: entry.name, + description: "Service", + href: `/admin/system/services/${encodeURIComponent(entry.id)}`, + keywords: [entry.id], + }); + for (const entry of access.selfHosted === false ? [] : (metadata.operations ?? [])) + entries.push({ + id: `operation:${entry.id}`, + title: entry.title, + description: entry.description, + href: `/admin/data#operation-${encodeURIComponent(entry.id)}`, + keywords: [entry.id, ...(entry.fields ?? []).map((field) => field.label)], + }); + return entries; +} +export function searchAdminEntries(entries: AdminSearchEntry[], query: string): AdminSearchEntry[] { + if (query.length > 200) return []; + if (!query.trim()) return entries.slice(0, 30); + return entries + .map((entry) => ({ + entry, + score: scoreCommand(query, { + id: entry.id, + group: "actions", + iconKey: "", + label: entry.title, + sublabel: entry.description, + keywords: [...(entry.keywords ?? [])], + run() {}, + }), + })) + .filter(({ score }) => score >= SCORE_CUTOFF) + .sort((a, b) => b.score - a.score) + .slice(0, 30) + .map(({ entry }) => entry); +} diff --git a/apps/web/src/lib/admin/serviceAdmin.ts b/apps/web/src/lib/admin/serviceAdmin.ts index 017edd0b8..7d1932a31 100644 --- a/apps/web/src/lib/admin/serviceAdmin.ts +++ b/apps/web/src/lib/admin/serviceAdmin.ts @@ -186,7 +186,7 @@ export function serviceActionReason( case "not_running": return "This service is not running."; case "control_plane": - return "Use System Maintenance for core updates; stopping the control plane requires host access."; + return "Use Updates for core updates. Stopping the control plane requires host access."; case "host_only": return "This action requires host access."; case "not_buildable": diff --git a/apps/web/src/lib/admin/useAdminFieldFocus.ts b/apps/web/src/lib/admin/useAdminFieldFocus.ts new file mode 100644 index 000000000..ccd688351 --- /dev/null +++ b/apps/web/src/lib/admin/useAdminFieldFocus.ts @@ -0,0 +1,17 @@ +"use client"; +import { useEffect } from "react"; +/** Public field IDs only; never changes form values or starts an operation. */ +export function useAdminFieldFocus(key: string | undefined, ready: boolean) { + useEffect(() => { + if (!ready || !key) return; + const timer = setTimeout(() => { + const field = document.getElementById(`setting-${key}`); + field?.scrollIntoView?.({ block: "center" }); + const input = field?.matches("input,textarea,button,[role=combobox]") + ? field + : field?.querySelector("input,textarea,button,[role=combobox]"); + input?.focus(); + }, 0); + return () => clearTimeout(timer); + }, [key, ready]); +} diff --git a/docs/docs/administration/admin-panel.md b/docs/docs/administration/admin-panel.md index 8282ada84..7a1aea77f 100644 --- a/docs/docs/administration/admin-panel.md +++ b/docs/docs/administration/admin-panel.md @@ -132,7 +132,7 @@ pipeline jobs. Pin changes require review. See ### Coverage & freshness -`/admin/coverage` reports eight domains: Addresses, POIs, Transit, EV, Parking, +`/admin/data/coverage` reports eight domains: Addresses, POIs, Transit, EV, Parking, Fuel, Cameras and Traffic. Publication, freshness, runtime, geography and rights remain separate observations. **Unknown** means evidence is missing or cannot be verified. See [Coverage & freshness](./coverage-freshness.md). @@ -206,3 +206,28 @@ Choose [Services](./services-administration.md), [Integrations](./integrations-administration.md), [Extensions](./community-extensions.md), [Users](./users-and-access.md), [Monitoring](./monitoring.md) or [Backup and restore](./backup-and-restore.md). + +## Administration navigation + +The primary navigation has nine workspaces: Overview, Integrations, Extensions, +Data, Users, Privacy, Activity, Settings and System. Settings and System provide +local topic navigation; on small screens, choose a topic from the selector. +Search administration (Cmd/Ctrl+K) finds pages, setting labels, service names and +data tasks. It navigates to a control without running the task or changing a value. + +Settings owns General, Authentication, Email, Map and Data use. Operator details, +legal facts and privacy retention belong to Privacy setup. The email readiness +step links to Settings → Email. Configuration export, import, migration and +protected credential transfer belong to System → Configuration transfer. + +System groups Services, Deployment, Updates, Backups, Diagnostics, Cache and +Configuration transfer. Explicitly hosted deployments show Cache and +Configuration transfer; host operations and the unsupported Platform editor are +hidden. When deployment status is unknown, troubleshooting topics remain visible. +Privacy administrators see privacy casework only. + +Existing Services, Transit, Coverage and Cache URLs redirect to their canonical +workspace, retaining supported public navigation state. Ordinary unsaved drafts +survive topic changes. The draft count in the top bar can discard all ordinary +drafts after confirmation, including drafts for a removed service or integration. +Draft values remain in this browser document's memory only. diff --git a/docs/docs/administration/coverage-freshness.md b/docs/docs/administration/coverage-freshness.md index fd4ae9c71..86fb55571 100644 --- a/docs/docs/administration/coverage-freshness.md +++ b/docs/docs/administration/coverage-freshness.md @@ -6,7 +6,7 @@ sidebar_position: 2 # Coverage & freshness -Open `/admin/coverage` with a full administrator account to check publication, +Open `/admin/data/coverage` with a full administrator account to check publication, freshness, runtime and source-rights evidence for a region. The report is read-only. It cannot certify geographic completeness or the legal position of your deployment. diff --git a/docs/docs/administration/monitoring.md b/docs/docs/administration/monitoring.md index 3518df2de..c7a4798cd 100644 --- a/docs/docs/administration/monitoring.md +++ b/docs/docs/administration/monitoring.md @@ -63,7 +63,7 @@ attention list; see [Admin panel](./admin-panel.md) for that landing view. ## Coverage and freshness evidence Full administrators can open [Coverage & freshness](./coverage-freshness.md) -at `/admin/coverage`. This is a read-only operator report, not a health probe +at `/admin/data/coverage`. This is a read-only operator report, not a health probe or a legal/geographic completeness claim. It combines the data-manager's bounded publication observations with passive integration and provider-health observations. Opening or refreshing it does not acquire a provider probe lease, @@ -82,7 +82,7 @@ workflows. :::note[Status vs. service catalog] The status dashboard reports whether the API can reach each dependency. It is not the Docker control plane. To start, stop, or inspect a container's lifecycle state, use the service catalog under -`/admin/services`; see [Services administration](./services-administration.md). +`/admin/system/services`; see [Services administration](./services-administration.md). ::: ## Transit provider health @@ -187,7 +187,7 @@ There are two log surfaces, and they answer different questions. ### Service container logs For "what is this backend service doing right now," open a service's **Logs** tab -(or the **Logs** button) under `/admin/services/`. It streams that container's +(or the **Logs** button) under `/admin/system/services/`. It streams that container's output live, tailing the most recent lines. It uses the same output as the equivalent `services logs` CLI command, in the browser. This is the place to watch a build finish or diagnose a container that won't come up. Full coverage is in diff --git a/docs/docs/administration/services-administration.md b/docs/docs/administration/services-administration.md index f0143902c..a9a79338f 100644 --- a/docs/docs/administration/services-administration.md +++ b/docs/docs/administration/services-administration.md @@ -9,7 +9,7 @@ sidebar_position: 2 Manage supported backend service operations from the admin panel in a browser, behind the same login. Operation availability follows the current instance and service ownership; initial installation and offline recovery require host access. The admin pages -under `/admin/services` are a window onto the service registry: they list what's +under `/admin/system/services` are a window onto the service registry: they list what's installed, show each service's manifest and live container status, let you edit config and stream logs, preview the compose file the renderer would write, and take stock of the data on disk. Under the hood these are the same operations the @@ -120,7 +120,7 @@ request. Apply an update separately when you want services to use newer images. ## A single service in detail -Open `/admin/services/`. Its header shows container state, selection and +Open `/admin/system/services/`. Its header shows container state, selection and lifecycle actions. The detail page has **Overview**, **Configuration**, **Credentials**, **Logs** and **Manifest** tabs. @@ -213,7 +213,7 @@ rather than trying to update the running API as an ordinary service job. ## Compose preview -Open `/admin/services/compose` to inspect the generated Compose YAML for the +Open `/admin/system/deployment` to inspect the generated Compose YAML for the current selection and resolved configuration. Do not edit generated files. Use the form or source configuration, then render again. @@ -260,7 +260,7 @@ no platform credentials or arbitrary environment keys. ## Data inventory -Open `/admin/services/data` to inspect OSM downloads, engine build directories, +Open `/admin/data` to inspect OSM downloads, engine build directories, transit source state and specialized publication workflows. Follow the linked jobs for failures and progress. Operation forms, previews and risk levels come from the API's operation catalog. diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 707706309..5dcc3564e 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -4566,12 +4566,12 @@ "encryption-key": { "title": "Verschlüsselungsschlüssel für Exporte", "description": "Ein nutzbarer Schlüsselring muss Exportdateien schützen.", - "action": "Systemwartung öffnen" + "action": "Diagnose öffnen" }, "artifact-storage": { "title": "Speicher für Exportdateien", "description": "Der private Speicher für Exportdateien muss funktionsfähig sein.", - "action": "Systemwartung öffnen" + "action": "Diagnose öffnen" }, "artifact-storage-backup": { "title": "Ausschluss von Exportdateien aus Sicherungen", @@ -4591,7 +4591,7 @@ "request-sla-monitor": { "title": "Überwachung der Anfragefristen", "description": "Die Fristenüberwachung muss funktionieren, damit Antworten nicht verspätet erfolgen.", - "action": "Systemwartung öffnen" + "action": "Diagnose öffnen" }, "notification-health": { "title": "Versand von Benachrichtigungen", @@ -4663,7 +4663,9 @@ "invalid": "Die Prüfdaten sind ungültig. Prüfen Sie Bereich, Daten, Entscheidung und Prüfsumme.", "generic": "Die Prüfung konnte nicht erfasst werden. Das unveränderte Formular kann sicher erneut gesendet werden." } - } + }, + "notificationsOwner": "Der E-Mail-Versand wird unter Einstellungen → E-Mail konfiguriert. Seine Bereitschaft ist in der folgenden Prüfung enthalten.", + "openEmailSettings": "E-Mail-Einstellungen öffnen" }, "privacyAdmin": { "newAssistedRequest": "Unterstützte Anfrage erfassen", @@ -4909,6 +4911,123 @@ "originChanged": "Die API hat sich geändert. Öffne den ursprünglichen Link erneut, bevor du dein Passwort festlegst.", "back": "Zurück zu OpenMapX" }, + "adminNavigation": { + "administration": "Verwaltung", + "topic": "Thema", + "topics": "{workspace}: Themen", + "primary": { + "overview": "Übersicht", + "integrations": "Integrationen", + "extensions": "Erweiterungen", + "data": "Daten", + "users": "Benutzer", + "privacy": "Datenschutz", + "activity": "Aktivität", + "settings": "Einstellungen", + "system": "System" + }, + "destinations": { + "overview": { + "label": "Übersicht", + "description": "Handlungsbedarf, Zustand und laufende Aufgaben" + }, + "integrations": { + "label": "Integrationen", + "description": "Anbieter, Konfiguration und Zugangsdaten" + }, + "extensions": { + "label": "Erweiterungen", + "description": "Erweiterungen entdecken und verwalten" + }, + "data": { + "label": "Datenabläufe", + "description": "Kartendaten beschaffen, erstellen und pflegen" + }, + "transit": { + "label": "Öffentlicher Verkehr", + "description": "Pipeline, Katalog und Feed-Zugangsdaten" + }, + "coverage": { + "label": "Abdeckung & Aktualität", + "description": "Verfügbare Daten und ihre Nachweise prüfen" + }, + "users": { + "label": "Benutzer", + "description": "Konten, Sitzungen und Wiederherstellung" + }, + "privacy": { + "label": "Datenschutzanfragen", + "description": "Datenschutzfälle prüfen und bearbeiten" + }, + "privacy-setup": { + "label": "Datenschutz einrichten", + "description": "Betreiber, Richtlinien und Freigaben" + }, + "activity": { + "label": "Aktivität", + "description": "Aufträge, Prüfereignisse und Anwendungsprotokolle" + }, + "general": { + "label": "Allgemein", + "description": "Anwendungsidentität und öffentliche Instanz-URL" + }, + "authentication": { + "label": "Authentifizierung", + "description": "Anmeldung, Sitzungen und wirksame Zugriffsrichtlinie" + }, + "email": { + "label": "E-Mail", + "description": "Absender, Versandanbieter und Testnachricht" + }, + "map": { + "label": "Karte", + "description": "Kartendarstellung und Anbietereinstellungen" + }, + "data-use": { + "label": "Datennutzung", + "description": "Richtlinie zur Datennutzung der Anwendung" + }, + "services": { + "label": "Dienste", + "description": "Anwendungsdienste prüfen und bedienen" + }, + "deployment": { + "label": "Bereitstellung", + "description": "Dauerhafte Dienstauswahl, Ursprung und Hostkonfiguration" + }, + "updates": { + "label": "Aktualisierungen", + "description": "OpenMapX-Version prüfen und aktualisieren" + }, + "backups": { + "label": "Sicherungen", + "description": "Sicherungen erstellen, prüfen und wiederherstellen" + }, + "diagnostics": { + "label": "Diagnose", + "description": "Host-Bereitschaft prüfen und tiefgehende Tests ausführen" + }, + "cache": { + "label": "Cache", + "description": "Anwendungs-Caches prüfen und leeren" + }, + "configuration-transfer": { + "label": "Konfiguration übertragen", + "description": "Konfiguration exportieren, importieren und migrieren" + } + }, + "applicationUrlHelp": "Die öffentliche Anwendungs-URL bestimmt von der Anwendung erzeugte Links. Die Bereitstellung verwaltet den Host-Ursprung und Zertifikate.", + "privacyPolicyLink": "Betreiber, rechtliche Angaben und Aufbewahrung → Datenschutz einrichten", + "drafts": "{count, plural, one {# Entwurf} other {# Entwürfe}}", + "discardDraftsHelp": "Alle ungespeicherten gewöhnlichen Verwaltungsentwürfe verwerfen, auch für nicht mehr verfügbare Formulare.", + "search": "Verwaltung durchsuchen", + "searchPlaceholder": "Einstellung, Dienst oder Aufgabe suchen", + "searchResults": "Verwaltungsziele", + "retry": "Erneut versuchen", + "partialSearch": "Einige Metadaten konnten nicht geladen werden. Die Seitenziele bleiben verfügbar.", + "noSearchResults": "Kein passendes Verwaltungsziel.", + "hostedData": "Hostverwaltete Datenaktionen sind in dieser gehosteten Bereitstellung nicht verfügbar. Transit und Abdeckung bleiben verfügbar." + }, "adminDrafts": { "title": "Nicht gespeicherte Verwaltungsänderungen verwerfen?", "ordinary": "Gewöhnliche Entwürfe werden verworfen. Bleiben Sie, um Ihre Arbeit zuerst zu prüfen.", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 71ad20bda..84ab01996 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -4566,12 +4566,12 @@ "encryption-key": { "title": "Export encryption key", "description": "A usable encryption key ring must be available to protect export artifacts.", - "action": "Open system maintenance" + "action": "Open diagnostics" }, "artifact-storage": { "title": "Export artifact storage", "description": "Private artifact storage must be healthy.", - "action": "Open system maintenance" + "action": "Open diagnostics" }, "artifact-storage-backup": { "title": "Artifact backup exclusion", @@ -4591,7 +4591,7 @@ "request-sla-monitor": { "title": "Request deadline monitor", "description": "The service-level monitor must be healthy so requests do not miss deadlines.", - "action": "Open system maintenance" + "action": "Open diagnostics" }, "notification-health": { "title": "Notification delivery", @@ -4663,7 +4663,9 @@ "invalid": "The review data is invalid. Check the scope, dates, decision, and findings digest.", "generic": "The review could not be recorded. The unchanged form can be retried safely." } - } + }, + "notificationsOwner": "Email delivery is configured in Settings → Email. Its readiness is included in the review below.", + "openEmailSettings": "Open email settings" }, "privacyAdmin": { "newAssistedRequest": "Record assisted request", @@ -4909,6 +4911,123 @@ "originChanged": "The API changed. Open the original link again before setting your password.", "back": "Back to OpenMapX" }, + "adminNavigation": { + "administration": "Administration", + "topic": "Topic", + "topics": "{workspace} topics", + "primary": { + "overview": "Overview", + "integrations": "Integrations", + "extensions": "Extensions", + "data": "Data", + "users": "Users", + "privacy": "Privacy", + "activity": "Activity", + "settings": "Settings", + "system": "System" + }, + "destinations": { + "overview": { + "label": "Overview", + "description": "Attention, health and current work" + }, + "integrations": { + "label": "Integrations", + "description": "Providers, configuration and credentials" + }, + "extensions": { + "label": "Extensions", + "description": "Discover and manage installed extensions" + }, + "data": { + "label": "Data workflows", + "description": "Acquire, build and maintain map data" + }, + "transit": { + "label": "Transit", + "description": "Pipeline, catalog and feed credentials" + }, + "coverage": { + "label": "Coverage & freshness", + "description": "Inspect available data and its evidence" + }, + "users": { + "label": "Users", + "description": "Accounts, sessions and recovery" + }, + "privacy": { + "label": "Privacy requests", + "description": "Review and fulfil privacy cases" + }, + "privacy-setup": { + "label": "Privacy setup", + "description": "Operator, policies and readiness reviews" + }, + "activity": { + "label": "Activity", + "description": "Jobs, audit events and application logs" + }, + "general": { + "label": "General", + "description": "Application identity and public instance URL" + }, + "authentication": { + "label": "Authentication", + "description": "Sign-in, sessions and effective access policy" + }, + "email": { + "label": "Email", + "description": "Senders, delivery providers and a test message" + }, + "map": { + "label": "Map", + "description": "Map presentation and provider settings" + }, + "data-use": { + "label": "Data use", + "description": "Application data-use policy" + }, + "services": { + "label": "Services", + "description": "Inspect and operate application services" + }, + "deployment": { + "label": "Deployment", + "description": "Persistent service selection, origin and host configuration" + }, + "updates": { + "label": "Updates", + "description": "Check and update the OpenMapX release" + }, + "backups": { + "label": "Backups", + "description": "Create, inspect and restore backups" + }, + "diagnostics": { + "label": "Diagnostics", + "description": "Inspect host readiness and run deep checks" + }, + "cache": { + "label": "Cache", + "description": "Inspect and clear application caches" + }, + "configuration-transfer": { + "label": "Configuration transfer", + "description": "Export, import and migrate configuration" + } + }, + "applicationUrlHelp": "The public application URL identifies links generated by the application. Deployment manages the host origin and certificates.", + "privacyPolicyLink": "Operator, legal and retention policy → Privacy setup", + "drafts": "{count, plural, one {# draft} other {# drafts}}", + "discardDraftsHelp": "Discard all unsaved ordinary administration drafts, including forms that are no longer available.", + "search": "Search administration", + "searchPlaceholder": "Find a setting, service or task", + "searchResults": "Administration destinations", + "retry": "Retry", + "partialSearch": "Some metadata could not be loaded. Page destinations are still available.", + "noSearchResults": "No matching administration destination.", + "hostedData": "Host-managed data operations are unavailable on this hosted deployment. Transit and Coverage remain available." + }, "adminDrafts": { "title": "Discard unsaved administration changes?", "ordinary": "Ordinary drafts will be discarded. Stay to review your work first.", From 389666d0e989609b082da2d11855839519c50450 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 17:28:56 +0100 Subject: [PATCH 2/3] fix(admin): keep privacy setup step links within the document --- .../app/admin/(full-admin)/privacy-setup/PrivacySetupPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/admin/(full-admin)/privacy-setup/PrivacySetupPage.tsx b/apps/web/src/app/admin/(full-admin)/privacy-setup/PrivacySetupPage.tsx index 224e9d6df..f080201c5 100644 --- a/apps/web/src/app/admin/(full-admin)/privacy-setup/PrivacySetupPage.tsx +++ b/apps/web/src/app/admin/(full-admin)/privacy-setup/PrivacySetupPage.tsx @@ -127,7 +127,7 @@ export function PrivacySetupPage() { {STEP_IDS.map((id) => ( - ))} From 93e82f765b783b564f10f04e131d66ae5c9933de Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 6 Nov 2026 07:48:11 +0100 Subject: [PATCH 3/3] test(admin): capture retired coordinator candidates atomically --- .../coordinator-lifecycle-postgres.test.ts | 26 +++++-------------- .../fixtures/native-coordinator-api.ts | 12 ++++----- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/apps/api/src/services/__tests__/coordinator-lifecycle-postgres.test.ts b/apps/api/src/services/__tests__/coordinator-lifecycle-postgres.test.ts index 228880470..622dc605f 100644 --- a/apps/api/src/services/__tests__/coordinator-lifecycle-postgres.test.ts +++ b/apps/api/src/services/__tests__/coordinator-lifecycle-postgres.test.ts @@ -3029,28 +3029,10 @@ describe.skipIf(process.env.OPENMAPX_RUN_DATABASE_TESTS !== "1")( expect(JSON.stringify(unchanged!.result)).toBe(failed); return; } - let retainedBytes: Buffer | undefined; const unlinkBoundary = async () => { const marker = join(host.root, "coordinator-retirement-unlinked"), until = Date.now() + 150000; while (!existsSync(marker)) { - const [observed] = await fixture.db - .select() - .from(schema.adminJob) - .where(eq(schema.adminJob.id, id)), - candidate = ( - observed?.result as - | { _configurationCandidate?: { sealedOperationKey?: string } } - | undefined - )?._configurationCandidate; - if (candidate?.sealedOperationKey) { - const path = join( - host.queue, - ".candidates", - `${candidate.sealedOperationKey}.json`, - ); - if (existsSync(path)) retainedBytes = readFileSync(path); - } if (Date.now() > until) throw Error("Actual retirement unlink boundary missing"); await delay(10); } @@ -3068,7 +3050,11 @@ describe.skipIf(process.env.OPENMAPX_RUN_DATABASE_TESTS !== "1")( prior = before!.result as Record, candidate = prior._configurationCandidate as Record, path = join(host.queue, ".candidates", `${candidate.sealedOperationKey}.json`), - bytes = point === "candidate unlink" ? retainedBytes! : readFileSync(path), + // The native unlink hook publishes these exact bytes only after unlink succeeds. + bytes = + point === "candidate unlink" + ? readFileSync(join(host.root, "coordinator-retirement-unlinked")) + : readFileSync(path), decision = JSON.stringify(prior._configurationMaintenanceDecision), inspected = inspectPreparedConfigurationRecord(bytes, host.token).record, originalNormal = @@ -3085,7 +3071,7 @@ describe.skipIf(process.env.OPENMAPX_RUN_DATABASE_TESTS !== "1")( point === "candidate unlink" ? "settled" : !pullBoundary ? "saved" : "prepared", ); if (point === "candidate unlink") { - expect(bytes).toBeDefined(); + expect(inspected.operationKey).toBe(candidate.sealedOperationKey); expect(existsSync(path)).toBe(false); expect(candidate.retirement).toMatchObject({ version: 1, integrationIds: [] }); expect(posts.get("configurationMaintenance.settle")).toBe(1); diff --git a/apps/api/src/services/__tests__/fixtures/native-coordinator-api.ts b/apps/api/src/services/__tests__/fixtures/native-coordinator-api.ts index 6df74caa9..4c8f647b7 100644 --- a/apps/api/src/services/__tests__/fixtures/native-coordinator-api.ts +++ b/apps/api/src/services/__tests__/fixtures/native-coordinator-api.ts @@ -2,7 +2,7 @@ * reply delivery and kills this native process; no DB/service/authority mocks. */ import { randomUUID } from "node:crypto"; -import { constants, writeFileSync } from "node:fs"; +import { constants, readFileSync, renameSync, writeFileSync } from "node:fs"; import filesystem from "node:fs/promises"; import { syncBuiltinESMExports } from "node:module"; import { join } from "node:path"; @@ -36,12 +36,12 @@ if (input.filesystemFault === "after-retirement-unlink") { const unlink = filesystem.unlink, directory = join(process.env.OPS_TRUSTED_CONFIG_DIR!, ".candidates") + "/"; filesystem.unlink = async (path) => { + const candidate = String(path).startsWith(directory) ? readFileSync(path) : undefined; await unlink(path); - if (String(path).startsWith(directory)) { - writeFileSync( - join(process.env.OPENMAPX_ROOT_DIR!, "coordinator-retirement-unlinked"), - "unlinked", - ); + if (candidate) { + const marker = join(process.env.OPENMAPX_ROOT_DIR!, "coordinator-retirement-unlinked"); + writeFileSync(`${marker}.partial`, candidate, { mode: 0o600, flag: "wx" }); + renameSync(`${marker}.partial`, marker); await new Promise(() => {}); } };