feat(core): support explicit public module routes

This commit is contained in:
2026-07-22 02:58:03 +02:00
parent a18499cbb5
commit 0946bc84a9
10 changed files with 308 additions and 16 deletions

View File

@@ -70,6 +70,15 @@ class FrontendRoute:
order: int = 100 order: int = 100
@dataclass(frozen=True, slots=True)
class PublicFrontendRoute:
"""Explicitly allowlisted route that can render without authentication."""
path: str
component: str
order: int = 100
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class FrontendModule: class FrontendModule:
module_id: str module_id: str
@@ -80,6 +89,7 @@ class FrontendModule:
asset_manifest_integrity: str | None = None asset_manifest_integrity: str | None = None
asset_manifest_contract_version: str = "1" asset_manifest_contract_version: str = "1"
routes: tuple[FrontendRoute, ...] = () routes: tuple[FrontendRoute, ...] = ()
public_routes: tuple[PublicFrontendRoute, ...] = ()
nav_items: tuple[NavItem, ...] = () nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = () settings_routes: tuple[FrontendRoute, ...] = ()

View File

@@ -16,6 +16,7 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
PublicFrontendRoute,
ResourceAclProvider, ResourceAclProvider,
RoleTemplate, RoleTemplate,
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION, SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
@@ -195,6 +196,7 @@ class PlatformRegistry:
available_capabilities=available_capabilities, available_capabilities=available_capabilities,
) )
permissions = _collect_manifest_permissions(ordered) permissions = _collect_manifest_permissions(ordered)
_validate_public_frontend_route_uniqueness(ordered)
_validate_interface_closure(ordered) _validate_interface_closure(ordered)
_validate_role_template_scopes(ordered, known_scopes=set(permissions)) _validate_role_template_scopes(ordered, known_scopes=set(permissions))
@@ -434,7 +436,7 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
) )
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name): if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}") raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
for route in (*frontend.routes, *frontend.settings_routes): for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
_validate_frontend_route(manifest.id, route.path, route.component) _validate_frontend_route(manifest.id, route.path, route.component)
for item in frontend.nav_items: for item in frontend.nav_items:
_validate_nav_item(manifest.id, item) _validate_nav_item(manifest.id, item)
@@ -447,6 +449,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component") raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
def _validate_public_frontend_route_uniqueness(
manifests: tuple[ModuleManifest, ...],
) -> None:
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
for manifest in manifests:
if manifest.frontend is None:
continue
for route in manifest.frontend.public_routes:
previous = owners.get(route.path)
if previous is not None:
previous_module, _previous_route = previous
raise RegistryError(
f"Duplicate public frontend route {route.path!r} in modules "
f"{previous_module!r} and {manifest.id!r}"
)
owners[route.path] = (manifest.id, route)
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None: def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list) providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
for manifest in manifests: for manifest in manifests:

View File

@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
from govoplan_core.core.maintenance import saved_maintenance_mode from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
from govoplan_core.i18n import system_i18n_payload from govoplan_core.i18n import system_i18n_payload
@@ -41,6 +41,14 @@ def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
} }
def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, object]:
return {
"path": route.path,
"component": route.component,
"order": route.order,
}
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None: def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
if frontend is None: if frontend is None:
return None return None
@@ -53,11 +61,31 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
"asset_manifest_integrity": frontend.asset_manifest_integrity, "asset_manifest_integrity": frontend.asset_manifest_integrity,
"asset_manifest_contract_version": frontend.asset_manifest_contract_version, "asset_manifest_contract_version": frontend.asset_manifest_contract_version,
"routes": [_frontend_route_payload(route) for route in frontend.routes], "routes": [_frontend_route_payload(route) for route in frontend.routes],
"public_routes": [
_public_frontend_route_payload(route) for route in frontend.public_routes
],
"nav": [_nav_item_payload(item) for item in frontend.nav_items], "nav": [_nav_item_payload(item) for item in frontend.nav_items],
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes], "settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
} }
def _public_frontend_payload(frontend: FrontendModule) -> dict[str, object]:
"""Return only assets and routes explicitly approved for signed-out use."""
return {
"module_id": frontend.module_id,
"package_name": frontend.package_name,
"asset_manifest": frontend.asset_manifest,
"asset_manifest_signature": frontend.asset_manifest_signature,
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
"asset_manifest_integrity": frontend.asset_manifest_integrity,
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
"public_routes": [
_public_frontend_route_payload(route) for route in frontend.public_routes
],
}
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]: def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
capabilities: list[str] = [] capabilities: list[str] = []
if manifest_id == "mail": if manifest_id == "mail":
@@ -104,6 +132,23 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
] ]
} }
@router.get("/public-modules")
def public_modules(request: Request):
registry = _registry(request)
return {
"modules": [
{
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"frontend": _public_frontend_payload(manifest.frontend),
}
for manifest in registry.manifests()
if manifest.frontend is not None
and manifest.frontend.public_routes
]
}
@router.get("/permissions") @router.get("/permissions")
def permissions(request: Request): def permissions(request: Request):
registry = _registry(request) registry = _registry(request)

View File

@@ -87,7 +87,11 @@ class ConditionalRequestTests(unittest.TestCase):
) )
with TestClient(app) as client: with TestClient(app) as client:
for path in ("/api/v1/platform/status", "/api/v1/platform/modules"): for path in (
"/api/v1/platform/status",
"/api/v1/platform/modules",
"/api/v1/platform/public-modules",
):
first = client.get(path) first = client.get(path)
self.assertEqual(200, first.status_code, first.text) self.assertEqual(200, first.status_code, first.text)
etag = first.headers.get("etag") etag = first.headers.get("etag")

View File

@@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_ROOT / 'module-test.db'
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false") os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false")
os.environ.setdefault("CELERY_ENABLED", "false") os.environ.setdefault("CELERY_ENABLED", "false")
from fastapi import APIRouter from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
@@ -99,7 +99,7 @@ from govoplan_core.core.module_package_catalog import (
sign_module_package_catalog, sign_module_package_catalog,
validate_module_package_catalog, validate_module_package_catalog,
) )
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute
from govoplan_core.core.module_guards import drop_table_retirement_provider from govoplan_core.core.module_guards import drop_table_retirement_provider
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
from govoplan_core.core.registry import PlatformRegistry, RegistryError from govoplan_core.core.registry import PlatformRegistry, RegistryError
@@ -112,6 +112,7 @@ from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_core.security.permissions import scope_grants from govoplan_core.security.permissions import scope_grants
from govoplan_core.server.app import create_app from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import RouteCollisionError from govoplan_core.server.route_validation import RouteCollisionError
from govoplan_core.tenancy.scope import Tenant, create_scope_tables from govoplan_core.tenancy.scope import Tenant, create_scope_tables
@@ -338,7 +339,7 @@ class ModuleSystemTests(unittest.TestCase):
self.assertEqual(manifest.id, manifest.frontend.module_id) self.assertEqual(manifest.id, manifest.frontend.module_id)
if manifest.frontend.package_name is not None: if manifest.frontend.package_name is not None:
self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE) self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE)
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes): for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes, *manifest.frontend.public_routes):
self.assertTrue(route.path.startswith("/")) self.assertTrue(route.path.startswith("/"))
self.assertTrue(route.component.strip()) self.assertTrue(route.component.strip())
for item in (*manifest.nav_items, *manifest.frontend.nav_items): for item in (*manifest.nav_items, *manifest.frontend.nav_items):
@@ -457,6 +458,74 @@ class ModuleSystemTests(unittest.TestCase):
with self.assertRaisesRegex(RegistryError, "Frontend route"): with self.assertRaisesRegex(RegistryError, "Frontend route"):
registry.validate() registry.validate()
def test_public_frontend_routes_are_explicitly_allowlisted(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="test",
frontend=FrontendModule(
module_id="example",
package_name="@govoplan/example-webui",
routes=(FrontendRoute(path="/example", component="ExamplePage"),),
public_routes=(
PublicFrontendRoute(
path="/example/public/:token",
component="ExamplePublicPage",
),
),
),
))
registry.register(ModuleManifest(
id="private",
name="Private",
version="test",
frontend=FrontendModule(
module_id="private",
package_name="@govoplan/private-webui",
routes=(FrontendRoute(path="/private", component="PrivatePage"),),
),
))
registry.validate()
app = FastAPI()
app.state.govoplan_registry = registry
app.include_router(create_platform_router(), prefix="/api/v1")
with TestClient(app) as client:
response = client.get("/api/v1/platform/public-modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]])
public_module = response.json()["modules"][0]
self.assertNotIn("dependencies", public_module)
self.assertNotIn("nav", public_module["frontend"])
self.assertNotIn("routes", public_module["frontend"])
self.assertEqual(
["/example/public/:token"],
[item["path"] for item in public_module["frontend"]["public_routes"]],
)
def test_registry_rejects_duplicate_public_frontend_routes(self) -> None:
registry = PlatformRegistry()
for module_id in ("first", "second"):
registry.register(ModuleManifest(
id=module_id,
name=module_id.title(),
version="test",
frontend=FrontendModule(
module_id=module_id,
public_routes=(
PublicFrontendRoute(
path="/shared/public/:token",
component=f"{module_id.title()}PublicPage",
),
),
),
))
with self.assertRaisesRegex(RegistryError, "Duplicate public frontend route"):
registry.validate()
def test_server_startup_rejects_duplicate_configured_routes(self) -> None: def test_server_startup_rejects_duplicate_configured_routes(self) -> None:
first = APIRouter() first = APIRouter()
second = APIRouter() second = APIRouter()

View File

@@ -1,14 +1,14 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth"; 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 { 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 AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage"; import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal"; import LoginModal from "./features/auth/LoginModal";
import { PermissionBoundary } from "./components/AccessBoundary"; 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 { PlatformModulesProvider } from "./platform/ModuleContext";
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents"; import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard"; import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
@@ -30,7 +30,9 @@ export default function App() {
const [auth, setAuth] = useState<AuthInfo | null>(null); const [auth, setAuth] = useState<AuthInfo | null>(null);
const [checkingSession, setCheckingSession] = useState(true); const [checkingSession, setCheckingSession] = useState(true);
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null); const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]); const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null }); const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
const [backendReachable, setBackendReachable] = useState(true); const [backendReachable, setBackendReachable] = useState(true);
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null); 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 localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]); 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 navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
const moduleRoutes = useMemo(() => routeContributionsForModules(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]); const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
function updateSettings(next: ApiSettings) { function updateSettings(next: ApiSettings) {
@@ -119,6 +125,14 @@ export default function App() {
}; };
}, [settings.apiBaseUrl]); }, [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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setCheckingSession(true); setCheckingSession(true);
@@ -233,6 +247,18 @@ export default function App() {
return () => {cancelled = true;}; return () => {cancelled = true;};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]); }, [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(() => { useEffect(() => {
if (!auth) return; if (!auth) return;
@@ -284,7 +310,7 @@ export default function App() {
if (checkingSession) { if (checkingSession) {
return ( return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}> <PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}> <PlatformModulesProvider modules={publicWebModules}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}> <AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<div className="public-landing"> <div className="public-landing">
@@ -304,10 +330,21 @@ export default function App() {
if (!auth) { if (!auth) {
return ( return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}> <PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}> <PlatformModulesProvider modules={publicWebModules}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}> <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> </AppShell>
</UnsavedChangesProvider> </UnsavedChangesProvider>
</PlatformModulesProvider> </PlatformModulesProvider>
@@ -342,6 +379,13 @@ export default function App() {
<Routes key={(auth.active_tenant ?? auth.tenant).id}> <Routes key={(auth.active_tenant ?? auth.tenant).id}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} /> <Route path="/" element={<Navigate to={defaultRoute} replace />} />
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />} {!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) => {moduleRoutes.map((route) =>
<Route <Route
key={route.path} key={route.path}

View File

@@ -1,8 +1,9 @@
import type { ApiSettings } from "../types"; import type { ApiSettings } from "../types";
import type { PlatformModuleInfo } from "../types"; import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
import { apiFetch } from "./client"; import { apiFetch } from "./client";
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] }; export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
export type PlatformPublicModulesResponse = { modules: PlatformPublicModuleInfo[] };
export type PlatformStatusResponse = { export type PlatformStatusResponse = {
maintenance_mode: { maintenance_mode: {
@@ -38,6 +39,10 @@ export async function fetchPlatformModules(settings: ApiSettings): Promise<Platf
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules"); 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> { export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status"); return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
} }

View File

@@ -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 { export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
for (const module of modules) { for (const module of modules) {
@@ -32,3 +32,7 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] { export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); 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));
}

View File

@@ -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 { 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 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 { import {
hasUiCapability as hasUiCapabilityForModules, hasUiCapability as hasUiCapabilityForModules,
moduleInstalled as moduleInstalledForModules, moduleInstalled as moduleInstalledForModules,
moduleIntegrationEnabled as moduleIntegrationEnabledForModules, moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
publicRouteContributionsForModules as publicRouteContributionsForModuleList,
routeContributionsForModules as routeContributionsForModuleList, routeContributionsForModules as routeContributionsForModuleList,
uiCapabilities as uiCapabilitiesForModules, uiCapabilities as uiCapabilitiesForModules,
uiCapability as uiCapabilityForModules } from uiCapability as uiCapabilityForModules } from
@@ -109,6 +110,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity, remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion, remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems, navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
uiCapabilities: { uiCapabilities: {
...(module.uiCapabilities ?? {}), ...(module.uiCapabilities ?? {}),
...runtimeUiCapabilitiesForModule(module, info) ...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[] { export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
if (!platformModules?.length) return localModules; if (!platformModules?.length) return localModules;
@@ -294,6 +346,10 @@ export function routeContributionsForModules(modules: PlatformWebModule[]) {
return routeContributionsForModuleList(modules); return routeContributionsForModuleList(modules);
} }
export function publicRouteContributionsForModules(modules: PlatformWebModule[]) {
return publicRouteContributionsForModuleList(modules);
}
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] { export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules). return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
flatMap((capability) => capability.widgets ?? []). flatMap((capability) => capability.widgets ?? []).

View File

@@ -245,6 +245,12 @@ export type PlatformRouteContext = {
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void; 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 AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
export type AdminSectionRenderContext = PlatformRouteContext & { export type AdminSectionRenderContext = PlatformRouteContext & {
@@ -298,6 +304,12 @@ export type PlatformRouteContribution = {
render: (context: PlatformRouteContext) => ReactNode; render: (context: PlatformRouteContext) => ReactNode;
}; };
export type PlatformPublicRouteContribution = {
path: string;
order?: number;
render: (context: PlatformPublicRouteContext) => ReactNode;
};
export type PlatformUiCapabilities = Record<string, unknown>; export type PlatformUiCapabilities = Record<string, unknown>;
export type PlatformTranslationDictionary = Record<string, string>; export type PlatformTranslationDictionary = Record<string, string>;
@@ -317,6 +329,7 @@ export type PlatformWebModule = {
remoteAssetContractVersion?: string | null; remoteAssetContractVersion?: string | null;
navItems?: PlatformNavItem[]; navItems?: PlatformNavItem[];
routes?: PlatformRouteContribution[]; routes?: PlatformRouteContribution[];
publicRoutes?: PlatformPublicRouteContribution[];
translations?: PlatformTranslations; translations?: PlatformTranslations;
uiCapabilities?: PlatformUiCapabilities; uiCapabilities?: PlatformUiCapabilities;
runtimeUiCapabilities?: PlatformUiCapabilities; runtimeUiCapabilities?: PlatformUiCapabilities;
@@ -751,6 +764,11 @@ export type PlatformFrontendModuleInfo = {
asset_manifest_integrity?: string | null; asset_manifest_integrity?: string | null;
asset_manifest_contract_version?: string | null; asset_manifest_contract_version?: string | null;
routes: PlatformFrontendRouteInfo[]; routes: PlatformFrontendRouteInfo[];
public_routes: Array<{
path: string;
component: string;
order: number;
}>;
nav: Array<{ nav: Array<{
path: string; path: string;
label: string; label: string;
@@ -763,6 +781,23 @@ export type PlatformFrontendModuleInfo = {
settings_routes: PlatformFrontendRouteInfo[]; 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 = { export type PlatformModuleInfo = {
id: string; id: string;
name: string; name: string;