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 (