213 lines
8.1 KiB
TypeScript
213 lines
8.1 KiB
TypeScript
import { Navigate, Route, Routes } from "react-router-dom";
|
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
|
import { fetchMe } from "./api/auth";
|
|
import { fetchPlatformModules } from "./api/platform";
|
|
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
|
import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo } 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 { adminReadScopes } from "./utils/permissions";
|
|
import { firstAccessibleRoute, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
|
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
|
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
|
|
|
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
|
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
|
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
|
|
|
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 [reloginMessage, setReloginMessage] = useState("");
|
|
|
|
const webModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
|
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
|
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
|
|
|
function updateSettings(next: ApiSettings) {
|
|
setSettings(next);
|
|
saveApiSettings(next);
|
|
}
|
|
|
|
function updateAuth(next: AuthInfo | null, accessToken?: string) {
|
|
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
|
|
setAuth(next);
|
|
if (accessToken !== undefined) {
|
|
setSettings(nextSettings);
|
|
saveApiSettings(nextSettings);
|
|
}
|
|
}
|
|
|
|
function authFromLoginResponse(response: LoginResponse): AuthInfo {
|
|
const active = response.active_tenant ?? response.tenant;
|
|
return {
|
|
user: response.user,
|
|
tenant: active,
|
|
active_tenant: active,
|
|
tenants: response.tenants ?? [active],
|
|
scopes: response.scopes,
|
|
roles: response.roles,
|
|
groups: response.groups
|
|
};
|
|
}
|
|
|
|
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 || "Your session has expired. Sign in again to continue.");
|
|
}
|
|
|
|
window.addEventListener(AUTH_REQUIRED_EVENT, handleAuthRequired);
|
|
return () => window.removeEventListener(AUTH_REQUIRED_EVENT, handleAuthRequired);
|
|
}, [auth]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setCheckingSession(true);
|
|
fetchMe(settings)
|
|
.then((me) => { if (!cancelled) setAuth(me); })
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
const cleared = { ...settings, accessToken: "" };
|
|
setSettings(cleared);
|
|
saveApiSettings(cleared);
|
|
setAuth(null);
|
|
setPlatformModules(null);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setCheckingSession(false);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
useEffect(() => {
|
|
if (!auth) return;
|
|
|
|
let cancelled = false;
|
|
fetchPlatformModules(settings)
|
|
.then((response) => { if (!cancelled) setPlatformModules(response.modules); })
|
|
.catch(() => { if (!cancelled) setPlatformModules(null); });
|
|
|
|
return () => { cancelled = true; };
|
|
}, [auth?.user.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
useEffect(() => {
|
|
if (!auth) return;
|
|
|
|
let inFlight = false;
|
|
let lastRefreshAt = 0;
|
|
|
|
async function refreshVisibleSession() {
|
|
if (document.visibilityState === "hidden" || inFlight) return;
|
|
const now = Date.now();
|
|
if (now - lastRefreshAt < 5_000) return;
|
|
|
|
inFlight = true;
|
|
lastRefreshAt = now;
|
|
try {
|
|
setAuth(await fetchMe(settings));
|
|
} catch {
|
|
// 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 () => {
|
|
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 (
|
|
<PlatformModulesProvider modules={webModules}>
|
|
<UnsavedChangesProvider>
|
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
|
<div className="public-landing">
|
|
<section className="public-card">
|
|
<div className="public-kicker">GovOPlaN</div>
|
|
<h1>Checking session...</h1>
|
|
<p>Please wait while the local session is verified.</p>
|
|
</section>
|
|
</div>
|
|
</AppShell>
|
|
</UnsavedChangesProvider>
|
|
</PlatformModulesProvider>
|
|
);
|
|
}
|
|
|
|
if (!auth) {
|
|
return (
|
|
<PlatformModulesProvider modules={webModules}>
|
|
<UnsavedChangesProvider>
|
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
|
<PublicLandingPage settings={settings} onLogin={handlePublicLogin} />
|
|
</AppShell>
|
|
</UnsavedChangesProvider>
|
|
</PlatformModulesProvider>
|
|
);
|
|
}
|
|
|
|
const defaultRoute = firstAccessibleRoute(auth, webModules);
|
|
|
|
return (
|
|
<PlatformModulesProvider modules={webModules}>
|
|
<UnsavedChangesProvider>
|
|
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems}>
|
|
<Suspense fallback={<div className="content-pad"><p className="muted">Loading module...</p></div>}>
|
|
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
|
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
|
<Route path="/dashboard" element={<DashboardPage settings={settings} />} />
|
|
{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 })}
|
|
</PermissionBoundary>
|
|
}
|
|
/>
|
|
))}
|
|
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
|
|
<Route path="/admin" element={
|
|
<PermissionBoundary auth={auth} anyOf={adminReadScopes} fallback={defaultRoute}>
|
|
<AdminPage settings={settings} auth={auth} onAuthChange={updateAuth} />
|
|
</PermissionBoundary>
|
|
} />
|
|
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
|
|
</Routes>
|
|
</Suspense>
|
|
{reloginMessage && (
|
|
<LoginModal
|
|
settings={settings}
|
|
title="Session expired"
|
|
message={reloginMessage}
|
|
onClose={() => setReloginMessage("")}
|
|
onLogin={handleRelogin}
|
|
/>
|
|
)}
|
|
</AppShell>
|
|
</UnsavedChangesProvider>
|
|
</PlatformModulesProvider>
|
|
);
|
|
}
|