initial commit after split
This commit is contained in:
205
webui/src/App.tsx
Normal file
205
webui/src/App.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { fetchMe } from "./api/auth";
|
||||
import { fetchPlatformModules } from "./api/platform";
|
||||
import { loadApiSettings, saveApiSettings } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo } from "./types";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
import { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary";
|
||||
import { adminReadScopes } from "./utils/permissions";
|
||||
import { firstAccessibleRoute, navItemsForModules, resolveInstalledWebModules } from "./platform/modules";
|
||||
import PlaceholderPage from "./features/PlaceholderPage";
|
||||
import { getCampaign } from "@govoplan/campaign-webui";
|
||||
|
||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||
const CampaignListPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.CampaignListPage })));
|
||||
const CampaignWorkspace = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.CampaignWorkspace })));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
||||
const TemplatesPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.TemplatesPage })));
|
||||
const FilesPage = lazy(() => import("@govoplan/files-webui").then((module) => ({ default: module.FilesPage })));
|
||||
const OperatorQueuePage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.OperatorQueuePage })));
|
||||
const AddressBookPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.AddressBookPage })));
|
||||
|
||||
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 webModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||
const navItems = useMemo(() => navItemsForModules(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 handlePublicLogin(response: LoginResponse) {
|
||||
const active = response.active_tenant ?? response.tenant;
|
||||
updateAuth(
|
||||
{
|
||||
user: response.user,
|
||||
tenant: active,
|
||||
active_tenant: active,
|
||||
tenants: response.tenants ?? [active],
|
||||
scopes: response.scopes,
|
||||
roles: response.roles,
|
||||
groups: response.groups
|
||||
},
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
return (
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems}>
|
||||
<PublicLandingPage settings={settings} onLogin={handlePublicLogin} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultRoute = firstAccessibleRoute(auth, webModules);
|
||||
|
||||
return (
|
||||
<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 />} />
|
||||
<Route path="/campaigns" element={
|
||||
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:read"]} fallback={defaultRoute}>
|
||||
<CampaignListPage settings={settings} />
|
||||
</PermissionBoundary>
|
||||
} />
|
||||
<Route path="/campaigns/:campaignId/*" element={
|
||||
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:read"]} fallback={defaultRoute}>
|
||||
<CampaignResourceRoute settings={settings} auth={auth} />
|
||||
</PermissionBoundary>
|
||||
} />
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/files" element={
|
||||
<PermissionBoundary auth={auth} anyOf={["files:file:read"]} fallback={defaultRoute}>
|
||||
<FilesPage settings={settings} auth={auth} />
|
||||
</PermissionBoundary>
|
||||
} />
|
||||
<Route path="/address-book" element={<AddressBookPage />} />
|
||||
<Route path="/operator" element={
|
||||
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"]} fallback={defaultRoute}>
|
||||
<OperatorQueuePage settings={settings} />
|
||||
</PermissionBoundary>
|
||||
} />
|
||||
<Route path="/reports" element={
|
||||
<PermissionBoundary auth={auth} anyOf={["campaigns:report:read"]} fallback={defaultRoute}>
|
||||
<PlaceholderPage title="Reports" />
|
||||
</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>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CampaignResourceRoute({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
const { campaignId = "" } = useParams();
|
||||
const tenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
const probe = useCallback(() => getCampaign(settings, campaignId), [settings.apiBaseUrl, settings.apiKey, campaignId, tenantId]);
|
||||
|
||||
return (
|
||||
<ResourceAccessBoundary probe={probe} resetKey={`${tenantId}:${campaignId}`} fallback="/campaigns">
|
||||
<CampaignWorkspace settings={settings} auth={auth} />
|
||||
</ResourceAccessBoundary>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user