import { designToken } from "@ekklesia/design-tokens"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import { Button } from "@ekklesia/ui"; import { type ReactElement, useCallback, useEffect, useRef, useState } from "react"; import { HostAuthError, type HostAuthLanding, type HostSignInCredentials, requestPasswordReset, resendVerificationEmail, resetPassword, signInWithEmail, signOutHost, } from "../auth-protocol.js"; import { bootstrapHost, type DashboardNavigationEntry, type EkklesiaHostConfig, type HostBootstrap, HostProtocolError, loadHostPrincipal, } from "../host-protocol.js"; import { DevicePanel } from "./DevicePanel.js"; import { ForgotPasswordForm } from "./ForgotPasswordForm.js"; import { LoginForm } from "./LoginForm.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js"; import { ResetPasswordForm } from "./ResetPasswordForm.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; /** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */ readonly importModule?: ModuleUiImporter; } /** * The shell's own chrome, expressed in the platform's global semantic tokens rather than in literal * colours (ADR 0039). Nothing here is a brand decision any more: changing * `--ekklesia-background-page` at the host root changes the Dashboard and every mounted module * artifact together, because custom properties inherit through the artifacts' isolated roots. */ const shellStyles = { actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" }, aside: { borderRight: `${designToken("border.width")} solid ${designToken("border.divider")}`, boxSizing: "border-box", minWidth: "13rem", padding: designToken("spacing.lg"), width: "13rem", }, groupLabel: { color: designToken("text.muted"), fontSize: designToken("typography.size-xs"), letterSpacing: "0.05em", margin: `0 0 ${designToken("spacing.xs")}`, textTransform: "uppercase", }, header: { alignItems: "center", borderBottom: `${designToken("border.width")} solid ${designToken("border.divider")}`, display: "flex", fontWeight: designToken("typography.weight-bold"), gridColumn: "1 / -1", justifyContent: "space-between", padding: `${designToken("spacing.md")} ${designToken("spacing.lg")}`, }, heading: { font: "inherit", margin: 0 }, layout: { background: designToken("background.page"), color: designToken("text.primary"), display: "grid", fontFamily: designToken("typography.family-sans"), gridTemplateColumns: "13rem minmax(0, 1fr)", minHeight: "100vh", minWidth: 0, width: "100%", }, main: { boxSizing: "border-box", minWidth: 0, overflowX: "auto", padding: designToken("spacing.lg"), }, 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 ModuleButton(props: { readonly entry: DashboardNavigationEntry; readonly selected: boolean; readonly onSelect: () => void; }): ReactElement { 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, URL * parser, location assignment, or client router in this shell, so the current internal Dashboard URL * simply remains the current URL through sign-in and no external/protocol-relative return value can * become a navigation sink. Modules receive the same five-field 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 [selectedKey, setSelectedKey] = useState(undefined); const [bootstrapAttempt, setBootstrapAttempt] = useState(0); const activeTransitionRef = useRef(undefined); const revalidationRef = useRef(undefined); const generationRef = useRef(0); const mountedRef = useRef(true); const { config, importModule } = props; // 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, [], ); const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => { setState(resolved); if (resolved.status === "ready") { const first = allEntries(resolved.bootstrap)[0]; setSelectedKey(first ? moduleKey(first) : undefined); } else { setSelectedKey(undefined); } }, []); 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" }); setSelectedKey(undefined); 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. setSelectedKey(undefined); 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" }); setState( principal.kind === "anonymous" ? { status: "anonymous" } : { 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; } } }, [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; setSelectedKey(undefined); setAuthView({ kind: "sign-in" }); setState({ status: "anonymous" }); } }) .catch(() => { if ( controller.signal.aborted || !mountedRef.current || generationRef.current !== observedGeneration ) { return; } const transition = beginTransition(); transition.controller.abort(); activeTransitionRef.current = undefined; setSelectedKey(undefined); console.error("[ekklesia] session revalidation failed"); setState({ status: "failed" }); }) .finally(() => { if (revalidationRef.current === controller) { revalidationRef.current = undefined; } }); }, [beginTransition, config], ); 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; const entries = allEntries(bootstrap); const selected = entries.find((entry) => moduleKey(entry) === selectedKey); const { groups, ungrouped } = bootstrap.navigation; return (

Ekklesia

{selected ? ( ) : undefined}
); }