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
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:
co-authored by
Claude Opus 5.5
parent
44430b5d59
commit
9afe9376c4
@@ -0,0 +1,82 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type { CSSProperties, MouseEvent, ReactElement, ReactNode } from "react";
|
||||
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
|
||||
export interface DashboardLinkProps {
|
||||
readonly history: HostHistory;
|
||||
readonly to: DashboardDestination;
|
||||
readonly children: ReactNode;
|
||||
/** Marks the link as the page currently open, for assistive technology and visually. */
|
||||
readonly current?: boolean;
|
||||
readonly variant?: "navigation" | "action";
|
||||
readonly "data-testid"?: string;
|
||||
}
|
||||
|
||||
const variantClass = {
|
||||
action: "ek-button ek-focusable ek-button--secondary",
|
||||
navigation: "ek-button ek-focusable ek-button--quiet",
|
||||
} as const;
|
||||
|
||||
const navigationStyle: CSSProperties = {
|
||||
justifyContent: "flex-start",
|
||||
textDecoration: "none",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
/** The open page is shown by weight as well as colour, so it never depends on colour alone. */
|
||||
const currentStyle: CSSProperties = {
|
||||
...navigationStyle,
|
||||
background: designToken("state.selected-background"),
|
||||
color: designToken("state.selected-foreground"),
|
||||
fontWeight: designToken("typography.weight-bold"),
|
||||
};
|
||||
|
||||
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
|
||||
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
|
||||
return (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A link to a Dashboard page, as a real `<a href>` (ADR 0057).
|
||||
*
|
||||
* A real link rather than a button, because it is one: it can be opened in a new tab, copied, and
|
||||
* bookmarked, and assistive technology announces it as a link. An ordinary click opens the page in
|
||||
* place through the shell's history instead of reloading the document; every other click — with a
|
||||
* modifier key, or the middle button — is left to the browser.
|
||||
*/
|
||||
export function DashboardLink(props: DashboardLinkProps): ReactElement {
|
||||
const href = dashboardPath(props.to);
|
||||
const variant = props.variant ?? "navigation";
|
||||
return (
|
||||
<a
|
||||
aria-current={props.current ? "page" : undefined}
|
||||
className={variantClass[variant]}
|
||||
data-testid={props["data-testid"]}
|
||||
href={href}
|
||||
onClick={(event) => {
|
||||
if (isBrowserHandledClick(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
props.history.push(href);
|
||||
}}
|
||||
style={
|
||||
variant === "action"
|
||||
? { textDecoration: "none" }
|
||||
: props.current
|
||||
? currentStyle
|
||||
: navigationStyle
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { EmptyState, PageHeader } from "@ekklesia/ui";
|
||||
import type { ReactElement } from "react";
|
||||
import { DashboardLink } from "./DashboardLink.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
|
||||
/**
|
||||
* The Dashboard's landing page (DESIGN.md §5, ADR 0057).
|
||||
*
|
||||
* The Overview is where the Dashboard opens. Its cards will come from modules through a contract
|
||||
* that does not exist yet, so for now it honestly says it is empty rather than inventing content.
|
||||
*/
|
||||
export function OverviewPage(): ReactElement {
|
||||
return (
|
||||
<div data-testid="host-overview">
|
||||
<PageHeader title="Overview" />
|
||||
<EmptyState
|
||||
description="Summaries from your modules will appear here as they become available. Choose a module in the navigation to open it."
|
||||
title="Nothing to show yet"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A module page whose module is not available to this Dashboard: it was disabled or removed, it
|
||||
* provides no user interface, or the address was mistyped. The server's navigation is the only
|
||||
* authority on what exists, and it does not say which of these applies, so neither does this page.
|
||||
* It never echoes the address back.
|
||||
*/
|
||||
export function ModuleUnavailablePage(props: { readonly history: HostHistory }): ReactElement {
|
||||
return (
|
||||
<div data-testid="host-module-unavailable">
|
||||
<PageHeader
|
||||
actions={<OverviewAction history={props.history} />}
|
||||
description="The module it belongs to may have been turned off or removed. If you expected to see it, ask an administrator."
|
||||
title="This page isn’t available"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Any other address inside the Dashboard. */
|
||||
export function NotFoundPage(props: { readonly history: HostHistory }): ReactElement {
|
||||
return (
|
||||
<div data-testid="host-not-found">
|
||||
<PageHeader
|
||||
actions={<OverviewAction history={props.history} />}
|
||||
description="There is nothing at this address. Check it, or start again from the Overview."
|
||||
title="Page not found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewAction(props: { readonly history: HostHistory }): ReactElement {
|
||||
return (
|
||||
<DashboardLink
|
||||
data-testid="host-go-to-overview"
|
||||
history={props.history}
|
||||
to={{ kind: "overview" }}
|
||||
variant="action"
|
||||
>
|
||||
Go to Overview
|
||||
</DashboardLink>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* The shell's one view of the address bar (ADR 0057).
|
||||
*
|
||||
* The Dashboard's URL is the source of truth for which page is open: a refresh, a bookmark, and the
|
||||
* browser's Back and Forward buttons all have to land on the same page that was showing. Everything
|
||||
* in the shell reads the current path and moves between pages through this small interface rather
|
||||
* than through `window.history`, for two reasons. It keeps the entry point the only file in this
|
||||
* package that touches a browser global — the browser implementation is handed the window's pieces,
|
||||
* it does not reach for them. And it lets every routing behaviour be tested against an in-memory
|
||||
* history whose entries a test can inspect.
|
||||
*
|
||||
* Deliberately not a routing library. The shell's pages are a handful of host-owned screens plus one
|
||||
* mounted module, and `react-router-dom` is refused to modules precisely because routing belongs to
|
||||
* the host (ADR 0026); adopting it here would prejudge how a module's own pages are addressed, which
|
||||
* a later decision owns.
|
||||
*/
|
||||
export interface HostHistory {
|
||||
/** The current path, without query or fragment. */
|
||||
pathname(): string;
|
||||
/** Opens a new page, adding a history entry. Opening the page already showing adds none. */
|
||||
push(path: string): void;
|
||||
/** Corrects the current entry in place, adding none — for a non-canonical spelling of the same
|
||||
* page. */
|
||||
replace(path: string): void;
|
||||
/** Notifies after every change, whether made here or by the browser's Back and Forward. */
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
/** The parts of a `Window` the browser history needs, passed in by the entry point. */
|
||||
export interface HostHistoryWindow {
|
||||
readonly location: Pick<Location, "pathname">;
|
||||
readonly history: Pick<History, "pushState" | "replaceState">;
|
||||
addEventListener(type: "popstate", listener: () => void): void;
|
||||
removeEventListener(type: "popstate", listener: () => void): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The address bar itself. `pushState` and `replaceState` fire no event of their own, so this
|
||||
* notifies its subscribers after each one; `popstate` covers Back and Forward.
|
||||
*/
|
||||
export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory {
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = (): void => {
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
return {
|
||||
pathname: () => view.location.pathname,
|
||||
push(path) {
|
||||
if (path === view.location.pathname) {
|
||||
return;
|
||||
}
|
||||
view.history.pushState(null, "", path);
|
||||
notify();
|
||||
},
|
||||
replace(path) {
|
||||
view.history.replaceState(null, "", path);
|
||||
notify();
|
||||
},
|
||||
subscribe(listener) {
|
||||
if (listeners.size === 0) {
|
||||
view.addEventListener("popstate", notify);
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
view.removeEventListener("popstate", notify);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** An in-memory history with the same semantics, whose entries a caller can inspect and step
|
||||
* through. Used by tests; production always uses {@link createBrowserHostHistory}. */
|
||||
export interface MemoryHostHistory extends HostHistory {
|
||||
readonly entries: readonly string[];
|
||||
readonly index: number;
|
||||
/** Steps like the browser's Back (`-1`) and Forward (`1`) buttons. */
|
||||
go(delta: number): void;
|
||||
}
|
||||
|
||||
export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory {
|
||||
const entries: string[] = [initialPath];
|
||||
let index = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = (): void => {
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
return {
|
||||
get entries() {
|
||||
return [...entries];
|
||||
},
|
||||
get index() {
|
||||
return index;
|
||||
},
|
||||
pathname: () => entries[index] ?? "/",
|
||||
push(path) {
|
||||
if (path === entries[index]) {
|
||||
return;
|
||||
}
|
||||
entries.splice(index + 1, entries.length, path);
|
||||
index = entries.length - 1;
|
||||
notify();
|
||||
},
|
||||
replace(path) {
|
||||
entries[index] = path;
|
||||
notify();
|
||||
},
|
||||
go(delta) {
|
||||
const next = Math.min(Math.max(index + delta, 0), entries.length - 1);
|
||||
if (next !== index) {
|
||||
index = next;
|
||||
notify();
|
||||
}
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { consumeAuthLanding } from "../auth-protocol.js";
|
||||
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
|
||||
import { HostApp } from "./HostApp.js";
|
||||
import { createBrowserHostHistory } from "./host-history.js";
|
||||
|
||||
/**
|
||||
* The one place the shell touches a browser global.
|
||||
@@ -23,6 +24,13 @@ const apiBaseUrl = resolveApiOrigin(
|
||||
* `consumeAuthLanding` for why the token must not stay in the URL. */
|
||||
const authLanding = consumeAuthLanding(window.location, window.history);
|
||||
|
||||
/** The address bar, handed to the shell once so no other file reads `window.location` (ADR 0057). */
|
||||
const history = createBrowserHostHistory(window);
|
||||
|
||||
const setDocumentTitle = (title: string): void => {
|
||||
document.title = title;
|
||||
};
|
||||
|
||||
const container = document.getElementById("ekklesia-host");
|
||||
if (!container) {
|
||||
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
|
||||
@@ -53,9 +61,14 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
|
||||
);
|
||||
}
|
||||
return authLanding ? (
|
||||
<HostApp authLanding={authLanding} config={config} />
|
||||
<HostApp
|
||||
authLanding={authLanding}
|
||||
config={config}
|
||||
history={history}
|
||||
setDocumentTitle={setDocumentTitle}
|
||||
/>
|
||||
) : (
|
||||
<HostApp config={config} />
|
||||
<HostApp config={config} history={history} setDocumentTitle={setDocumentTitle} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The Dashboard's URL space (ADR 0057): which paths are Dashboard pages, and what each one means.
|
||||
*
|
||||
* Framework-free and free of browser globals, because two programs read it. The shell parses the
|
||||
* address bar with it, and `apps/api` serves the Dashboard page at exactly the namespaces it lists,
|
||||
* so a refreshed or bookmarked deep link reaches the shell instead of a `404`. One list, imported by
|
||||
* both, is what keeps the server from serving a path the shell cannot interpret, or the shell from
|
||||
* linking to a path the server would refuse.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every namespace the Dashboard owns besides `/` itself. Reserved whole, including paths no page uses
|
||||
* yet, so that adding a Settings or Account page never needs another server or proxy change.
|
||||
*
|
||||
* None of these overlaps a namespace something else owns: `/api` (the API), `/display` (the display
|
||||
* host), and `/assets` (both hosts' scripts).
|
||||
*/
|
||||
export const DASHBOARD_PAGE_PREFIXES = Object.freeze([
|
||||
"/modules",
|
||||
"/settings",
|
||||
"/account",
|
||||
] as const);
|
||||
|
||||
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
|
||||
export type DashboardRoute =
|
||||
| { readonly kind: "overview" }
|
||||
| { readonly kind: "module"; readonly moduleId: string }
|
||||
| { readonly kind: "not-found" };
|
||||
|
||||
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
|
||||
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
|
||||
|
||||
/** Whether the Dashboard page is served at this path: the root, or anything inside a reserved
|
||||
* namespace. Says nothing about whether a page exists there; the shell decides that. */
|
||||
export function isDashboardPagePath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === "/" ||
|
||||
DASHBOARD_PAGE_PREFIXES.some(
|
||||
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function decodeSegment(segment: string): string | undefined {
|
||||
try {
|
||||
const decoded = decodeURIComponent(segment);
|
||||
return decoded.length > 0 ? decoded : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a path into a route.
|
||||
*
|
||||
* `/modules/<id>` names a module's page. The module id is only decoded here, never validated against
|
||||
* a pattern: the shell compares it with the navigation the server resolved, and an id that matches
|
||||
* no entry is an unavailable module, not a malformed URL. Everything below `/modules/<id>/` belongs
|
||||
* to that module's own pages, which need a contract that does not exist yet, so it is `not-found`
|
||||
* for now rather than silently showing the module's first page. One trailing slash is tolerated, as
|
||||
* a person typing a URL expects; the shell then replaces it with the canonical spelling.
|
||||
*/
|
||||
export function parseDashboardPath(pathname: string): DashboardRoute {
|
||||
if (pathname === "/") {
|
||||
return { kind: "overview" };
|
||||
}
|
||||
const segments = pathname.split("/");
|
||||
if (segments[0] !== "") {
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1);
|
||||
if (meaningful.length === 2 && meaningful[0] === "modules") {
|
||||
const moduleId = decodeSegment(meaningful[1] ?? "");
|
||||
if (moduleId !== undefined) {
|
||||
return { kind: "module", moduleId };
|
||||
}
|
||||
}
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
|
||||
/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
|
||||
* own path segment. */
|
||||
export function dashboardPath(route: DashboardDestination): string {
|
||||
if (route.kind === "overview") {
|
||||
return "/";
|
||||
}
|
||||
return `/modules/${encodeURIComponent(route.moduleId)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user