WebUI module split; docs
This commit is contained in:
@@ -1,26 +1,18 @@
|
||||
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { fetchMe } from "./api/auth";
|
||||
import { fetchPlatformModules } from "./api/platform";
|
||||
import { loadApiSettings, saveApiSettings } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo } from "./types";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
import { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary";
|
||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||
import { adminReadScopes } from "./utils/permissions";
|
||||
import { firstAccessibleRoute, navItemsForModules, resolveInstalledWebModules } from "./platform/modules";
|
||||
import PlaceholderPage from "./features/PlaceholderPage";
|
||||
import { getCampaign } from "@govoplan/campaign-webui";
|
||||
import { firstAccessibleRoute, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
|
||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||
const CampaignListPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.CampaignListPage })));
|
||||
const CampaignWorkspace = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.CampaignWorkspace })));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
||||
const TemplatesPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.TemplatesPage })));
|
||||
const FilesPage = lazy(() => import("@govoplan/files-webui").then((module) => ({ default: module.FilesPage })));
|
||||
const OperatorQueuePage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.OperatorQueuePage })));
|
||||
const AddressBookPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.AddressBookPage })));
|
||||
|
||||
export default function App() {
|
||||
const [settings, setSettings] = useState<ApiSettings>(() => loadApiSettings());
|
||||
@@ -30,6 +22,7 @@ export default function App() {
|
||||
|
||||
const webModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||
|
||||
function updateSettings(next: ApiSettings) {
|
||||
setSettings(next);
|
||||
@@ -152,33 +145,17 @@ export default function App() {
|
||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/campaigns" element={
|
||||
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:read"]} fallback={defaultRoute}>
|
||||
<CampaignListPage settings={settings} />
|
||||
</PermissionBoundary>
|
||||
} />
|
||||
<Route path="/campaigns/:campaignId/*" element={
|
||||
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:read"]} fallback={defaultRoute}>
|
||||
<CampaignResourceRoute settings={settings} auth={auth} />
|
||||
</PermissionBoundary>
|
||||
} />
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/files" element={
|
||||
<PermissionBoundary auth={auth} anyOf={["files:file:read"]} fallback={defaultRoute}>
|
||||
<FilesPage settings={settings} auth={auth} />
|
||||
</PermissionBoundary>
|
||||
} />
|
||||
<Route path="/address-book" element={<AddressBookPage />} />
|
||||
<Route path="/operator" element={
|
||||
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"]} fallback={defaultRoute}>
|
||||
<OperatorQueuePage settings={settings} />
|
||||
</PermissionBoundary>
|
||||
} />
|
||||
<Route path="/reports" element={
|
||||
<PermissionBoundary auth={auth} anyOf={["campaigns:report:read"]} fallback={defaultRoute}>
|
||||
<PlaceholderPage title="Reports" />
|
||||
</PermissionBoundary>
|
||||
} />
|
||||
{moduleRoutes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={
|
||||
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
|
||||
{route.render({ settings, auth })}
|
||||
</PermissionBoundary>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
|
||||
<Route path="/admin" element={
|
||||
<PermissionBoundary auth={auth} anyOf={adminReadScopes} fallback={defaultRoute}>
|
||||
@@ -191,15 +168,3 @@ export default function App() {
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CampaignResourceRoute({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
const { campaignId = "" } = useParams();
|
||||
const tenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
const probe = useCallback(() => getCampaign(settings, campaignId), [settings.apiBaseUrl, settings.apiKey, campaignId, tenantId]);
|
||||
|
||||
return (
|
||||
<ResourceAccessBoundary probe={probe} resetKey={`${tenantId}:${campaignId}`} fallback="/campaigns">
|
||||
<CampaignWorkspace settings={settings} auth={auth} />
|
||||
</ResourceAccessBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,20 @@ import { Navigate } from "react-router-dom";
|
||||
import type { AuthInfo } from "../types";
|
||||
import { isApiError } from "../api/client";
|
||||
import DismissibleAlert from "./DismissibleAlert";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||
|
||||
type PermissionBoundaryProps = {
|
||||
auth: AuthInfo;
|
||||
anyOf: string[];
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
fallback: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function PermissionBoundary({ auth, anyOf, fallback, children }: PermissionBoundaryProps) {
|
||||
return hasAnyScope(auth, anyOf) ? <>{children}</> : <Navigate to={fallback} replace />;
|
||||
export function PermissionBoundary({ auth, anyOf = [], allOf = [], fallback, children }: PermissionBoundaryProps) {
|
||||
if (allOf.length && !allOf.every((scope) => hasScope(auth, scope))) return <Navigate to={fallback} replace />;
|
||||
if (anyOf.length && !hasAnyScope(auth, anyOf)) return <Navigate to={fallback} replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
type ResourceAccessBoundaryProps = {
|
||||
|
||||
@@ -670,7 +670,7 @@ export default function DataGrid<T>({
|
||||
startX: event.clientX,
|
||||
startWidth: currentWidth,
|
||||
minWidth: effectiveColumnMinWidth(column),
|
||||
maxWidth: Math.max(effectiveColumnMinWidth(column), column.maxWidth ?? 2000),
|
||||
maxWidth: effectiveColumnMaxWidth(column),
|
||||
baseWidths,
|
||||
rightResizeTargets,
|
||||
bufferTarget: canUseBuffer ? {
|
||||
@@ -1223,7 +1223,7 @@ function sanitizePersistedColumnState<T>(
|
||||
const column = columnMap.get(columnId);
|
||||
if (!column || !Number.isFinite(width)) return [];
|
||||
const minimum = effectiveColumnMinWidth(column);
|
||||
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
|
||||
const maximum = effectiveColumnMaxWidth(column, minimum);
|
||||
return [[columnId, Math.min(maximum, Math.max(minimum, width))]];
|
||||
})
|
||||
);
|
||||
@@ -1246,7 +1246,7 @@ function sanitizePersistedColumnState<T>(
|
||||
|
||||
function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number): string {
|
||||
const minimum = effectiveColumnMinWidth(column);
|
||||
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
|
||||
const maximum = effectiveColumnMaxWidth(column, minimum);
|
||||
if (savedWidth !== undefined) return `${Math.min(maximum, Math.max(minimum, savedWidth))}px`;
|
||||
if (typeof column.width === "number") return `${Math.min(maximum, Math.max(minimum, column.width))}px`;
|
||||
if (column.width) return columnTrackWithMinimum(column.width, minimum);
|
||||
@@ -1291,7 +1291,7 @@ function resizeTargetForColumn<T>(column: DataGridColumn<T>, baseWidths: Record<
|
||||
columnId: column.id,
|
||||
startWidth: baseWidths[column.id] ?? columnPixelWidth(column),
|
||||
minWidth: minimum,
|
||||
maxWidth: Math.max(minimum, column.maxWidth ?? 2000)
|
||||
maxWidth: effectiveColumnMaxWidth(column, minimum)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1303,6 +1303,11 @@ function effectiveColumnMinWidth<T>(column: DataGridColumn<T>): number {
|
||||
return Math.max(column.minWidth ?? 0, affordanceWidth);
|
||||
}
|
||||
|
||||
function effectiveColumnMaxWidth<T>(column: DataGridColumn<T>, minimum = effectiveColumnMinWidth(column)): number {
|
||||
if (column.maxWidth !== undefined) return Math.max(minimum, column.maxWidth);
|
||||
return Math.max(minimum, isFlexibleWidth(column.width) ? BUFFER_MAX_WIDTH : 2000);
|
||||
}
|
||||
|
||||
function totalColumnWidth<T>(columns: DataGridColumn<T>[], widths: Record<string, number>): number {
|
||||
return columns.reduce((total, column) => total + (widths[column.id] ?? columnPixelWidth(column)), 0);
|
||||
}
|
||||
@@ -1450,7 +1455,7 @@ function isFlexibleWidth(width?: string | number): boolean {
|
||||
|
||||
function columnPixelWidth<T>(column: DataGridColumn<T>, savedWidth?: number, measuredWidth?: number): number {
|
||||
const minimum = effectiveColumnMinWidth(column);
|
||||
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
|
||||
const maximum = effectiveColumnMaxWidth(column, minimum);
|
||||
const configured = measuredWidth
|
||||
?? savedWidth
|
||||
?? (typeof column.width === "number" ? column.width : parsePixelWidth(column.width))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Activity, FileText, Folder, Form, LayoutDashboard, MailCheck, Users, ty
|
||||
import campaignModule from "@govoplan/campaign-webui";
|
||||
import filesModule from "@govoplan/files-webui";
|
||||
import mailModule from "@govoplan/mail-webui";
|
||||
import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
||||
import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
|
||||
|
||||
export const shellNavItems: PlatformNavItem[] = [
|
||||
@@ -73,6 +73,10 @@ export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavIte
|
||||
return [...shellNavItems, ...modules.flatMap((module) => module.navItems ?? [])].sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = legacyModules): PlatformNavItem[] {
|
||||
return navItemsForModules(modules).filter((item) => {
|
||||
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||
|
||||
@@ -122,6 +122,7 @@ export type PlatformNavItem = {
|
||||
};
|
||||
|
||||
export type PlatformRouteContext = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
};
|
||||
|
||||
@@ -129,6 +130,7 @@ export type PlatformRouteContribution = {
|
||||
path: string;
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
order?: number;
|
||||
render: (context: PlatformRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user