Files
govoplan-core/webui/src/App.tsx

558 lines
24 KiB
TypeScript

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<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 [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
const [backendReachable, setBackendReachable] = useState(true);
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
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<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);
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 (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={publicWebModules}>
<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>
</PlatformModulesProvider>
</PlatformLanguageProvider>);
}
if (!auth) {
return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={publicWebModules}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
<Routes>
{publicRoutes.map((route) =>
<Route
key={`public:${route.path}`}
path={route.path}
element={route.render({ settings, auth: null, onAuthChange: updateAuth })}
/>
)}
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
</Routes>
</Suspense>
</AppShell>
</UnsavedChangesProvider>
</PlatformModulesProvider>
</PlatformLanguageProvider>);
}
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 (
<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}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
{publicRoutes.map((route) =>
<Route
key={`public:${route.path}`}
path={route.path}
element={route.render({ settings, auth, onAuthChange: updateAuth })}
/>
)}
{moduleRoutes.map((route) =>
<Route
key={route.path}
path={route.path}
element={
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
{route.render({ settings, auth, onAuthChange: updateAuth })}
</PermissionBoundary>
} />
)}
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
</Routes>
</Suspense>
{reloginMessage &&
<LoginModal
settings={settings}
title="i18n:govoplan-core.session_expired.b828190e"
message={reloginMessage}
onClose={() => setReloginMessage("")}
onLogin={handleRelogin} />
}
</AppShell>
</UnsavedChangesProvider>
</PlatformModulesProvider>
</PlatformLanguageProvider>);
}
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<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,
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<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
};
}
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;
})];
}