import { designToken } from "@ekklesia/design-tokens"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import { Button } from "@ekklesia/ui"; import { type ReactElement, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { HostAuthError, type HostAuthLanding, type HostSignInCredentials, requestPasswordReset, resendVerificationEmail, resetPassword, signInWithEmail, signOutHost, } from "../auth-protocol.js"; import { type DashboardRoute, dashboardPath, parseDashboardPath, returnTargetFromSearch, } from "../dashboard-routes.js"; import { resolveModuleRoute } from "../module-navigation.js"; import { parseReturnTarget } from "../return-target.js"; import { bootstrapHost, type DashboardNavigationEntry, type EkklesiaHostConfig, type HostAccountPreferences, type HostBootstrap, HostProtocolError, loadHostPrincipal, } from "../host-protocol.js"; import { AccountHomePage, AccountLoadFailedPage, AppearancePage } from "./AccountPages.js"; import { createMemoryAppearanceCache } from "./appearance-cache.js"; import { type AppearancePreference, createDetachedAppearancePreference, } from "./appearance-preference.js"; import { ForgotPasswordForm } from "./ForgotPasswordForm.js"; import type { HostHistory } from "./host-history.js"; import { DisplaysSettingsPage } from "./DisplaysSettingsPage.js"; import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js"; import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js"; import { LoginForm } from "./LoginForm.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js"; import { createModuleUiNavigation } from "./module-ui-navigation.js"; import { ResetPasswordForm } from "./ResetPasswordForm.js"; import { ShellLayout } from "./ShellLayout.js"; import { resolveSettingsPage, type SettingsPage, settingsPageTitle } from "./settings-catalog.js"; import { DiscoverPage, ModuleSettingsPage, SettingsHomePage, SettingsLoadFailedPage, SettingsSectionPage, settingsBack, type SettingsReturn, SettingsUnavailablePage, } from "./SettingsPages.js"; import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js"; import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js"; type AuthenticatedPrincipal = Extract< HostBootstrap["principal"], { readonly kind: "authenticated" } >; type BootstrapState = | { readonly status: "loading" } | { readonly status: "ready"; readonly bootstrap: HostBootstrap } | { readonly status: "anonymous" } | { readonly status: "access-denied"; readonly principal: AuthenticatedPrincipal } | { readonly status: "failed" } | { readonly status: "signing-out" } | { readonly status: "sign-out-failed" }; type ResolvedBootstrapState = Extract< BootstrapState, { readonly status: "ready" | "anonymous" | "access-denied" | "failed" } >; /** * Which host-owned screen an unauthenticated visitor sees. * * A small explicit union rather than a client-side router: these four screens are the entirety of * the platform's anonymous surface, and a routing library would be a dependency and an extra URL * authority for a state machine that fits on a page. Only `reset-password` carries anything, and * what it carries came from an emailed link the shell has already stripped from the address bar. * * None of these states is authenticated, and none can become authenticated: the shell renders the * Dashboard only after `/api/v1/principal` says so, exactly as before. */ type AuthView = | { readonly kind: "sign-in" } | { readonly kind: "forgot-password" } | { readonly kind: "verification-required"; readonly email: string } | { readonly kind: "verification-result"; readonly outcome: VerificationOutcome } | { readonly kind: "reset-password"; readonly token: string } | { readonly kind: "reset-link-invalid" }; type VerificationOutcome = | { readonly kind: "verified" } | { readonly kind: "failed"; readonly expired: boolean }; function authViewFromLanding(landing: HostAuthLanding | undefined): AuthView { if (!landing) { return { kind: "sign-in" }; } if (landing.kind === "verified") { return { kind: "verification-result", outcome: { kind: "verified" } }; } if (landing.kind === "verification-failed") { return { kind: "verification-result", outcome: { expired: landing.expired, kind: "failed" }, }; } if (landing.kind === "reset-password") { return { kind: "reset-password", token: landing.token }; } return { kind: "reset-link-invalid" }; } export interface HostAppProps { /** Must be a stable reference: bootstrap re-runs whenever it changes, so a fresh object literal * built during render would re-fetch forever. The browser entry point builds it once at module * scope. */ readonly config: EkklesiaHostConfig; /** * What an emailed verification or reset link asked this browser to show, already parsed. * * Supplied by the entry point rather than read here, so this component still touches no browser * global. It only ever selects among the host's own anonymous screens: it can name no destination, * so there is nothing an open redirect could use. */ readonly authLanding?: HostAuthLanding; /** * The address bar (ADR 0057). The current path decides which page is open, so a refresh, a * bookmark, and Back and Forward all land where the person was. The entry point passes the * browser's; tests pass an in-memory one. Must be a stable reference. */ readonly history: HostHistory; /** Names the browser tab after the open page. Supplied by the entry point, which owns `document`; * left unset, the shell names nothing. */ readonly setDocumentTitle?: (title: string) => void; /** * Whether the page is laid out for a phone (ADR 0058). The entry point passes the browser's media * query; left unset, the wide layout. Must be a stable reference. */ readonly viewport?: HostViewport; /** * Where the desktop sidebar's expanded or collapsed mode is remembered (ADR 0058). The entry point * passes this browser's storage; left unset, the mode lasts only as long as this component. */ readonly sidebarPreference?: SidebarPreference; /** * The signed-in person's appearance and motion as this page applies them (ADR 0062). The entry * point passes one over the document's theme controller and this browser's pre-paint cache; left * unset, choices apply to nothing and last only as long as this component. Must be a stable * reference. */ readonly appearance?: AppearancePreference; /** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */ readonly importModule?: ModuleUiImporter; } /** * The host's full-page messages (loading, failure, sign-out), in the platform's semantic tokens * (ADR 0039). The signed-in Dashboard's own frame is `ShellLayout` (ADR 0058). */ const shellStyles = { actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" }, message: { color: designToken("text.secondary"), display: "grid", fontFamily: designToken("typography.family-sans"), gap: designToken("spacing.md"), justifyItems: "center", margin: 0, padding: designToken("spacing.lg"), textAlign: "center", }, } as const; /** Identity, not placement: moving a module between groups does not reset what is open. */ function moduleKey(entry: DashboardNavigationEntry): string { return `${entry.descriptor.id}@${entry.descriptor.version}`; } function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry[] { return [ ...bootstrap.navigation.groups.flatMap((group) => group.modules), ...bootstrap.navigation.ungrouped, ]; } function SettingsPageView(props: { readonly config: EkklesiaHostConfig; readonly page: SettingsPage; readonly history: HostHistory; readonly onRetry: () => void; readonly returnTo: SettingsReturn; }): ReactElement { const { config, page, history, returnTo } = props; switch (page.kind) { case "home": return ; case "section": return page.section === "displays" ? ( ) : ( ); case "discover": return ; case "module": return ; case "unavailable": return ; case "load-failed": return ; case "not-found": return ; } } /** A stable identity for "which page is showing", so a non-canonical spelling of the same page is * not a page change. */ function routeKey(route: DashboardRoute): string { return route.kind === "not-found" ? "not-found" : dashboardPath(route); } /** * A `?return=` value the Settings pages may use (ADR 0063), or `undefined`. `parseReturnTarget` * accepts only a canonical same-origin Dashboard address; a module's address is additionally checked * against the modules actually running here and the routes each declares, so Back never points at a * module that is not running or at a page it does not have. */ function usableReturnTarget( raw: string | undefined, entries: readonly DashboardNavigationEntry[], ): string | undefined { const destination = parseReturnTarget(raw); if (destination === undefined) { return undefined; } if (destination.kind !== "module") { return raw; } const entry = entries.find((candidate) => candidate.descriptor.id === destination.moduleId); return entry !== undefined && resolveModuleRoute(entry.pages, destination.path) !== undefined ? raw : undefined; } /** * One mounted module UI and the navigation capability it is handed (ADR 0063). The capability is * created once per mount and reads the address bar live, so it is the same object for the module's * whole life however often it moves between its pages, and the frame never remounts because of it. */ function MountedModule(props: { readonly bootstrap: HostBootstrap; readonly config: EkklesiaHostConfig; readonly entry: DashboardNavigationEntry; readonly history: HostHistory; readonly importModule?: ModuleUiImporter; readonly onAuthorizationResponse: (status: 401 | 403) => void; }): ReactElement { const { bootstrap, entry, history } = props; const settingsRef = useRef(bootstrap.settings); settingsRef.current = bootstrap.settings; const moduleId = entry.descriptor.id; const navigation = useMemo( () => createModuleUiNavigation({ hasSettings: () => { const settings = settingsRef.current; return ( settings !== "unavailable" && settings.modules.some((module) => module.id === moduleId && module.available) ); }, history, moduleId, pages: entry.pages, }), [entry.pages, history, moduleId], ); return ( ); } function HostMessage(props: { readonly children: string; readonly testId: string; readonly actions?: ReactElement; }): ReactElement { return (

{props.children}

{props.actions}
); } /** * Principal first, navigation second. A 403 after an authenticated principal is authorization, not * evidence that the session vanished. Re-read the principal once only to close the narrow race where * a session expires between those two sequential requests. */ async function resolveBootstrapState( config: EkklesiaHostConfig, signal: AbortSignal, ): Promise { try { const bootstrap = await bootstrapHost(config, signal); // Only a person signs into the Dashboard. The test is `=== "authenticated"` and never "not // anonymous", so a principal class added later cannot quietly become a Dashboard login — which // is precisely how a paired display device would otherwise have walked in (ADR 0036). In // practice `/api/v1/principal` never reports a device at all; this is the second lock. return bootstrap.principal.kind === "authenticated" ? { bootstrap: { ...bootstrap, principal: bootstrap.principal }, status: "ready" } : { status: "anonymous" }; } catch (error) { if (error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED") { return { status: "anonymous" }; } if (error instanceof HostProtocolError && error.code === "HOST_ACCESS_DENIED") { try { const principal = await loadHostPrincipal(config, signal); return principal.kind === "authenticated" ? { principal, status: "access-denied" } : { status: "anonymous" }; } catch { return { status: "failed" }; } } return { status: "failed" }; } } /** * The Ekklesia Dashboard shell and its host-owned authentication boundary. * * The login form replaces the protected application in place. There is no redirect parameter, * return value, or location assignment in this shell, so the current internal Dashboard URL simply * remains the current URL through sign-in — a deep link opens on its own page once the person has * signed in — and no external/protocol-relative value can become a navigation sink. The shell only * ever moves between its own paths (ADR 0057). Modules receive the same context after * authentication; they neither render platform login nor receive sign-in/sign-out controls. */ export function HostApp(props: HostAppProps): ReactElement { const [state, setState] = useState({ status: "loading" }); const [bootstrapAttempt, setBootstrapAttempt] = useState(0); const activeTransitionRef = useRef(undefined); const revalidationRef = useRef(undefined); const generationRef = useRef(0); const mountedRef = useRef(true); const mainRef = useRef(null); const shownRouteRef = useRef(undefined); const { config, history, importModule, setDocumentTitle } = props; // Fallbacks for a composition that supplies neither; created once, so they stay stable. const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined)); const [fallbackPreference] = useState(() => createMemorySidebarPreference()); const [fallbackAppearance] = useState(() => createDetachedAppearancePreference(createMemoryAppearanceCache()), ); const viewport = props.viewport ?? fallbackViewport; const sidebarPreference = props.sidebarPreference ?? fallbackPreference; const appearance = props.appearance ?? fallbackAppearance; const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]); // The whole address, so a change of query alone (a new place to return to) re-renders too. const readAddress = useCallback(() => `${history.pathname()}${history.search()}`, [history]); const address = useSyncExternalStore(subscribe, readAddress, readAddress); const pathname = address.split("?")[0] ?? "/"; const search = address.slice(pathname.length); const route = useMemo(() => parseDashboardPath(pathname), [pathname]); // Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign // in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter // cannot resurrect a screen the person has left. const [authView, setAuthView] = useState(() => authViewFromLanding(props.authLanding)); const beginTransition = useCallback(() => { activeTransitionRef.current?.abort(); revalidationRef.current?.abort(); revalidationRef.current = undefined; const controller = new AbortController(); activeTransitionRef.current = controller; generationRef.current += 1; return { controller, generation: generationRef.current }; }, []); const isCurrent = useCallback( (controller: AbortController, generation: number): boolean => mountedRef.current && !controller.signal.aborted && activeTransitionRef.current === controller && generationRef.current === generation, [], ); /** * Every resolved bootstrap reconciles the page's appearance with the account (ADR 0062): the * account's preferences replace whatever this browser cached, so a choice made on another device * is in effect here from this load on. A signed-out page follows the system and keeps no cached * choice, so the next person to use this browser does not inherit one. An account that could not * be loaded leaves the cached choice alone rather than guessing. */ const commitResolvedState = useCallback( (resolved: ResolvedBootstrapState): void => { if (resolved.status === "ready" && resolved.bootstrap.account !== "unavailable") { appearance.apply(resolved.bootstrap.account.preferences); } else if (resolved.status === "anonymous") { appearance.forget(); } setState(resolved); }, [appearance], ); /** A saved preference, as the account answered, kept in the bootstrap's copy of the account. */ const recordSavedPreferences = useCallback((preferences: HostAccountPreferences): void => { setState((current) => current.status === "ready" && current.bootstrap.account !== "unavailable" ? { ...current, bootstrap: { ...current.bootstrap, account: { ...current.bootstrap.account, preferences }, }, } : current, ); }, []); // A non-canonical spelling of a real page (`/example/`) is corrected in place, adding no // history entry, so what is bookmarked or shared next is the one canonical address. useEffect(() => { if (route.kind !== "not-found" && dashboardPath(route) !== pathname) { // The query is kept: it is where to return to (ADR 0063), and respelling the path must not // lose it. history.replace(`${dashboardPath(route)}${search}`); } }, [history, pathname, route, search]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; activeTransitionRef.current?.abort(); revalidationRef.current?.abort(); }; }, []); useEffect(() => { // The value is an explicit manual-retry generation; reading it is what makes Retry re-run this // otherwise config-driven bootstrap effect. void bootstrapAttempt; const { controller, generation } = beginTransition(); setState({ status: "loading" }); void resolveBootstrapState(config, controller.signal).then((resolved) => { if (!isCurrent(controller, generation)) { return; } if (resolved.status === "failed") { console.error("[ekklesia] host bootstrap failed"); } commitResolvedState(resolved); activeTransitionRef.current = undefined; }); return () => { if (activeTransitionRef.current === controller) { controller.abort(); activeTransitionRef.current = undefined; } }; }, [beginTransition, bootstrapAttempt, commitResolvedState, config, isCurrent]); const authenticate = useCallback( async (credentials: HostSignInCredentials, formSignal: AbortSignal): Promise => { const { controller, generation } = beginTransition(); const abortFromForm = (): void => controller.abort(); formSignal.addEventListener("abort", abortFromForm, { once: true }); try { await signInWithEmail(config, credentials, controller.signal); const resolved = await resolveBootstrapState(config, controller.signal); if (!isCurrent(controller, generation)) { throw new HostAuthError("AUTH_REQUEST_FAILED"); } if (resolved.status === "failed") { console.error("[ekklesia] post-sign-in principal resolution failed"); } commitResolvedState(resolved); activeTransitionRef.current = undefined; if (resolved.status === "anonymous" || resolved.status === "failed") { throw new HostAuthError("AUTH_REQUEST_FAILED"); } } finally { formSignal.removeEventListener("abort", abortFromForm); if (activeTransitionRef.current === controller) { activeTransitionRef.current = undefined; } } }, [beginTransition, commitResolvedState, config, isCurrent], ); const signOut = useCallback(async (): Promise => { const { controller, generation } = beginTransition(); // The authenticated subtree disappears before the request starts. That synchronously schedules // ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions. setState({ status: "signing-out" }); try { await signOutHost(config, controller.signal); const principal = await loadHostPrincipal(config, controller.signal); if (!isCurrent(controller, generation)) { return; } // Returning to the anonymous shell always returns to the sign-in form. Without this, signing // out while a verification or reset screen was the last anonymous view would show that screen // again, describing an emailed link the person has long since finished with. setAuthView({ kind: "sign-in" }); if (principal.kind === "anonymous") { appearance.forget(); setState({ status: "anonymous" }); } else { setState({ status: "sign-out-failed" }); } } catch { if (isCurrent(controller, generation)) { console.error("[ekklesia] sign out failed"); setState({ status: "sign-out-failed" }); } } finally { if (activeTransitionRef.current === controller) { activeTransitionRef.current = undefined; } } }, [appearance, beginTransition, config, isCurrent]); const onAuthorizationResponse = useCallback( (status: 401 | 403): void => { // Module handlers are not the platform auth authority: one may emit an ordinary local 401, // while Ekklesia authorization uses 403 for both anonymous and authenticated denials. Re-read // principal once, de-duplicated across a burst of either status. Only an anonymous answer // expires the shell; an authenticated answer leaves the module's original response alone. A // principal failure is an unavailable platform, never a fabricated logout. void status; if (revalidationRef.current) { return; } const controller = new AbortController(); const observedGeneration = generationRef.current; revalidationRef.current = controller; void loadHostPrincipal(config, controller.signal) .then((principal) => { if ( controller.signal.aborted || !mountedRef.current || generationRef.current !== observedGeneration ) { return; } if (principal.kind === "anonymous") { const transition = beginTransition(); transition.controller.abort(); activeTransitionRef.current = undefined; setAuthView({ kind: "sign-in" }); appearance.forget(); setState({ status: "anonymous" }); } }) .catch(() => { if ( controller.signal.aborted || !mountedRef.current || generationRef.current !== observedGeneration ) { return; } const transition = beginTransition(); transition.controller.abort(); activeTransitionRef.current = undefined; console.error("[ekklesia] session revalidation failed"); setState({ status: "failed" }); }) .finally(() => { if (revalidationRef.current === controller) { revalidationRef.current = undefined; } }); }, [appearance, beginTransition, config], ); const openEntry = state.status === "ready" && route.kind === "module" ? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId) : undefined; // Which of the open module's own routes this is (ADR 0063): its root, a declared page, or a route // beneath one. Anything else is the host's not-found, and the module is not mounted for it. const openModuleRoute = openEntry !== undefined && route.kind === "module" ? resolveModuleRoute(openEntry.pages, route.path) : undefined; const settingsPage = state.status === "ready" && (route.kind === "settings" || route.kind === "settings-section" || route.kind === "discover" || route.kind === "module-settings") ? resolveSettingsPage( route, state.bootstrap.settings, new Set(allEntries(state.bootstrap).map((entry) => entry.descriptor.id)), ) : undefined; const pageTitle = state.status !== "ready" ? undefined : settingsPage !== undefined ? settingsPageTitle(settingsPage) : route.kind === "overview" ? "Overview" : route.kind === "module" ? openEntry === undefined ? "This page isn’t available" : openModuleRoute === undefined ? "Page not found" : openModuleRoute.page === undefined ? openEntry.name : `${openModuleRoute.page.label} · ${openEntry.name}` : route.kind === "account" ? "Account" : route.kind === "account-section" ? "Appearance" : "Page not found"; const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle; useEffect(() => { if (documentTitle !== undefined) { setDocumentTitle?.(documentTitle); } }, [documentTitle, setDocumentTitle]); // Moving to another page moves keyboard and screen-reader focus to the new page's content, as a // full page load would have. The first page shown after sign-in or a refresh is left alone, and a // canonical respelling of the same page is not a move. const currentRouteKey = routeKey(route); useEffect(() => { if (state.status !== "ready") { shownRouteRef.current = undefined; return; } if (shownRouteRef.current !== undefined && shownRouteRef.current !== currentRouteKey) { // A new page starts at its top, as a full page load would, and focusing it must not scroll // it: focus() alone would scroll the page's top to the very top of the viewport, beneath // the phone's sticky header, so every page opened from the drawer started under it. const main = mainRef.current; main?.focus({ preventScroll: true }); main?.ownerDocument.scrollingElement?.scrollTo?.({ left: 0, top: 0 }); } shownRouteRef.current = currentRouteKey; }, [currentRouteKey, state.status]); const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1); if (state.status === "loading") { return Loading Ekklesia…; } if (state.status === "anonymous") { const returnToSignIn = (): void => setAuthView({ kind: "sign-in" }); if (authView.kind === "forgot-password") { return ( requestPasswordReset(config, email, signal)} onReturnToSignIn={returnToSignIn} /> ); } if (authView.kind === "verification-required") { return ( resendVerificationEmail(config, email, signal)} onReturnToSignIn={returnToSignIn} /> ); } if (authView.kind === "verification-result") { return ; } if (authView.kind === "reset-password") { return ( resetPassword(config, input, signal)} onReturnToSignIn={returnToSignIn} token={authView.token} /> ); } if (authView.kind === "reset-link-invalid") { return ; } return ( setAuthView({ email, kind: "verification-required" })} onForgotPassword={() => setAuthView({ kind: "forgot-password" })} /> ); } if (state.status === "failed") { return ( Retry} testId="host-error"> Ekklesia could not be reached. ); } if (state.status === "signing-out") { return Signing out…; } if (state.status === "sign-out-failed") { return ( } testId="host-sign-out-error" > Ekklesia could not sign you out. Try again. ); } if (state.status === "access-denied") { return ( void signOut()}> Sign out } testId="host-access-denied" > You are signed in, but do not have access to this Dashboard. ); } const { bootstrap } = state; return ( void signOut()} pageTitle={pageTitle} route={route} settings={bootstrap.settings} sidebarPreference={sidebarPreference} viewport={viewport} > {settingsPage !== undefined ? ( allEntries(bootstrap).find((entry) => entry.descriptor.id === moduleId)?.name, target: usableReturnTarget(returnTargetFromSearch(search), allEntries(bootstrap)), }} /> ) : route.kind === "account" || route.kind === "account-section" ? ( bootstrap.account === "unavailable" ? ( ) : route.kind === "account" ? ( void signOut()} /> ) : ( onAuthorizationResponse(403)} /> ) ) : route.kind === "overview" ? ( ) : route.kind === "module" ? ( openEntry === undefined ? ( // Installed but not running, or not known at all: never mounted, whatever the path. ) : openModuleRoute === undefined ? ( ) : ( ) ) : ( )} ); }