Module Package Release / publish-packages (push) Successful in 13s
Release v0.1.46. Coordinated integrity review: GovOPlaN/govoplan-core#298.
874 lines
36 KiB
TypeScript
874 lines
36 KiB
TypeScript
import { Navigate, Route, Routes, useLocation } from "react-router";
|
|
import { lazy, useEffect, useMemo, useState } from "react";
|
|
import { fetchSession, fetchShellAuth, logout, updateProfile } from "./api/auth";
|
|
import { createModuleRefresh } from "./platform/moduleRefresh";
|
|
import type { AuthActionUiCapability } from "./types";
|
|
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
|
import { AUTH_REQUIRED_EVENT, apiSettingsForAuthUpdate, clearApiReadCache, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
|
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPalette, UserUiPreferences, ViewsRuntimeUiCapability } 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 { configurableNavigationItemsForModules, firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
|
|
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
|
import { PlatformViewProvider } from "./platform/ViewContext";
|
|
import { PlatformTemporalProvider } from "./platform/TemporalContext";
|
|
import { PlatformActiveObjectProvider } from "./platform/ActiveObjectContext";
|
|
import { PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT } from "./platform/temporal";
|
|
import {
|
|
PLATFORM_VIEW_CHANGED_EVENT,
|
|
PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT,
|
|
type WorkflowViewChangedEventDetail
|
|
} from "./platform/views";
|
|
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
|
import { UnsavedChangesProvider, useUnsavedChanges } from "./components/UnsavedChangesGuard";
|
|
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
|
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
|
|
import ModuleLoadBoundary from "./components/ModuleLoadBoundary";
|
|
import DismissibleAlert from "./components/DismissibleAlert";
|
|
import Button from "./components/Button";
|
|
import { i18nMessage } from "./i18n/LanguageContext";
|
|
import { DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
|
|
import { hasAnyScope } from "./utils/permissions";
|
|
import { applyAppearanceOverrides } from "./components/appearanceOverrides";
|
|
|
|
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
|
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
|
const ProductSurfaceRoute = lazy(() => import("./components/ProductSurfaceRoute"));
|
|
const AuthActionGate = lazy(() => import("./features/auth/AuthActionGate"));
|
|
|
|
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
|
compact_tables: false,
|
|
show_inline_help_hints: true,
|
|
reduce_motion: false,
|
|
sticky_section_sidebars: true,
|
|
theme: "system",
|
|
palette: null,
|
|
appearance_overrides: null
|
|
};
|
|
|
|
export default function App() {
|
|
const location = useLocation();
|
|
const [settings, setSettings] = useState<ApiSettings>(() => loadApiSettings());
|
|
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 [localWebModules, setLocalWebModules] = useState<PlatformWebModule[]>([]);
|
|
const [localPublicWebModules, setLocalPublicWebModules] = useState<PlatformWebModule[]>([]);
|
|
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
|
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
|
|
const [webModulesLoading, setWebModulesLoading] = useState(true);
|
|
const [webModuleLoadFailures, setWebModuleLoadFailures] = useState<string[]>([]);
|
|
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);
|
|
const [reloginMessage, setReloginMessage] = useState("");
|
|
const [baseViewProjection, setBaseViewProjection] = useState<EffectiveViewProjection | null>(null);
|
|
const [workflowViewProjection, setWorkflowViewProjection] = useState<EffectiveViewProjection | null>(null);
|
|
const [temporalRevision, setTemporalRevision] = useState(0);
|
|
const viewProjection = workflowViewProjection ?? baseViewProjection;
|
|
|
|
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
|
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
|
const requiredAuthAction = auth?.user.required_auth_action ?? null;
|
|
const authActions = useMemo(
|
|
() => uiCapability<AuthActionUiCapability>("auth.actions", publicWebModules),
|
|
[publicWebModules]
|
|
);
|
|
const viewsRuntime = useMemo(
|
|
() => uiCapability<ViewsRuntimeUiCapability>("views.runtime", webModules),
|
|
[webModules]
|
|
);
|
|
const navItems = useMemo(
|
|
() => navItemsForModules(webModules, viewProjection),
|
|
[viewProjection, webModules]
|
|
);
|
|
const allToolItems = useMemo(
|
|
() => configurableNavigationItemsForModules(webModules),
|
|
[webModules]
|
|
);
|
|
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
|
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
|
const contextModules = auth && !requiredAuthAction ? webModules : publicWebModules;
|
|
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
|
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
|
|
|
useEffect(() => {
|
|
function reloadTemporalData() {
|
|
setTemporalRevision((current) => current + 1);
|
|
}
|
|
window.addEventListener(
|
|
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
|
|
reloadTemporalData
|
|
);
|
|
return () => window.removeEventListener(
|
|
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
|
|
reloadTemporalData
|
|
);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!auth || requiredAuthAction || !viewsRuntime) {
|
|
setBaseViewProjection(null);
|
|
setWorkflowViewProjection(null);
|
|
return;
|
|
}
|
|
const currentAuth = auth;
|
|
const currentRuntime = viewsRuntime;
|
|
let cancelled = false;
|
|
async function loadEffectiveView() {
|
|
try {
|
|
const projection = await currentRuntime.loadEffectiveView(settings, currentAuth);
|
|
if (!cancelled) setBaseViewProjection(projection);
|
|
} catch (error) {
|
|
if (!cancelled) setBaseViewProjection(null);
|
|
console.error("Failed to load the effective View", error);
|
|
}
|
|
}
|
|
function reloadNormalView() {
|
|
setWorkflowViewProjection(null);
|
|
clearStoredWorkflowView(currentAuth);
|
|
void loadEffectiveView();
|
|
}
|
|
void loadEffectiveView();
|
|
window.addEventListener(PLATFORM_VIEW_CHANGED_EVENT, reloadNormalView);
|
|
return () => {
|
|
cancelled = true;
|
|
window.removeEventListener(PLATFORM_VIEW_CHANGED_EVENT, reloadNormalView);
|
|
};
|
|
}, [
|
|
auth?.user?.id,
|
|
auth?.active_tenant?.id,
|
|
auth?.tenant.id,
|
|
settings.accessToken,
|
|
settings.apiBaseUrl,
|
|
settings.apiKey,
|
|
requiredAuthAction,
|
|
viewsRuntime
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!auth || requiredAuthAction || !viewsRuntime) {
|
|
setWorkflowViewProjection(null);
|
|
return;
|
|
}
|
|
const currentAuth = auth;
|
|
setWorkflowViewProjection(loadStoredWorkflowView(currentAuth));
|
|
function applyWorkflowView(event: Event) {
|
|
const detail = (event as CustomEvent<WorkflowViewChangedEventDetail>).detail;
|
|
const projection = detail?.projection ?? null;
|
|
setWorkflowViewProjection(projection);
|
|
storeWorkflowView(currentAuth, projection, detail?.contextId);
|
|
}
|
|
window.addEventListener(
|
|
PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT,
|
|
applyWorkflowView
|
|
);
|
|
return () => {
|
|
window.removeEventListener(
|
|
PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT,
|
|
applyWorkflowView
|
|
);
|
|
};
|
|
}, [
|
|
auth?.user?.id,
|
|
auth?.active_tenant?.id,
|
|
auth?.tenant.id,
|
|
requiredAuthAction,
|
|
viewsRuntime
|
|
]);
|
|
|
|
function updateSettings(next: ApiSettings) {
|
|
setSettings(next);
|
|
saveApiSettings(next);
|
|
}
|
|
|
|
function updateAuth(next: AuthUpdate | null, accessToken?: string) {
|
|
clearApiReadCache();
|
|
const nextSettings = apiSettingsForAuthUpdate(settings, next, accessToken);
|
|
setAuth((current) => next ? normalizeAuthInfo(mergeAuthPayload(current, next)) : null);
|
|
if (nextSettings !== settings) {
|
|
setSettings(nextSettings);
|
|
saveApiSettings(nextSettings);
|
|
}
|
|
}
|
|
|
|
function authFromLoginResponse(response: LoginResponse): AuthInfo {
|
|
return normalizeAuthInfo(response);
|
|
}
|
|
|
|
function handlePublicLogin(response: LoginResponse) {
|
|
setWebModulesLoading(true);
|
|
updateAuth(authFromLoginResponse(response), "");
|
|
}
|
|
|
|
function handleRelogin(response: LoginResponse) {
|
|
updateAuth(authFromLoginResponse(response), "");
|
|
setReloginMessage("");
|
|
}
|
|
|
|
useEffect(() => {
|
|
function handleAuthRequired(event: Event) {
|
|
if (!auth) return;
|
|
const detail = (event as CustomEvent<AuthRequiredEventDetail>).detail;
|
|
setReloginMessage(detail?.message || "i18n:govoplan-core.your_session_has_expired_sign_in_again_to_contin.4cc6a1b3");
|
|
}
|
|
|
|
window.addEventListener(AUTH_REQUIRED_EVENT, handleAuthRequired);
|
|
return () => window.removeEventListener(AUTH_REQUIRED_EVENT, handleAuthRequired);
|
|
}, [auth]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function refreshPlatformStatus() {
|
|
try {
|
|
const response = await fetchPlatformStatus(settings);
|
|
if (cancelled) return;
|
|
setBackendReachable(true);
|
|
setMaintenanceMode(response.maintenance_mode);
|
|
if (response.i18n) {
|
|
setSystemLanguages({
|
|
available: response.i18n.available_languages.map((item) => ({
|
|
code: item.code,
|
|
label: item.label,
|
|
nativeLabel: item.native_label ?? undefined
|
|
})),
|
|
enabled: response.i18n.enabled_languages,
|
|
defaultLanguage: response.i18n.default_language
|
|
});
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setBackendReachable(false);
|
|
setMaintenanceMode({ enabled: false, message: null });
|
|
}
|
|
}
|
|
}
|
|
|
|
void refreshPlatformStatus();
|
|
const interval = window.setInterval(() => {void refreshPlatformStatus();}, 30_000);
|
|
window.addEventListener("focus", refreshPlatformStatus);
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearInterval(interval);
|
|
window.removeEventListener("focus", refreshPlatformStatus);
|
|
};
|
|
}, [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);
|
|
|
|
async function bootstrapAuth() {
|
|
try {
|
|
const shellAuth = await fetchShellAuth(settings);
|
|
if (!cancelled) {
|
|
setBackendReachable(true);
|
|
setWebModulesLoading(true);
|
|
setAuth(normalizeAuthInfo(shellAuth));
|
|
}
|
|
} catch (error) {
|
|
if (!cancelled) {
|
|
setBackendReachable(isApiError(error));
|
|
const cleared = { ...settings, accessToken: "" };
|
|
setSettings(cleared);
|
|
saveApiSettings(cleared);
|
|
setAuth(null);
|
|
setPlatformModules(null);
|
|
setWebModulesLoading(false);
|
|
setRemoteWebModules([]);
|
|
}
|
|
} finally {
|
|
if (!cancelled) setCheckingSession(false);
|
|
}
|
|
}
|
|
|
|
void bootstrapAuth();
|
|
return () => {cancelled = true;};
|
|
}, [settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
useEffect(() => {
|
|
if (!auth || requiredAuthAction) {
|
|
setPlatformModules(null);
|
|
return;
|
|
}
|
|
|
|
const refresh = createModuleRefresh({
|
|
load: () => fetchPlatformModules(settings),
|
|
accept: (response) => setPlatformModules(response.modules),
|
|
reject: () => {
|
|
setPlatformModules(null);
|
|
setWebModulesLoading(false);
|
|
}
|
|
});
|
|
refresh.invalidate();
|
|
|
|
function handleModulesChanged() {
|
|
refresh.invalidate();
|
|
}
|
|
|
|
function refreshVisibleModules() {
|
|
refresh.refreshVisible(document.visibilityState !== "hidden");
|
|
}
|
|
|
|
window.addEventListener(PLATFORM_MODULES_CHANGED_EVENT, handleModulesChanged);
|
|
window.addEventListener("focus", refreshVisibleModules);
|
|
document.addEventListener("visibilitychange", refreshVisibleModules);
|
|
return () => {
|
|
refresh.dispose();
|
|
window.removeEventListener(PLATFORM_MODULES_CHANGED_EVENT, handleModulesChanged);
|
|
window.removeEventListener("focus", refreshVisibleModules);
|
|
document.removeEventListener("visibilitychange", refreshVisibleModules);
|
|
};
|
|
}, [auth, requiredAuthAction, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setWebModuleLoadFailures([]);
|
|
if (!auth || requiredAuthAction) {
|
|
setLocalWebModules([]);
|
|
setRemoteWebModules([]);
|
|
setWebModulesLoading(false);
|
|
return () => {cancelled = true;};
|
|
}
|
|
if (platformModules === null) {
|
|
return () => {cancelled = true;};
|
|
}
|
|
if (platformModules.length === 0) {
|
|
setLocalWebModules([]);
|
|
setRemoteWebModules([]);
|
|
setWebModulesLoading(false);
|
|
return () => {cancelled = true;};
|
|
}
|
|
|
|
async function loadWebModules() {
|
|
const failedIds: string[] = [];
|
|
const local = await loadInstalledWebModules(platformModules, (moduleId) => failedIds.push(moduleId));
|
|
if (cancelled) return;
|
|
setLocalWebModules(local);
|
|
setWebModuleLoadFailures(failedIds);
|
|
setWebModulesLoading(false);
|
|
const remote = await loadRemoteWebModules(platformModules, local);
|
|
if (!cancelled) {
|
|
setRemoteWebModules(remote);
|
|
const recovered = new Set(remote.map((module) => module.id));
|
|
setWebModuleLoadFailures(failedIds.filter((moduleId) => !recovered.has(moduleId)));
|
|
}
|
|
}
|
|
|
|
void loadWebModules().catch((error) => {
|
|
console.error("Failed to load platform WebUI modules", error);
|
|
if (!cancelled) {
|
|
setLocalWebModules([]);
|
|
setRemoteWebModules([]);
|
|
setWebModulesLoading(false);
|
|
}
|
|
});
|
|
return () => {cancelled = true;};
|
|
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, requiredAuthAction, platformModules]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
if (!platformPublicModules?.length) {
|
|
setLocalPublicWebModules([]);
|
|
setRemotePublicWebModules([]);
|
|
return () => {cancelled = true;};
|
|
}
|
|
|
|
async function loadPublicWebModules() {
|
|
const local = await loadInstalledPublicWebModules(platformPublicModules);
|
|
if (cancelled) return;
|
|
setLocalPublicWebModules(local);
|
|
const remote = await loadRemotePublicWebModules(platformPublicModules, local);
|
|
if (!cancelled) setRemotePublicWebModules(remote);
|
|
}
|
|
|
|
void loadPublicWebModules().catch((error) => {
|
|
console.error("Failed to load public WebUI modules", error);
|
|
if (!cancelled) {
|
|
setLocalPublicWebModules([]);
|
|
setRemotePublicWebModules([]);
|
|
}
|
|
});
|
|
return () => {cancelled = true;};
|
|
}, [platformPublicModules]);
|
|
|
|
useEffect(() => {
|
|
const preferences = auth?.user.ui_preferences ?? DEFAULT_UI_PREFERENCES;
|
|
const root = document.documentElement;
|
|
root.classList.toggle("ui-compact-tables", preferences.compact_tables);
|
|
root.classList.toggle("ui-hide-help-hints", !preferences.show_inline_help_hints);
|
|
root.classList.toggle("ui-reduce-motion", preferences.reduce_motion);
|
|
root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars);
|
|
root.dataset.palette = normalizeUiPalette(auth?.user.appearance?.palette ?? preferences.palette);
|
|
|
|
const systemDarkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
|
|
const applyTheme = () => {
|
|
const resolvedTheme = preferences.theme === "system" ?
|
|
systemDarkQuery?.matches ? "dark" : "light" :
|
|
preferences.theme;
|
|
root.dataset.theme = resolvedTheme;
|
|
root.dataset.themePreference = preferences.theme;
|
|
applyAppearanceOverrides(root, auth?.user.appearance?.custom_overrides ?? null, resolvedTheme);
|
|
};
|
|
|
|
applyTheme();
|
|
if (preferences.theme !== "system" || !systemDarkQuery) {
|
|
return undefined;
|
|
}
|
|
|
|
systemDarkQuery.addEventListener("change", applyTheme);
|
|
return () => {
|
|
systemDarkQuery.removeEventListener("change", applyTheme);
|
|
};
|
|
}, [
|
|
auth?.user.ui_preferences?.compact_tables,
|
|
auth?.user.ui_preferences?.show_inline_help_hints,
|
|
auth?.user.ui_preferences?.reduce_motion,
|
|
auth?.user.ui_preferences?.sticky_section_sidebars,
|
|
auth?.user.ui_preferences?.theme,
|
|
auth?.user.ui_preferences?.palette,
|
|
auth?.user.appearance?.palette,
|
|
auth?.user.appearance?.custom_overrides
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!auth) return;
|
|
|
|
const currentAuth = auth;
|
|
let cancelled = false;
|
|
let inFlight = false;
|
|
let lastRefreshAt = 0;
|
|
let lastShellRefreshAt = Date.now();
|
|
|
|
async function refreshVisibleSession() {
|
|
if (document.visibilityState === "hidden" || inFlight) return;
|
|
const now = Date.now();
|
|
if (now - lastRefreshAt < 5_000) return;
|
|
|
|
inFlight = true;
|
|
lastRefreshAt = now;
|
|
try {
|
|
const sessionInfo = await fetchSession(settings);
|
|
if (cancelled) return;
|
|
setBackendReachable(true);
|
|
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
|
|
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
|
|
clearApiReadCache();
|
|
const shellAuth = await fetchShellAuth(settings);
|
|
if (cancelled) return;
|
|
clearApiReadCache();
|
|
lastShellRefreshAt = Date.now();
|
|
setAuth((current) => current && sessionMatchesAuth(sessionInfo, current)
|
|
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
|
|
: normalizeAuthInfo(shellAuth));
|
|
}
|
|
} catch (error) {
|
|
if (!cancelled && !isApiError(error)) {
|
|
setBackendReachable(false);
|
|
}
|
|
|
|
|
|
// A background refresh must not log the user out on a transient network error.
|
|
} finally {inFlight = false;}
|
|
}
|
|
|
|
window.addEventListener("focus", refreshVisibleSession);
|
|
document.addEventListener("visibilitychange", refreshVisibleSession);
|
|
return () => {
|
|
cancelled = true;
|
|
window.removeEventListener("focus", refreshVisibleSession);
|
|
document.removeEventListener("visibilitychange", refreshVisibleSession);
|
|
};
|
|
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, auth?.principal?.session_id, requiredAuthAction, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
if (checkingSession) {
|
|
return (
|
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
|
<PlatformModulesProvider modules={publicWebModules}>
|
|
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
|
<UnsavedChangesProvider>
|
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
|
<div className="public-landing">
|
|
<section className="public-card">
|
|
<div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div>
|
|
<h1>i18n:govoplan-core.checking_session.e7d81968</h1>
|
|
<p>i18n:govoplan-core.please_wait_while_the_local_session_is_verified.1dbd8fd3</p>
|
|
</section>
|
|
</div>
|
|
</AppShell>
|
|
</UnsavedChangesProvider>
|
|
</PlatformViewProvider>
|
|
</PlatformModulesProvider>
|
|
</PlatformLanguageProvider>);
|
|
|
|
}
|
|
|
|
if (!auth) {
|
|
return (
|
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
|
<PlatformModulesProvider modules={publicWebModules}>
|
|
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
|
<UnsavedChangesProvider>
|
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
|
<ModuleLoadBoundary resetKey={location.pathname}>
|
|
<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>
|
|
</ModuleLoadBoundary>
|
|
</AppShell>
|
|
</UnsavedChangesProvider>
|
|
</PlatformViewProvider>
|
|
</PlatformModulesProvider>
|
|
</PlatformLanguageProvider>);
|
|
|
|
}
|
|
|
|
if (requiredAuthAction) {
|
|
return <PlatformLanguageProvider
|
|
systemAvailableLanguages={systemLanguages?.available}
|
|
systemEnabledLanguageCodes={systemLanguages?.enabled}
|
|
defaultLanguage={systemLanguages?.defaultLanguage}
|
|
preferredLanguageCode={auth.user.preferred_language ?? undefined}
|
|
moduleTranslations={moduleTranslations}>
|
|
<PlatformModulesProvider modules={publicWebModules}>
|
|
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
|
<ModuleLoadBoundary resetKey={requiredAuthAction}>
|
|
<AuthActionGate settings={settings} auth={auth} capability={authActions}
|
|
onAuthChange={updateAuth}
|
|
onSignOut={() => { void logout(settings).catch(() => undefined).finally(() => updateAuth(null, "")); }} />
|
|
</ModuleLoadBoundary>
|
|
{reloginMessage && <LoginModal settings={settings} message={reloginMessage}
|
|
onClose={() => setReloginMessage("")} onLogin={handleRelogin} />}
|
|
</PlatformViewProvider>
|
|
</PlatformModulesProvider>
|
|
</PlatformLanguageProvider>;
|
|
}
|
|
|
|
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection);
|
|
const localDocsAvailable = hasAnyScope(auth, ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"]) &&
|
|
webModules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
|
|
const authAvailableLanguages = auth.available_languages?.map((item) => ({
|
|
code: item.code,
|
|
label: item.label,
|
|
nativeLabel: item.native_label ?? undefined
|
|
}));
|
|
|
|
function persistLanguagePreference(code: string) {
|
|
void updateProfile(settings, { preferred_language: code }).then((next) => updateAuth(next)).catch(() => undefined);
|
|
}
|
|
|
|
return (
|
|
<PlatformLanguageProvider
|
|
systemAvailableLanguages={authAvailableLanguages ?? systemLanguages?.available}
|
|
systemEnabledLanguageCodes={auth.enabled_language_codes ?? systemLanguages?.enabled}
|
|
userEnabledLanguageCodes={auth.user?.enabled_language_codes}
|
|
defaultLanguage={auth.default_language ?? systemLanguages?.defaultLanguage ?? (auth.active_tenant ?? auth.tenant).default_locale}
|
|
preferredLanguageCode={auth.user?.preferred_language ?? auth.default_language}
|
|
onLanguageChange={persistLanguagePreference}
|
|
moduleTranslations={moduleTranslations}>
|
|
<PlatformModulesProvider modules={webModules}>
|
|
<PlatformTemporalProvider scopeKey={`${auth.user?.id ?? "account"}:${(auth.active_tenant ?? auth.tenant).id}`}>
|
|
<DocumentationHelpProvider localDocsAvailable={localDocsAvailable}>
|
|
<PlatformViewProvider modules={webModules} projection={viewProjection}>
|
|
<PlatformActiveObjectProvider>
|
|
<UnsavedChangesProvider>
|
|
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} allToolItems={allToolItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
|
{webModuleLoadFailures.length > 0 && <WebModuleLoadFailureNotice moduleIds={webModuleLoadFailures} />}
|
|
<ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}>
|
|
<Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}>
|
|
<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}
|
|
path={route.path}
|
|
element={
|
|
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
|
|
<ViewSurfaceRouteBoundary
|
|
surfaceId={route.surfaceId}
|
|
settings={settings}
|
|
runtime={viewsRuntime}
|
|
fallbackPath={defaultRoute}>
|
|
{route.render({ settings, auth, onAuthChange: updateAuth })}
|
|
</ViewSurfaceRouteBoundary>
|
|
</PermissionBoundary>
|
|
} />
|
|
|
|
)}
|
|
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
|
|
<Route path="*" element={<ProductSurfaceRoute auth={auth} />} />
|
|
</Routes>
|
|
</ModuleLoadBoundary>
|
|
{reloginMessage &&
|
|
<LoginModal
|
|
settings={settings}
|
|
title="i18n:govoplan-core.session_expired.b828190e"
|
|
message={reloginMessage}
|
|
onClose={() => setReloginMessage("")}
|
|
onLogin={handleRelogin} />
|
|
|
|
}
|
|
</AppShell>
|
|
</UnsavedChangesProvider>
|
|
</PlatformActiveObjectProvider>
|
|
</PlatformViewProvider>
|
|
</DocumentationHelpProvider>
|
|
</PlatformTemporalProvider>
|
|
</PlatformModulesProvider>
|
|
</PlatformLanguageProvider>);
|
|
|
|
}
|
|
|
|
function WebModuleLoadFailureNotice({ moduleIds }: { moduleIds: string[] }) {
|
|
const { requestNavigation } = useUnsavedChanges();
|
|
return <DismissibleAlert tone="warning" dismissible={false} floating>
|
|
<p>{i18nMessage("i18n:govoplan-core.optional_module_load_failed", { value0: moduleIds.join(", ") })}</p>
|
|
<Button onClick={() => requestNavigation(() => window.location.reload())}>i18n:govoplan-core.reload.cce71553</Button>
|
|
</DismissibleAlert>;
|
|
}
|
|
|
|
type AuthPayload = AuthUpdate;
|
|
|
|
function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayload {
|
|
if (!current) return next;
|
|
const nextActiveTenant = next.active_tenant ?? next.tenant ?? null;
|
|
const currentActiveTenant = current.active_tenant ?? current.tenant;
|
|
const tenantChanged = Boolean(nextActiveTenant && nextActiveTenant.id !== currentActiveTenant.id);
|
|
return {
|
|
...current,
|
|
...next,
|
|
user: next.user ? { ...current.user, ...next.user } : current.user,
|
|
tenant: nextActiveTenant ?? current.tenant,
|
|
active_tenant: nextActiveTenant ?? currentActiveTenant,
|
|
tenants: next.tenants ?? current.tenants,
|
|
scopes: next.scopes ?? current.scopes,
|
|
roles: next.roles ?? (next.roles_loaded === false || tenantChanged ? [] : current.roles),
|
|
groups: next.groups ?? (next.groups_loaded === false || tenantChanged ? [] : current.groups),
|
|
principal: next.principal === undefined ? current.principal : next.principal,
|
|
available_languages: next.available_languages ?? (tenantChanged ? undefined : current.available_languages),
|
|
enabled_language_codes: next.enabled_language_codes ?? (tenantChanged ? undefined : current.enabled_language_codes),
|
|
default_language: next.default_language ?? (tenantChanged ? undefined : current.default_language),
|
|
profile_loaded: next.profile_loaded ?? (tenantChanged ? false : current.profile_loaded),
|
|
roles_loaded: next.roles_loaded ?? (tenantChanged ? false : current.roles_loaded),
|
|
groups_loaded: next.groups_loaded ?? (tenantChanged ? false : current.groups_loaded)
|
|
};
|
|
}
|
|
|
|
export function normalizeAuthInfo(response: AuthPayload): AuthInfo {
|
|
const principal = response.principal ?? null;
|
|
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
|
|
const user = normalizeAuthUser(response.user, principal);
|
|
const profileLoaded = response.profile_loaded ?? hasFullProfilePayload(response);
|
|
const rolesLoaded = response.roles_loaded ?? response.roles !== undefined;
|
|
const groupsLoaded = response.groups_loaded ?? response.groups !== undefined;
|
|
|
|
if (!activeTenant) {
|
|
throw new Error("Authentication response did not include an active tenant.");
|
|
}
|
|
if (!user) {
|
|
throw new Error("Authentication response did not include a user.");
|
|
}
|
|
|
|
return {
|
|
user,
|
|
tenant: activeTenant,
|
|
active_tenant: activeTenant,
|
|
tenants: response.tenants ?? [activeTenant],
|
|
scopes: response.scopes ?? principal?.scopes ?? [],
|
|
roles: response.roles ?? [],
|
|
groups: response.groups ?? [],
|
|
principal,
|
|
available_languages: response.available_languages,
|
|
enabled_language_codes: profileLoaded ? response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [] : undefined,
|
|
default_language: profileLoaded ? response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "de" : undefined,
|
|
profile_loaded: profileLoaded,
|
|
roles_loaded: rolesLoaded,
|
|
groups_loaded: groupsLoaded
|
|
};
|
|
}
|
|
|
|
function hasFullProfilePayload(response: AuthPayload): boolean {
|
|
return Boolean(
|
|
response.default_language ||
|
|
response.available_languages ||
|
|
response.enabled_language_codes ||
|
|
response.roles ||
|
|
response.groups
|
|
);
|
|
}
|
|
|
|
function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal: AuthInfo["principal"]): AuthUser | null {
|
|
if (user?.id && user.account_id) {
|
|
return {
|
|
id: user.id,
|
|
account_id: user.account_id,
|
|
email: user.email ?? principal?.email ?? "",
|
|
display_name: user.display_name ?? principal?.display_name ?? principal?.email ?? null,
|
|
tenant_display_name: user.tenant_display_name ?? null,
|
|
is_tenant_admin: user.is_tenant_admin ?? false,
|
|
password_reset_required: user.password_reset_required ?? false,
|
|
required_auth_action: user.required_auth_action ?? null,
|
|
local_password: user.local_password ?? false,
|
|
preferred_language: user.preferred_language ?? null,
|
|
enabled_language_codes: user.enabled_language_codes ?? [],
|
|
ui_preferences: normalizeUiPreferences(user.ui_preferences)
|
|
};
|
|
}
|
|
|
|
if (!principal?.membership_id || !principal.account_id) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: principal.membership_id,
|
|
account_id: principal.account_id,
|
|
email: principal.email ?? "",
|
|
display_name: principal.display_name ?? principal.email ?? null,
|
|
tenant_display_name: principal.display_name ?? null,
|
|
is_tenant_admin: false,
|
|
password_reset_required: false,
|
|
required_auth_action: null,
|
|
local_password: false,
|
|
preferred_language: null,
|
|
enabled_language_codes: [],
|
|
ui_preferences: DEFAULT_UI_PREFERENCES
|
|
};
|
|
}
|
|
|
|
export function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
|
|
const activeTenant = auth.active_tenant ?? auth.tenant;
|
|
if (sessionInfo.user.id !== auth.user.id) return false;
|
|
if (sessionInfo.user.account_id !== auth.user.account_id) return false;
|
|
if ((sessionInfo.user.required_auth_action ?? null) !== (auth.user.required_auth_action ?? null)) return false;
|
|
if (Boolean(sessionInfo.user.local_password) !== Boolean(auth.user.local_password)) return false;
|
|
if ((sessionInfo.active_tenant ?? sessionInfo.tenant).id !== activeTenant.id) return false;
|
|
if (auth.principal?.auth_method && sessionInfo.auth_method !== auth.principal.auth_method) return false;
|
|
if (auth.principal?.session_id && sessionInfo.session_id && auth.principal.session_id !== sessionInfo.session_id) return false;
|
|
if (auth.principal?.api_key_id && sessionInfo.api_key_id && auth.principal.api_key_id !== sessionInfo.api_key_id) return false;
|
|
return true;
|
|
}
|
|
|
|
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
|
|
const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system";
|
|
return {
|
|
compact_tables: Boolean(value?.compact_tables ?? DEFAULT_UI_PREFERENCES.compact_tables),
|
|
show_inline_help_hints: Boolean(value?.show_inline_help_hints ?? DEFAULT_UI_PREFERENCES.show_inline_help_hints),
|
|
reduce_motion: Boolean(value?.reduce_motion ?? DEFAULT_UI_PREFERENCES.reduce_motion),
|
|
sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
|
|
theme,
|
|
palette: normalizeOptionalUiPalette(value?.palette),
|
|
appearance_overrides: value?.appearance_overrides ?? null
|
|
};
|
|
}
|
|
|
|
function normalizeUiPalette(value: UserUiPalette | string | null | undefined): UserUiPalette {
|
|
return value === "civic_blue" || value === "forest" || value === "plum"
|
|
? value
|
|
: "default";
|
|
}
|
|
|
|
function normalizeOptionalUiPalette(value: UserUiPalette | string | null | undefined): UserUiPalette | null {
|
|
return value == null ? null : normalizeUiPalette(value);
|
|
}
|
|
|
|
function mergeWebModules(localModules: PlatformWebModule[], remoteModules: PlatformWebModule[]): PlatformWebModule[] {
|
|
if (remoteModules.length === 0) return localModules;
|
|
const seen = new Set(localModules.map((module) => module.id));
|
|
return [
|
|
...localModules,
|
|
...remoteModules.filter((module) => {
|
|
if (seen.has(module.id)) return false;
|
|
seen.add(module.id);
|
|
return true;
|
|
})];
|
|
|
|
}
|
|
|
|
const WORKFLOW_VIEW_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
|
|
type StoredWorkflowView = {
|
|
projection: EffectiveViewProjection;
|
|
contextId?: string | null;
|
|
expiresAt: number;
|
|
};
|
|
|
|
function workflowViewStorageKey(auth: AuthInfo): string {
|
|
const tenant = auth.active_tenant ?? auth.tenant;
|
|
return `govoplan:workflow-view:${tenant.id}:${auth.user.account_id}`;
|
|
}
|
|
|
|
function loadStoredWorkflowView(
|
|
auth: AuthInfo
|
|
): EffectiveViewProjection | null {
|
|
try {
|
|
const key = workflowViewStorageKey(auth);
|
|
const raw = window.sessionStorage.getItem(key);
|
|
if (!raw) return null;
|
|
const stored = JSON.parse(raw) as Partial<StoredWorkflowView>;
|
|
if (
|
|
typeof stored.expiresAt !== "number"
|
|
|| stored.expiresAt <= Date.now()
|
|
|| !stored.projection
|
|
|| !Array.isArray(stored.projection.visibleSurfaceIds)
|
|
) {
|
|
window.sessionStorage.removeItem(key);
|
|
return null;
|
|
}
|
|
return stored.projection;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function storeWorkflowView(
|
|
auth: AuthInfo,
|
|
projection: EffectiveViewProjection | null,
|
|
contextId?: string | null
|
|
): void {
|
|
try {
|
|
const key = workflowViewStorageKey(auth);
|
|
if (!projection) {
|
|
window.sessionStorage.removeItem(key);
|
|
return;
|
|
}
|
|
const stored: StoredWorkflowView = {
|
|
projection,
|
|
contextId,
|
|
expiresAt: Date.now() + WORKFLOW_VIEW_SESSION_TTL_MS
|
|
};
|
|
window.sessionStorage.setItem(key, JSON.stringify(stored));
|
|
} catch {
|
|
// Session persistence is optional; the in-memory projection still applies.
|
|
}
|
|
}
|
|
|
|
function clearStoredWorkflowView(auth: AuthInfo): void {
|
|
storeWorkflowView(auth, null);
|
|
}
|