feat(shell): give every Dashboard page a real URL
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m17s
CI / image-build (pull_request) Successful in 1m10s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s

The Dashboard had one address. The API served the page only at `/`, both
reference proxies forwarded only `/`, and the open module lived in React
state, so a refresh went back to the first module, nothing could be
bookmarked, and Back left the Dashboard (DESIGN.md §2.7).

ADR 0057 makes the address bar the source of truth:

- `/` is a host-owned Overview, now the landing page; `/modules/<id>`
  mounts that module. An id the server's navigation does not list shows
  "This page isn't available" and loads nothing; any other Dashboard
  address is a host-owned "Page not found".
- `/modules`, `/settings`, and `/account` are reserved whole, from one
  list (`@ekklesia/shell/dashboard-routes`) that the shell's router and
  the API's page routes both read. The API serves the same frozen page,
  ETag, and security headers anywhere inside them; look-alikes and other
  methods stay the sanitized 404.
- A small host-owned history replaces the selected-module state, built in
  the entry point so it stays the only file touching `window`. Navigation
  items are real links; modified clicks are left to the browser. Focus
  moves to the page after an in-place change, and the tab is named after
  the page.
- Sign-in still happens in place with no return value (ADR 0034), so a
  deep link opens its own page after sign-in.
- Both reference Caddyfiles forward the new paths; the deploy smoke now
  reloads a module's address through the real proxy and requires it to
  mount again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 08:11:02 -05:00
co-authored by Claude Opus 5.5
parent 44430b5d59
commit 9afe9376c4
31 changed files with 1634 additions and 120 deletions
+160 -79
View File
@@ -1,7 +1,15 @@
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 {
type ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import {
HostAuthError,
type HostAuthLanding,
@@ -12,6 +20,7 @@ import {
signInWithEmail,
signOutHost,
} from "../auth-protocol.js";
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
import {
bootstrapHost,
type DashboardNavigationEntry,
@@ -20,8 +29,11 @@ import {
HostProtocolError,
loadHostPrincipal,
} from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.js";
import { DevicePanel } from "./DevicePanel.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import type { HostHistory } from "./host-history.js";
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js";
@@ -101,6 +113,15 @@ export interface HostAppProps {
* 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;
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter;
}
@@ -167,6 +188,8 @@ const shellStyles = {
main: {
boxSizing: "border-box",
minWidth: 0,
// A programmatic focus target after a page change (never in the tab order), not a control.
outline: "none",
overflowX: "auto",
padding: designToken("spacing.xl"),
},
@@ -194,25 +217,30 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
];
}
function ModuleButton(props: {
function ModuleLink(props: {
readonly entry: DashboardNavigationEntry;
readonly selected: boolean;
readonly onSelect: () => void;
readonly history: HostHistory;
readonly route: DashboardRoute;
}): ReactElement {
const moduleId = props.entry.descriptor.id;
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"
<DashboardLink
current={props.route.kind === "module" && props.route.moduleId === moduleId}
data-testid={`select-${moduleId}`}
history={props.history}
to={{ kind: "module", moduleId }}
>
{props.entry.name}
</Button>
</DashboardLink>
);
}
/** 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 === "module" ? `module:${route.moduleId}` : route.kind;
}
function HostMessage(props: {
readonly children: string;
readonly testId: string;
@@ -265,21 +293,27 @@ async function resolveBootstrapState(
/**
* 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.
* 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 [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;
const mainRef = useRef<HTMLElement>(null);
const shownRouteRef = useRef<string | undefined>(undefined);
const { config, history, importModule, setDocumentTitle } = props;
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
const readPathname = useCallback(() => history.pathname(), [history]);
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
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.
@@ -306,14 +340,16 @@ export function HostApp(props: HostAppProps): ReactElement {
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);
}
}, []);
// A non-canonical spelling of a real page (`/modules/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) {
history.replace(dashboardPath(route));
}
}, [history, pathname, route]);
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -329,7 +365,6 @@ export function HostApp(props: HostAppProps): ReactElement {
void bootstrapAttempt;
const { controller, generation } = beginTransition();
setState({ status: "loading" });
setSelectedKey(undefined);
void resolveBootstrapState(config, controller.signal).then((resolved) => {
if (!isCurrent(controller, generation)) {
return;
@@ -381,7 +416,6 @@ export function HostApp(props: HostAppProps): ReactElement {
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);
@@ -435,7 +469,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const transition = beginTransition();
transition.controller.abort();
activeTransitionRef.current = undefined;
setSelectedKey(undefined);
setAuthView({ kind: "sign-in" });
setState({ status: "anonymous" });
}
@@ -451,7 +484,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const transition = beginTransition();
transition.controller.abort();
activeTransitionRef.current = undefined;
setSelectedKey(undefined);
console.error("[ekklesia] session revalidation failed");
setState({ status: "failed" });
})
@@ -464,6 +496,41 @@ export function HostApp(props: HostAppProps): ReactElement {
[beginTransition, config],
);
const openEntry =
state.status === "ready" && route.kind === "module"
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
: undefined;
const pageTitle =
state.status !== "ready"
? undefined
: route.kind === "overview"
? "Overview"
: route.kind === "module"
? (openEntry?.name ?? "This page isn’t available")
: "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) {
mainRef.current?.focus();
}
shownRouteRef.current = currentRouteKey;
}, [currentRouteKey, state.status]);
const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1);
if (state.status === "loading") {
@@ -553,7 +620,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const { bootstrap } = state;
const entries = allEntries(bootstrap);
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
const { groups, ungrouped } = bootstrap.navigation;
return (
@@ -569,56 +635,71 @@ export function HostApp(props: HostAppProps): ReactElement {
</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: designToken("spacing.xl") }}>
{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>
)}
<nav aria-label="Dashboard" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<DashboardLink
current={route.kind === "overview"}
data-testid="select-overview"
history={history}
to={{ kind: "overview" }}
>
Overview
</DashboardLink>
{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>
) : undefined}
{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) => (
<ModuleLink
entry={entry}
history={history}
key={moduleKey(entry)}
route={route}
/>
))}
</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) => (
<ModuleLink
entry={entry}
history={history}
key={moduleKey(entry)}
route={route}
/>
))}
</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 aria-label={pageTitle} ref={mainRef} style={shellStyles.main} tabIndex={-1}>
{route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry ? (
<ModuleUiFrame
config={config}
descriptor={openEntry.descriptor}
{...(importModule ? { importModule } : {})}
key={moduleKey(openEntry)}
onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal}
/>
) : (
<ModuleUnavailablePage history={history} />
)
) : (
<NotFoundPage history={history} />
)}
</main>
</div>
);