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
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]>
844 lines
32 KiB
TypeScript
844 lines
32 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,
|
||
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>
|
||
);
|
||
}
|