Files
Ekklesia/surfaces/shell/src/browser/HostApp.tsx
T
ekklesiaandClaude Opus 5.5 405e509bcb
CI / verify (pull_request) Successful in 1m13s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m22s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
Fix the Stage 1 Dashboard UX defects found in assembled review
The assembled Stage 1 Dashboard was reviewed by hand on a desktop, with
the sidebar collapsed, and on an iPhone. This fixes what that review
found, and records the decisions in ADR 0064:

- Discover is its own page at /settings/discover, with Installed and
  Updates beneath it as children. There is no Browse item any more.
  /settings/discover/browse is still read, as an alias, and is respelled
  in place. Parents and children now read as a hierarchy: a guide line,
  text-only children, a disclosure chevron, and parent and current
  states that look different.
- The sidebar is navigation only. Display pairing moves from the sidebar
  and drawer to Settings -> Displays: the paired displays, a deliberate
  Add display dialog, and revocation. Nothing takes focus on arrival;
  the code field is focused only once Add display is chosen.
- Collapsed icons are centred. Nav-link layout moves from inline styles
  to the shell stylesheet, and every collapsed item is one square
  centred on the rail.
- /settings is an overview of the installation: displays paired,
  modules installed and running, and a line for each other area. It
  reads only existing endpoints.
- Phones get a sticky, safe-area-aware header holding the menu trigger
  and the page name. New pages start at the top, and focus no longer
  scrolls content under the header.
- @ekklesia/ui draws checkbox and radio indicators from tokens rather
  than accent-color, so the selected state is the strong accent in
  Light and Dark alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:37:32 -05:00

844 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <SettingsHomePage config={config} history={history} settings={page.settings} />;
case "section":
return page.section === "displays" ? (
<DisplaysSettingsPage
back={settingsBack(history, returnTo)}
config={config}
moduleName={returnTo.moduleName}
/>
) : (
<SettingsSectionPage history={history} returnTo={returnTo} section={page.section} />
);
case "discover":
return <DiscoverPage history={history} returnTo={returnTo} view={page.view} />;
case "module":
return <ModuleSettingsPage history={history} module={page.module} returnTo={returnTo} />;
case "unavailable":
return <SettingsUnavailablePage history={history} reason={page.reason} returnTo={returnTo} />;
case "load-failed":
return <SettingsLoadFailedPage onRetry={props.onRetry} />;
case "not-found":
return <NotFoundPage history={history} />;
}
}
/** 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 (
<ModuleUiFrame
config={props.config}
descriptor={entry.descriptor}
{...(props.importModule ? { importModule: props.importModule } : {})}
navigation={navigation}
onAuthorizationResponse={props.onAuthorizationResponse}
principal={bootstrap.principal}
/>
);
}
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,
* 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<BootstrapState>({ status: "loading" });
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 mainRef = useRef<HTMLElement>(null);
const shownRouteRef = useRef<string | undefined>(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<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,
[],
);
/**
* 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<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.
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 <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;
return (
<ShellLayout
history={history}
mainRef={mainRef}
account={bootstrap.account}
navigation={bootstrap.navigation}
onSignOut={() => void signOut()}
pageTitle={pageTitle}
route={route}
settings={bootstrap.settings}
sidebarPreference={sidebarPreference}
viewport={viewport}
>
{settingsPage !== undefined ? (
<SettingsPageView
config={config}
history={history}
onRetry={retryBootstrap}
page={settingsPage}
returnTo={{
moduleName: (moduleId) =>
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" ? (
<AccountLoadFailedPage onRetry={retryBootstrap} />
) : route.kind === "account" ? (
<AccountHomePage
account={bootstrap.account}
history={history}
onSignOut={() => void signOut()}
/>
) : (
<AppearancePage
account={bootstrap.account}
appearance={appearance}
config={config}
history={history}
onSaved={recordSavedPreferences}
onSessionCheck={() => onAuthorizationResponse(403)}
/>
)
) : route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry === undefined ? (
// Installed but not running, or not known at all: never mounted, whatever the path.
<ModuleUnavailablePage history={history} />
) : openModuleRoute === undefined ? (
<NotFoundPage history={history} />
) : (
<MountedModule
bootstrap={bootstrap}
config={config}
entry={openEntry}
history={history}
{...(importModule ? { importModule } : {})}
// Keyed by module identity, not path: moving between its own pages never remounts it.
key={moduleKey(openEntry)}
onAuthorizationResponse={onAuthorizationResponse}
/>
)
) : (
<NotFoundPage history={history} />
)}
</ShellLayout>
);
}