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, 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, 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, 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"; import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext"; const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage")); const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); const DEFAULT_UI_PREFERENCES: UserUiPreferences = { compact_tables: false, show_inline_help_hints: true, reduce_motion: false, sticky_section_sidebars: true, theme: "system" }; export default function App() { const [settings, setSettings] = useState(() => loadApiSettings()); const [auth, setAuth] = useState(null); const [checkingSession, setCheckingSession] = useState(true); const [platformModules, setPlatformModules] = useState(null); const [platformPublicModules, setPlatformPublicModules] = useState(null); const [remoteWebModules, setRemoteWebModules] = useState([]); const [remotePublicWebModules, setRemotePublicWebModules] = useState([]); 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 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 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) { setSettings(next); saveApiSettings(next); } function updateAuth(next: AuthUpdate | null, accessToken?: string) { const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings; setAuth((current) => next ? normalizeAuthInfo(mergeAuthPayload(current, next)) : null); if (accessToken !== undefined) { setSettings(nextSettings); saveApiSettings(nextSettings); } } function authFromLoginResponse(response: LoginResponse): AuthInfo { return normalizeAuthInfo(response); } function handlePublicLogin(response: LoginResponse) { updateAuth(authFromLoginResponse(response), ""); } function handleRelogin(response: LoginResponse) { updateAuth(authFromLoginResponse(response), ""); setReloginMessage(""); } useEffect(() => { function handleAuthRequired(event: Event) { if (!auth) return; const detail = (event as CustomEvent).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); setAuth(normalizeAuthInfo(shellAuth)); } } catch (error) { if (!cancelled) { setBackendReachable(isApiError(error)); const cleared = { ...settings, accessToken: "" }; setSettings(cleared); saveApiSettings(cleared); setAuth(null); setPlatformModules(null); setRemoteWebModules([]); } } finally { if (!cancelled) setCheckingSession(false); } } void bootstrapAuth(); return () => {cancelled = true;}; }, [settings.apiBaseUrl, settings.apiKey]); useEffect(() => { if (!auth) return; let cancelled = false; let inFlight = false; let lastRefreshAt = 0; function loadModules() { inFlight = true; lastRefreshAt = Date.now(); return fetchPlatformModules(settings). then((response) => {if (!cancelled) setPlatformModules(response.modules);}). catch(() => {if (!cancelled) setPlatformModules(null);}). finally(() => {inFlight = false;}); } void loadModules(); function handleModulesChanged() { void loadModules(); } function refreshVisibleModules() { if (document.visibilityState === "hidden" || inFlight) return; if (Date.now() - lastRefreshAt < 5_000) return; void loadModules(); } window.addEventListener(PLATFORM_MODULES_CHANGED_EVENT, handleModulesChanged); window.addEventListener("focus", refreshVisibleModules); document.addEventListener("visibilitychange", refreshVisibleModules); return () => { cancelled = true; window.removeEventListener(PLATFORM_MODULES_CHANGED_EVENT, handleModulesChanged); window.removeEventListener("focus", refreshVisibleModules); document.removeEventListener("visibilitychange", refreshVisibleModules); }; }, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]); 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); 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; }; 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 ]); useEffect(() => { let cancelled = false; if (!auth || !platformModules?.length) { setRemoteWebModules([]); return () => {cancelled = true;}; } loadRemoteWebModules(platformModules, localWebModules). then((modules) => {if (!cancelled) setRemoteWebModules(modules);}). catch(() => {if (!cancelled) setRemoteWebModules([]);}); 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; 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) { const shellAuth = await fetchShellAuth(settings); if (cancelled) return; 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, settings.apiBaseUrl, settings.apiKey]); if (checkingSession) { return (
i18n:govoplan-core.govoplan.a84c0a85

i18n:govoplan-core.checking_session.e7d81968

i18n:govoplan-core.please_wait_while_the_local_session_is_verified.1dbd8fd3

); } if (!auth) { return (

i18n:govoplan-core.loading_module.50161f3c

}> {publicRoutes.map((route) => )} } />
); } const defaultRoute = firstAccessibleRoute(auth, webModules); 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 (

i18n:govoplan-core.loading_module.50161f3c

}> } /> {!dashboardModuleInstalled && } />} {publicRoutes.map((route) => )} {moduleRoutes.map((route) => {route.render({ settings, auth, onAuthChange: updateAuth })} } /> )} } /> } />
{reloginMessage && setReloginMessage("")} onLogin={handleRelogin} /> }
); } 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) }; } 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 ?? "en" : 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 | 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, 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, preferred_language: null, enabled_language_codes: [], ui_preferences: DEFAULT_UI_PREFERENCES }; } 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.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 | 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 }; } 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; })]; }