feat(core): support explicit public module routes
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
|
||||
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
import LoginModal from "./features/auth/LoginModal";
|
||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||
import { firstAccessibleRoute, loadRemoteWebModules, moduleInstalled, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||
@@ -30,7 +30,9 @@ export default function App() {
|
||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||
const [checkingSession, setCheckingSession] = useState(true);
|
||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
|
||||
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
||||
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
|
||||
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
||||
const [backendReachable, setBackendReachable] = useState(true);
|
||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||
@@ -38,9 +40,13 @@ export default function App() {
|
||||
|
||||
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
||||
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||
const moduleTranslations = useMemo(() => webModules.map((module) => module.translations).filter(Boolean), [webModules]);
|
||||
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
||||
const contextModules = auth ? webModules : publicWebModules;
|
||||
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
||||
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
||||
|
||||
function updateSettings(next: ApiSettings) {
|
||||
@@ -119,6 +125,14 @@ export default function App() {
|
||||
};
|
||||
}, [settings.apiBaseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchPlatformPublicModules(settings).
|
||||
then((response) => {if (!cancelled) setPlatformPublicModules(response.modules);}).
|
||||
catch(() => {if (!cancelled) setPlatformPublicModules(null);});
|
||||
return () => {cancelled = true;};
|
||||
}, [settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCheckingSession(true);
|
||||
@@ -233,6 +247,18 @@ export default function App() {
|
||||
return () => {cancelled = true;};
|
||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!platformPublicModules?.length) {
|
||||
setRemotePublicWebModules([]);
|
||||
return () => {cancelled = true;};
|
||||
}
|
||||
loadRemotePublicWebModules(platformPublicModules, localPublicWebModules).
|
||||
then((modules) => {if (!cancelled) setRemotePublicWebModules(modules);}).
|
||||
catch(() => {if (!cancelled) setRemotePublicWebModules([]);});
|
||||
return () => {cancelled = true;};
|
||||
}, [platformPublicModules, localPublicWebModules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth) return;
|
||||
|
||||
@@ -284,7 +310,7 @@ export default function App() {
|
||||
if (checkingSession) {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<div className="public-landing">
|
||||
@@ -304,10 +330,21 @@ export default function App() {
|
||||
if (!auth) {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
|
||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||
<Routes>
|
||||
{publicRoutes.map((route) =>
|
||||
<Route
|
||||
key={`public:${route.path}`}
|
||||
path={route.path}
|
||||
element={route.render({ settings, auth: null, onAuthChange: updateAuth })}
|
||||
/>
|
||||
)}
|
||||
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
@@ -342,6 +379,13 @@ export default function App() {
|
||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
||||
{publicRoutes.map((route) =>
|
||||
<Route
|
||||
key={`public:${route.path}`}
|
||||
path={route.path}
|
||||
element={route.render({ settings, auth, onAuthChange: updateAuth })}
|
||||
/>
|
||||
)}
|
||||
{moduleRoutes.map((route) =>
|
||||
<Route
|
||||
key={route.path}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import type { PlatformModuleInfo } from "../types";
|
||||
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
||||
export type PlatformPublicModulesResponse = { modules: PlatformPublicModuleInfo[] };
|
||||
|
||||
export type PlatformStatusResponse = {
|
||||
maintenance_mode: {
|
||||
@@ -38,6 +39,10 @@ export async function fetchPlatformModules(settings: ApiSettings): Promise<Platf
|
||||
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
|
||||
}
|
||||
|
||||
export async function fetchPlatformPublicModules(settings: ApiSettings): Promise<PlatformPublicModulesResponse> {
|
||||
return apiFetch<PlatformPublicModulesResponse>(settings, "/api/v1/platform/public-modules");
|
||||
}
|
||||
|
||||
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
|
||||
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||
import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||
|
||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
||||
for (const module of modules) {
|
||||
@@ -32,3 +32,7 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
|
||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {
|
||||
return modules.flatMap((module) => module.publicRoutes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
|
||||
import installedWebModules from "virtual:govoplan-installed-modules";
|
||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformWebModule } from "../types";
|
||||
import {
|
||||
hasUiCapability as hasUiCapabilityForModules,
|
||||
moduleInstalled as moduleInstalledForModules,
|
||||
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
||||
publicRouteContributionsForModules as publicRouteContributionsForModuleList,
|
||||
routeContributionsForModules as routeContributionsForModuleList,
|
||||
uiCapabilities as uiCapabilitiesForModules,
|
||||
uiCapability as uiCapabilityForModules } from
|
||||
@@ -109,6 +110,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
||||
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||
uiCapabilities: {
|
||||
...(module.uiCapabilities ?? {}),
|
||||
...runtimeUiCapabilitiesForModule(module, info)
|
||||
@@ -116,6 +118,56 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
};
|
||||
}
|
||||
|
||||
function filterPublicRoutes(
|
||||
module: PlatformWebModule,
|
||||
routes: NonNullable<PlatformModuleInfo["frontend"]>["public_routes"] | undefined
|
||||
) {
|
||||
if (!routes?.length) return [];
|
||||
const allowedPaths = new Set(routes.map((route) => route.path));
|
||||
return (module.publicRoutes ?? []).filter((route) => allowedPaths.has(route.path));
|
||||
}
|
||||
|
||||
function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
|
||||
return {
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
version: info.version,
|
||||
dependencies: [],
|
||||
optional_dependencies: [],
|
||||
enabled: true,
|
||||
runtime_ui_capabilities: [],
|
||||
nav: [],
|
||||
frontend: {
|
||||
...info.frontend,
|
||||
routes: [],
|
||||
nav: [],
|
||||
settings_routes: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveInstalledPublicWebModules(
|
||||
platformModules: PlatformPublicModuleInfo[] | null | undefined
|
||||
): PlatformWebModule[] {
|
||||
if (!platformModules?.length) return [];
|
||||
const localById = new Map(localModules.map((module) => [module.id, module]));
|
||||
return platformModules.flatMap((info) => {
|
||||
const local = localById.get(info.id);
|
||||
if (!local) return [];
|
||||
const resolved = applyServerMetadata(local, publicModuleInfo(info));
|
||||
return resolved.publicRoutes?.length ? [resolved] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadRemotePublicWebModules(
|
||||
platformModules: PlatformPublicModuleInfo[] | null | undefined,
|
||||
alreadyLoaded: PlatformWebModule[] = resolveInstalledPublicWebModules(platformModules)
|
||||
): Promise<PlatformWebModule[]> {
|
||||
if (!platformModules?.length) return [];
|
||||
const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded);
|
||||
return loaded.filter((module) => module.publicRoutes?.length);
|
||||
}
|
||||
|
||||
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
|
||||
if (!platformModules?.length) return localModules;
|
||||
|
||||
@@ -294,6 +346,10 @@ export function routeContributionsForModules(modules: PlatformWebModule[]) {
|
||||
return routeContributionsForModuleList(modules);
|
||||
}
|
||||
|
||||
export function publicRouteContributionsForModules(modules: PlatformWebModule[]) {
|
||||
return publicRouteContributionsForModuleList(modules);
|
||||
}
|
||||
|
||||
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
|
||||
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
||||
flatMap((capability) => capability.widgets ?? []).
|
||||
|
||||
@@ -245,6 +245,12 @@ export type PlatformRouteContext = {
|
||||
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
};
|
||||
|
||||
export type PlatformPublicRouteContext = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo | null;
|
||||
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
};
|
||||
|
||||
export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
|
||||
|
||||
export type AdminSectionRenderContext = PlatformRouteContext & {
|
||||
@@ -298,6 +304,12 @@ export type PlatformRouteContribution = {
|
||||
render: (context: PlatformRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
export type PlatformPublicRouteContribution = {
|
||||
path: string;
|
||||
order?: number;
|
||||
render: (context: PlatformPublicRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
export type PlatformUiCapabilities = Record<string, unknown>;
|
||||
|
||||
export type PlatformTranslationDictionary = Record<string, string>;
|
||||
@@ -317,6 +329,7 @@ export type PlatformWebModule = {
|
||||
remoteAssetContractVersion?: string | null;
|
||||
navItems?: PlatformNavItem[];
|
||||
routes?: PlatformRouteContribution[];
|
||||
publicRoutes?: PlatformPublicRouteContribution[];
|
||||
translations?: PlatformTranslations;
|
||||
uiCapabilities?: PlatformUiCapabilities;
|
||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||
@@ -751,6 +764,11 @@ export type PlatformFrontendModuleInfo = {
|
||||
asset_manifest_integrity?: string | null;
|
||||
asset_manifest_contract_version?: string | null;
|
||||
routes: PlatformFrontendRouteInfo[];
|
||||
public_routes: Array<{
|
||||
path: string;
|
||||
component: string;
|
||||
order: number;
|
||||
}>;
|
||||
nav: Array<{
|
||||
path: string;
|
||||
label: string;
|
||||
@@ -763,6 +781,23 @@ export type PlatformFrontendModuleInfo = {
|
||||
settings_routes: PlatformFrontendRouteInfo[];
|
||||
};
|
||||
|
||||
export type PlatformPublicModuleInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
frontend: Pick<
|
||||
PlatformFrontendModuleInfo,
|
||||
| "module_id"
|
||||
| "package_name"
|
||||
| "asset_manifest"
|
||||
| "asset_manifest_signature"
|
||||
| "asset_manifest_public_key_id"
|
||||
| "asset_manifest_integrity"
|
||||
| "asset_manifest_contract_version"
|
||||
| "public_routes"
|
||||
>;
|
||||
};
|
||||
|
||||
export type PlatformModuleInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user