Files
Ekklesia/surfaces/shell/src/browser/HostApp.tsx
T
ekklesia 2969ed4992
CI / verify (pull_request) Failing after 16s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
fix: keep dashboard shell dimensions stable
2026-09-29 18:15:46 -05:00

606 lines
22 KiB
TypeScript

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 (
<Button
aria-current={props.selected ? "page" : undefined}
aria-pressed={props.selected}
data-testid={`select-${props.entry.descriptor.id}`}
onClick={props.onSelect}
style={{ justifyContent: "flex-start", width: "100%" }}
variant="quiet"
>
{props.entry.name}
</Button>
);
}
function HostMessage(props: {
readonly children: string;
readonly testId: string;
readonly actions?: ReactElement;
}): ReactElement {
return (
<main aria-live="polite" data-testid={props.testId} style={shellStyles.message}>
<p style={{ margin: 0 }}>{props.children}</p>
{props.actions}
</main>
);
}
/**
* 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<ResolvedBootstrapState> {
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<BootstrapState>({ status: "loading" });
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
const [bootstrapAttempt, setBootstrapAttempt] = useState(0);
const activeTransitionRef = useRef<AbortController | undefined>(undefined);
const revalidationRef = useRef<AbortController | undefined>(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<AuthView>(() => 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<void> => {
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<void> => {
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 <HostMessage testId="host-loading">Loading Ekklesia…</HostMessage>;
}
if (state.status === "anonymous") {
const returnToSignIn = (): void => setAuthView({ kind: "sign-in" });
if (authView.kind === "forgot-password") {
return (
<ForgotPasswordForm
onRequestReset={(email, signal) => requestPasswordReset(config, email, signal)}
onReturnToSignIn={returnToSignIn}
/>
);
}
if (authView.kind === "verification-required") {
return (
<VerificationNotice
email={authView.email}
onResend={(email, signal) => resendVerificationEmail(config, email, signal)}
onReturnToSignIn={returnToSignIn}
/>
);
}
if (authView.kind === "verification-result") {
return <VerificationResult onReturnToSignIn={returnToSignIn} outcome={authView.outcome} />;
}
if (authView.kind === "reset-password") {
return (
<ResetPasswordForm
onResetPassword={(input, signal) => resetPassword(config, input, signal)}
onReturnToSignIn={returnToSignIn}
token={authView.token}
/>
);
}
if (authView.kind === "reset-link-invalid") {
return <ResetLinkInvalid onReturnToSignIn={returnToSignIn} />;
}
return (
<LoginForm
onAuthenticate={authenticate}
onEmailNotVerified={(email) => setAuthView({ email, kind: "verification-required" })}
onForgotPassword={() => setAuthView({ kind: "forgot-password" })}
/>
);
}
if (state.status === "failed") {
return (
<HostMessage actions={<Button onClick={retryBootstrap}>Retry</Button>} testId="host-error">
Ekklesia could not be reached.
</HostMessage>
);
}
if (state.status === "signing-out") {
return <HostMessage testId="host-signing-out">Signing out…</HostMessage>;
}
if (state.status === "sign-out-failed") {
return (
<HostMessage
actions={
<div style={shellStyles.actions}>
<Button onClick={() => void signOut()}>Retry sign out</Button>
<Button onClick={retryBootstrap}>Return to Ekklesia</Button>
</div>
}
testId="host-sign-out-error"
>
Ekklesia could not sign you out. Try again.
</HostMessage>
);
}
if (state.status === "access-denied") {
return (
<HostMessage
actions={
<Button data-testid="host-logout" onClick={() => void signOut()}>
Sign out
</Button>
}
testId="host-access-denied"
>
You are signed in, but do not have access to this Dashboard.
</HostMessage>
);
}
const { bootstrap } = state;
const entries = allEntries(bootstrap);
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
const { groups, ungrouped } = bootstrap.navigation;
return (
<div data-testid="host-shell" style={shellStyles.layout}>
<header style={shellStyles.header}>
<h1 style={shellStyles.heading}>Ekklesia</h1>
<Button data-testid="host-logout" onClick={() => void signOut()}>
Sign out
</Button>
</header>
<aside style={shellStyles.aside}>
<DevicePanel config={config} />
{entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : (
<nav aria-label="Modules" style={{ display: "grid", gap: "1rem" }}>
{groups.map((group) => (
<section data-testid={`group-${group.id}`} key={group.id}>
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
<div style={{ display: "grid", gap: "0.125rem" }}>
{group.modules.map((entry) => (
<ModuleButton
entry={entry}
key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))}
selected={moduleKey(entry) === selectedKey}
/>
))}
</div>
</section>
))}
{ungrouped.length > 0 ? (
<section data-testid="group-ungrouped">
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
<div style={{ display: "grid", gap: "0.125rem" }}>
{ungrouped.map((entry) => (
<ModuleButton
entry={entry}
key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))}
selected={moduleKey(entry) === selectedKey}
/>
))}
</div>
</section>
) : undefined}
</nav>
)}
</aside>
<main style={shellStyles.main}>
{selected ? (
<ModuleUiFrame
config={config}
descriptor={selected.descriptor}
{...(importModule ? { importModule } : {})}
key={moduleKey(selected)}
onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal}
/>
) : undefined}
</main>
</div>
);
}