Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import Stack from "@mui/material/Stack";
import type { Metadata } from "next";
import { BackupsPage } from "@/components/admin/services/BackupsPage";
import { PlatformConfiguration } from "@/components/admin/settings/PlatformConfiguration";
export const metadata: Metadata = { title: "Backups — Admin — OpenMapX" };
export default function Page() {
return <BackupsPage />;
return (
<Stack sx={{ gap: 2 }}>
<BackupsPage />
<PlatformConfiguration scope="retention" />
</Stack>
);
}
19 changes: 17 additions & 2 deletions apps/web/src/app/admin/(full-admin)/system/deployment/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import Accordion from "@mui/material/Accordion";
import AccordionDetails from "@mui/material/AccordionDetails";
import AccordionSummary from "@mui/material/AccordionSummary";
import Stack from "@mui/material/Stack";
import { ComposePreview } from "@/components/admin/services/ComposePreview";
import { ServiceSelectionEditor } from "@/components/admin/services/ServiceSelectionEditor";
import { PlatformConfiguration } from "@/components/admin/settings/PlatformConfiguration";
import { AdminPageHeader } from "@/components/admin/shared/AdminPageHeader";
export default function Page() {
return (
<Stack sx={{ gap: 2 }}>
<PlatformConfiguration />
<ComposePreview />
<AdminPageHeader
title="Deployment"
subtitle="Persistent service roots, public origin and host configuration"
/>
<ServiceSelectionEditor />
<PlatformConfiguration scope="deployment" />
<Accordion variant="outlined" disableGutters>
<AccordionSummary>Compose and host recovery</AccordionSummary>
<AccordionDetails>
<ComposePreview />
</AccordionDetails>
</Accordion>
</Stack>
);
}
11 changes: 10 additions & 1 deletion apps/web/src/app/admin/(full-admin)/system/updates/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import Stack from "@mui/material/Stack";
import { PlatformConfiguration } from "@/components/admin/settings/PlatformConfiguration";
import { AdminPageHeader } from "@/components/admin/shared/AdminPageHeader";
import { SystemMaintenance } from "@/components/admin/system/SystemMaintenance";
export default function Page() {
return <SystemMaintenance section="updates" />;
return (
<Stack sx={{ gap: 2 }}>
<AdminPageHeader title="Updates" subtitle="Select, check and apply an OpenMapX release" />
<PlatformConfiguration scope="release" />
<SystemMaintenance section="updates" showHeader={false} />
</Stack>
);
}
68 changes: 68 additions & 0 deletions apps/web/src/components/admin/AdminLayout.navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { en } from "@openmapx/i18n";
import { NextIntlClientProvider } from "next-intl";
import { useContext } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, expect, it, vi } from "vitest";
import { AdminDraftContext, AdminDraftProvider } from "@/lib/admin/AdminDraftProvider";
import { act, cleanup, createQueryWrapper, fireEvent, render, screen } from "@/test";
import { adminTestHistory } from "@/test/adminNavigation";
import { AdminLayout } from "./AdminLayout";

vi.mock("@/integration-api/runtime/EnvProvider", () => ({
Expand All @@ -16,6 +19,71 @@ afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it.each([false, null])("guards direct host-topic entry for deployment %s", (selfHosted) => {
adminTestHistory.reset("/admin/system/deployment");
vi.stubGlobal("matchMedia", () => ({
matches: true,
addEventListener() {},
removeEventListener() {},
}));
render(
<NextIntlClientProvider locale="en" messages={en}>
<AdminLayout
user={{ id: "admin", name: "Example", email: "admin@example.test", role: "admin" }}
selfHosted={selfHosted}
>
<button type="button">Host mutation</button>
</AdminLayout>
</NextIntlClientProvider>,
{ wrapper: createQueryWrapper() },
);
if (selfHosted === false) {
expect(screen.getByRole("combobox", { name: "Topic" })).toHaveTextContent("Choose a topic");
expect(screen.queryByRole("button", { name: "Host mutation" })).toBeNull();
expect(screen.getAllByRole("link", { name: "Cache" }).length).toBeGreaterThan(0);
} else expect(screen.getByRole("button", { name: "Host mutation" })).toBeInTheDocument();
});
it("retires the admin view and navigation after a confirmed authority change", async () => {
function Child() {
const store = useContext(AdminDraftContext);
return (
<button
type="button"
onClick={() =>
store?.observeSession({
data: { user: { id: "admin", role: "privacy_admin" }, session: { id: "session" } },
isPending: false,
})
}
>
Change authority
</button>
);
}
vi.stubGlobal("matchMedia", () => ({
matches: true,
addEventListener() {},
removeEventListener() {},
}));
render(
<NextIntlClientProvider locale="en" messages={en}>
<AdminDraftProvider>
<AdminLayout
user={{ id: "admin", name: "Example", email: "admin@example.test", role: "admin" }}
selfHosted={true}
>
<Child />
</AdminLayout>
</AdminDraftProvider>
</NextIntlClientProvider>,
{ wrapper: createQueryWrapper() },
);
fireEvent.click(await screen.findByRole("button", { name: "Change authority" }));
expect(screen.queryByRole("button", { name: "Change authority" })).toBeNull();
expect(screen.queryByRole("link", { name: "System" })).toBeNull();
expect(screen.queryByRole("button", { name: "Administration jobs" })).toBeNull();
expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument();
});
it("keeps the desktop sidebar preference independent of mobile drawer navigation", () => {
let wide = true;
const listeners = new Set<() => void>();
Expand Down
183 changes: 118 additions & 65 deletions apps/web/src/components/admin/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
"use client";

import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import useMediaQuery from "@mui/material/useMediaQuery";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { useEnv } from "@/integration-api/runtime/EnvProvider";
import { useBindAdminDraftOwner } from "@/lib/admin/AdminDraftProvider";
import { useAdminDraftAuthority, useBindAdminDraftOwner } from "@/lib/admin/AdminDraftProvider";
import { AdminJobEffectsProvider } from "@/lib/admin/adminJobEffects";
import { adminWorkspaceForPath } from "@/lib/admin/adminNavigation";
import { adminDestinationForPath, adminWorkspaceForPath } from "@/lib/admin/adminNavigation";
import { useHydrated } from "@/lib/useHydrated";
import { AdminSidebar } from "./AdminSidebar";
import { AdminTopBar } from "./AdminTopBar";
import { TOPBAR_HEIGHT } from "./layoutConstants";
import { PlatformPreviewLifecycle } from "./settings/PlatformPreviewLifecycle";
import { AdminNavigationContext } from "./shared/AdminNavigationContext";
import { AdminPageHeader } from "./shared/AdminPageHeader";
import { AdminThemeProvider } from "./shared/AdminThemeProvider";
import { AdminToastProvider } from "./shared/AdminToast";
import { AdminTopicNavigation } from "./shared/AdminTopicNavigation";
Expand Down Expand Up @@ -41,8 +49,19 @@ export function AdminLayout(props: AdminLayoutProps) {
}
function AdminLayoutContent({ children, user, selfHosted = false }: AdminLayoutProps) {
const { apiUrl } = useEnv();
const workspace = adminWorkspaceForPath(usePathname());
const pathname = usePathname();
const workspace = adminWorkspaceForPath(pathname);
const destination = adminDestinationForPath(pathname);
const hostUnavailable = destination?.hostOnly && selfHosted === false;
const t = useTranslations("adminNavigation");
useBindAdminDraftOwner({ apiUrl, actorId: user.id, role: user.role ?? "admin" });
const authority = useAdminDraftAuthority();
const authorized =
!authority.present ||
(authority.owner?.apiUrl === apiUrl &&
authority.owner?.actorId === user.id &&
authority.owner?.role === (user.role ?? "admin"));
const visibleRole = authorized ? (user.role ?? "admin") : "";
const hydrated = useHydrated();
const desktop = useMediaQuery("(min-width:900px)", { defaultMatches: true });
const [desktopOpen, setDesktopOpen] = useState(true);
Expand All @@ -54,83 +73,117 @@ function AdminLayoutContent({ children, user, selfHosted = false }: AdminLayoutP
}, [desktop]);

return (
<AdminThemeProvider>
<AdminToastProvider>
<AdminJobEffectsProvider enabled={user.role === "admin"}>
<Box
className="omx-admin"
sx={{ display: "flex", height: "100dvh", bgcolor: "background.default" }}
>
<AdminSidebar
open={sidebarOpen}
onClose={() => setMobileOpen(false)}
selfHosted={selfHosted}
role={user.role}
/>
<AdminNavigationContext.Provider value={{ role: visibleRole, selfHosted }}>
<AdminThemeProvider>
<AdminToastProvider>
<AdminJobEffectsProvider enabled={authorized && user.role === "admin"}>
<Box
component="main"
sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
minWidth: 0,
}}
className="omx-admin"
sx={{ display: "flex", height: "100dvh", bgcolor: "background.default" }}
>
<AdminTopBar
user={user}
<PlatformPreviewLifecycle apiUrl={apiUrl} actorId={user.id} />
<AdminSidebar
open={sidebarOpen}
onClose={() => setMobileOpen(false)}
selfHosted={selfHosted}
sidebarOpen={sidebarOpen}
onToggleSidebar={toggleSidebar}
role={visibleRole}
/>
<Box
component="main"
sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
mt: `calc(${TOPBAR_HEIGHT}px + var(--omx-safe-top))`,
overflowY: "auto",
minWidth: 0,
}}
>
<AdminTopBar
user={{ ...user, role: visibleRole }}
selfHosted={selfHosted}
sidebarOpen={sidebarOpen}
onToggleSidebar={toggleSidebar}
/>
<Box
sx={{
width: "100%",
maxWidth: 1536,
mx: "auto",
px: { xs: 1.5, sm: 2, lg: 3 },
py: { xs: 2, lg: 2.5 },
flexGrow: 1,
mt: `calc(${TOPBAR_HEIGHT}px + var(--omx-safe-top))`,
overflowY: "auto",
}}
>
{hydrated ? (
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", lg: "row" },
gap: { xs: 1, lg: 2.5 },
alignItems: "flex-start",
}}
>
{workspace && (
<AdminTopicNavigation
workspace={workspace}
role={user.role}
selfHosted={selfHosted}
/>
)}
<Box sx={{ flex: 1, width: "100%", minWidth: 0 }}>{children}</Box>
</Box>
) : (
<Box
role="status"
aria-label="Loading administration"
sx={{ display: "flex", justifyContent: "center", py: 6 }}
>
<CircularProgress size={24} />
</Box>
)}
<Box
sx={{
width: "100%",
maxWidth: 1536,
mx: "auto",
px: { xs: 1.5, sm: 2, lg: 3 },
py: { xs: 2, lg: 2.5 },
}}
>
{hydrated ? (
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", lg: "row" },
gap: { xs: 1, lg: 2.5 },
alignItems: "flex-start",
}}
>
{workspace && (
<AdminTopicNavigation
workspace={workspace}
role={visibleRole}
selfHosted={selfHosted}
/>
)}
<Box sx={{ flex: 1, width: "100%", minWidth: 0 }}>
{!authorized ? (
<Alert
severity="warning"
action={
<Button onClick={() => window.location.reload()}>
{t("reload")}
</Button>
}
>
{t("authorityChanged")}
</Alert>
) : hostUnavailable && destination ? (
<Stack sx={{ gap: 2, maxWidth: 800 }}>
<AdminPageHeader title={t(`destinations.${destination.id}.label`)} />
<Alert severity="info">{t("hostUnavailable")}</Alert>
<Stack direction="row" sx={{ gap: 1, flexWrap: "wrap" }}>
<Button component={Link} href="/admin/system/cache">
{t("destinations.cache.label")}
</Button>
<Button
component={Link}
href="/admin/system/configuration-transfer"
>
{t("destinations.configuration-transfer.label")}
</Button>
</Stack>
</Stack>
) : (
children
)}
</Box>
</Box>
) : (
<Box
role="status"
aria-label="Loading administration"
sx={{ display: "flex", justifyContent: "center", py: 6 }}
>
<CircularProgress size={24} />
</Box>
)}
</Box>
</Box>
</Box>
</Box>
</Box>
</AdminJobEffectsProvider>
</AdminToastProvider>
</AdminThemeProvider>
</AdminJobEffectsProvider>
</AdminToastProvider>
</AdminThemeProvider>
</AdminNavigationContext.Provider>
);
}
4 changes: 4 additions & 0 deletions apps/web/src/components/admin/AdminTopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ export function AdminTopBar({ user, selfHosted, sidebarOpen, onToggleSidebar }:
flex: 1,
minWidth: 0,
"& .MuiBreadcrumbs-ol": { flexWrap: "nowrap" },
"& .MuiBreadcrumbs-li": { minWidth: 0 },
"& .MuiBreadcrumbs-li:not(:last-child), & .MuiBreadcrumbs-separator": {
display: { xs: "none", sm: "block" },
},
}}
>
{breadcrumbs.map((crumb, i) => {
Expand Down
Loading
Loading