Pair displays at the platform's own /display/setup, with or without modules
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 45s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m34s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m52s

Displays are platform-owned, but a screen could only start pairing on a
module's display page: every pairing request named a module, so an
installation with no module that uses displays could not set one up.

- /display/setup is the platform's own display page, served whenever
  the deployment has device support, whatever is installed. It pairs a
  platform display with no module. Once paired, it opens the module it
  is set to show; with none set, it shows its name and waits, rechecking
  so it stays alive and notices revocation. "setup" is now a reserved
  module id.
- Migration core:0009_platform_display_pairing lets a pairing request
  name no module. It relaxes module_id and permission_id and adds a
  both-or-neither check. It is additive and destroys nothing.
- POST /api/v1/device-pairing/requests accepts {} for a platform
  display. Approval accepts an optional moduleId, so the administrator
  can choose an installed display module and the device gets that
  module's declared permission. A module display's own request can't be
  redirected.
- GET /api/v1/device-pairing/self lets a paired device describe itself:
  its name and the module displays it may show. It renews the device's
  cookie.
- GET /api/v1/devices/setup is replaced by
  GET /api/v1/devices/display-modules: the modules a display can be set
  to show.
- Settings -> Displays always offers the /display/setup address, Copy
  setup link and Add display. Add display has an optional "Show on this
  screen" choice, and each paired display says what it shows.
- /display/<module-id> is unchanged, so released Signage 0.1.5 works as
  before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-16 16:43:02 -05:00
co-authored by Claude Opus 5.5
parent 906c015ca8
commit 300d9eab51
44 changed files with 3169 additions and 396 deletions
@@ -11,6 +11,7 @@ import {
PageHeader,
type PageHeaderBack,
Panel,
Select,
TextInput,
} from "@ekklesia/ui";
import {
@@ -25,12 +26,13 @@ import {
} from "react";
import {
approveDevicePairing,
displaySetupUrl,
type EkklesiaHostConfig,
type HostDeviceSummary,
type HostDisplaySetup,
type HostDisplayModule,
HostProtocolError,
listHostDevices,
listHostDisplaySetup,
listHostDisplayModules,
lookupDevicePairing,
revokeHostDevice,
} from "../host-protocol.js";
@@ -40,7 +42,11 @@ import { ShellIcon } from "./shell-icons.js";
/**
* Settings → Displays (ADR 0064, ADR 0065): the platform's own administration page for the screens
* paired with this installation — how to set up a new screen, the paired displays, adding one with
* the code its screen shows, and revoking one.
* the code its screen shows (and, optionally, which module it shows), and revoking one.
*
* Displays are the platform's, whatever is installed: a new screen opens the platform's own
* `/display/setup`, so it can be paired with no module at all, and a module later shows content on a
* display it is set to show rather than creating one.
*
* It is the device surface that used to sit in the sidebar, moved rather than rebuilt: the same
* platform-owned identity, the same endpoints, and the same rules (ADR 0036). One record per
@@ -58,16 +64,22 @@ type ListState =
| { readonly kind: "denied" }
| { readonly kind: "failed" };
type SetupState =
| { readonly kind: "loading" }
| { readonly kind: "ready"; readonly displays: readonly HostDisplaySetup[] }
| { readonly kind: "failed" };
/** The modules a display can be set to show, or `undefined` while unknown (loading or failed). */
type DisplayModules = readonly HostDisplayModule[] | undefined;
/**
* Where the Add display flow is. Once a screen is found, `asked` is the module display it asked
* from, or `undefined` for a platform display from `/display/setup`, which the administrator may set
* to show one of the installed display modules.
*/
type PairingStep =
| { readonly step: "code"; readonly error?: string }
| { readonly step: "finding" }
| { readonly step: "found"; readonly moduleId: string; readonly error?: string }
| { readonly step: "approving"; readonly moduleId: string };
| { readonly step: "found"; readonly asked: string | undefined; readonly error?: string }
| { readonly step: "approving"; readonly asked: string | undefined };
/** The "Nothing yet" choice: a platform display that shows nothing until a module is set for it. */
const NO_MODULE = "";
export interface DisplaysSettingsPageProps {
readonly config: EkklesiaHostConfig;
@@ -79,13 +91,17 @@ export interface DisplaysSettingsPageProps {
readonly now?: () => Date;
}
/** The "Add display" flow: the code from the screen, then the display's one name, then approval. */
/**
* The "Add display" flow: the code from the screen, then the display's one name — and, for a screen
* set up at `/display/setup`, which installed module it shows, or nothing yet — then approval.
*/
function AddDisplayDialog(props: {
readonly config: EkklesiaHostConfig;
readonly open: boolean;
readonly modules: DisplayModules;
readonly moduleName: (moduleId: string) => string | undefined;
readonly onDismiss: () => void;
readonly onPaired: (name: string) => void;
readonly onPaired: (name: string, shows: string | undefined) => void;
}): ReactElement {
const { config, onPaired } = props;
const formId = useId();
@@ -93,6 +109,7 @@ function AddDisplayDialog(props: {
const nameRef = useRef<HTMLInputElement>(null);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [shows, setShows] = useState<string>(NO_MODULE);
const [pairing, setPairing] = useState<PairingStep>({ step: "code" });
// A fresh flow every time it opens.
@@ -100,6 +117,7 @@ function AddDisplayDialog(props: {
if (props.open) {
setCode("");
setName("");
setShows(NO_MODULE);
setPairing({ step: "code" });
}
}, [props.open]);
@@ -120,7 +138,7 @@ function AddDisplayDialog(props: {
setPairing({ step: "finding" });
try {
const lookup = await lookupDevicePairing(config, code.trim());
setPairing({ moduleId: lookup.moduleId, step: "found" });
setPairing({ asked: lookup.moduleId, step: "found" });
} catch {
// One message for expired, consumed, mistyped, and never-existed alike.
setPairing({
@@ -133,23 +151,27 @@ function AddDisplayDialog(props: {
}
if (pairing.step === "found") {
const displayName = name.trim();
setPairing({ moduleId: pairing.moduleId, step: "approving" });
const asked = pairing.asked;
// A screen that asked from a module's own display already shows that module; a platform
// display shows what the administrator chose, if anything.
const chosen = asked === undefined && shows !== NO_MODULE ? shows : undefined;
setPairing({ asked, step: "approving" });
try {
await approveDevicePairing(config, code.trim(), displayName);
onPaired(displayName);
await approveDevicePairing(config, code.trim(), displayName, chosen);
onPaired(displayName, asked ?? chosen);
} catch {
setPairing({
asked,
error: "That display could not be approved. Its code may have expired.",
moduleId: pairing.moduleId,
step: "found",
});
}
}
};
const moduleId =
pairing.step === "found" || pairing.step === "approving" ? pairing.moduleId : undefined;
const naming = moduleId !== undefined;
const naming = pairing.step === "found" || pairing.step === "approving";
const asked = naming ? pairing.asked : undefined;
const modules = props.modules;
const error = pairing.step === "code" || pairing.step === "found" ? pairing.error : undefined;
return (
<Dialog
@@ -185,7 +207,7 @@ function AddDisplayDialog(props: {
description={
naming
? "Give it the name everyone will know it by. Every module that uses this screen shows the same name."
: "Open the display page on the screen you want to add. It shows a six-digit pairing code."
: "Enter the six-digit code the new screen shows."
}
dismissible={!busy}
initialFocusRef={codeRef}
@@ -215,9 +237,15 @@ function AddDisplayDialog(props: {
</Field>
{naming ? (
<>
<p data-testid="device-pairing-found" style={{ margin: 0 }}>
A display is asking to show <strong>{props.moduleName(moduleId) ?? moduleId}</strong>.
</p>
{asked !== undefined ? (
<p data-testid="device-pairing-found" style={{ margin: 0 }}>
A display is asking to show <strong>{props.moduleName(asked) ?? asked}</strong>.
</p>
) : (
<p data-testid="device-pairing-found" style={{ margin: 0 }}>
A new screen is waiting to be paired.
</p>
)}
<Field label="Display name">
{(control) => (
<TextInput
@@ -233,6 +261,35 @@ function AddDisplayDialog(props: {
/>
)}
</Field>
{asked === undefined && modules !== undefined && modules.length > 0 ? (
<Field
hint="You can pair it now and choose later; until then it shows its name and waits."
label="Show on this screen"
>
{(control) => (
<Select
{...control}
data-testid="device-pairing-shows"
disabled={busy}
onChange={(event) => setShows(event.target.value)}
value={shows}
>
<option value={NO_MODULE}>Nothing yet</option>
{modules.map((module) => (
<option key={module.moduleId} value={module.moduleId}>
{module.name}
</option>
))}
</Select>
)}
</Field>
) : undefined}
{asked === undefined && modules !== undefined && modules.length === 0 ? (
<p className="ek-shell-display-card__facts" data-testid="device-pairing-no-modules">
No installed module shows content on screens yet. The display is paired now and
waits, showing its name, until one is.
</p>
) : undefined}
</>
) : undefined}
{error === undefined ? undefined : (
@@ -245,20 +302,17 @@ function AddDisplayDialog(props: {
);
}
/** One display page to open on a screen, with a way to copy it there. */
function SetupLink(props: { readonly setup: HostDisplaySetup; readonly labelled: boolean }) {
/** The setup address to open on a screen, with a way to copy it there. */
function SetupLink(props: { readonly url: string }): ReactElement {
const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle");
return (
<div className="ek-shell-setup-link" data-testid={`display-setup-${props.setup.moduleId}`}>
{props.labelled ? (
<span className="ek-shell-setup-link__module">For {props.setup.name}</span>
) : undefined}
<div className="ek-shell-setup-link">
<span className="ek-shell-setup-link__row">
<span className="ek-shell-setup-link__url" data-testid="display-setup-url">
{props.setup.url}
{props.url}
</span>
<Button
data-testid={`display-setup-copy-${props.setup.moduleId}`}
data-testid="display-setup-copy"
onClick={(event) => {
// The clipboard of this page's own window: the shell reads no browser global here.
const clipboard = event.currentTarget.ownerDocument.defaultView?.navigator.clipboard;
@@ -266,14 +320,14 @@ function SetupLink(props: { readonly setup: HostDisplaySetup; readonly labelled:
setCopy("failed");
return;
}
clipboard.writeText(props.setup.url).then(
clipboard.writeText(props.url).then(
() => setCopy("copied"),
() => setCopy("failed"),
);
}}
size="sm"
>
Copy link
Copy setup link
</Button>
<span aria-live="polite" className="ek-shell-setup-link__status">
{copy === "copied"
@@ -288,74 +342,47 @@ function SetupLink(props: { readonly setup: HostDisplaySetup; readonly labelled:
}
/**
* Setting up a new screen, start to finish, in one place (ADR 0065): the address to open on the
* screen, then **Add display** to enter the code it shows — so the next step is always right under
* the one before, never back up the page. One sentence says what to do; the step-by-step version is
* there for whoever wants it, folded away.
* Setting up a new screen, start to finish, in one place (ADR 0065): the platform's own setup
* address to open on the screen, then **Add display** to enter the code it shows — so the next step
* is always right under the one before, never back up the page. One sentence says what to do; the
* step-by-step version is there for whoever wants it, folded away.
*
* It is platform setup, not any module's: each address is the platform's own display page for a
* module that shows content on screens, as the server lists them. With no such module installed it
* says so, and offers no Add display that could only fail. If the addresses cannot be read, Add
* display is still offered, since a screen may already be showing its code.
* It is the same whatever is installed. The address is `/display/setup` on the API origin, which
* pairs a platform display with no module; which module it shows, if any, is a choice made when it
* is approved.
*/
function DisplaySetupPanel(props: {
readonly setup: SetupState;
readonly url: string;
readonly onAdd: (event: MouseEvent<HTMLButtonElement>) => void;
}): ReactElement {
const { setup } = props;
const none = setup.kind === "ready" && setup.displays.length === 0;
const several = setup.kind === "ready" && setup.displays.length > 1;
return (
<Panel title="Set up a new screen">
<div data-testid="display-setup" style={{ display: "grid", gap: designToken("spacing.md") }}>
{setup.kind === "loading" ? <LoadingState label="Loading setup…" /> : undefined}
{setup.kind === "failed" ? (
<p className="ek-shell-display-card__facts" data-testid="display-setup-failed">
The setup address couldn't be loaded. Reload the page to try again.
</p>
) : undefined}
{none ? (
<p className="ek-shell-display-card__facts" data-testid="display-setup-none">
No installed module shows content on screens yet. Once one is installed from Discover,
its setup address appears here.
</p>
) : undefined}
{setup.kind === "ready" && !none ? (
<>
<p className="ek-shell-setup__lead">
Open {several ? "an address" : "this address"} on the new screen, then add it with the
code it shows.
</p>
{setup.displays.map((display) => (
<SetupLink key={display.moduleId} labelled={several} setup={display} />
))}
</>
) : undefined}
{none || setup.kind === "loading" ? undefined : (
<div>
<Button data-testid="add-display" onClick={props.onAdd} variant="primary">
<ShellIcon name="addDisplay" size={18} />
Add display
</Button>
</div>
)}
{setup.kind === "ready" && !none ? (
<details className="ek-shell-setup__more" data-testid="display-setup-steps">
<summary className="ek-focusable">Step-by-step instructions</summary>
<ol className="ek-shell-setup-steps">
<li>
On the screen, open the address above in its web browser. Opening it on this device
only previews it.
</li>
<li>The screen shows a six-digit pairing code.</li>
<li>
Choose <strong>Add display</strong>, enter the code, and give the screen the name
everyone will know it by.
</li>
<li>It appears under Paired displays once it's approved.</li>
</ol>
</details>
) : undefined}
<p className="ek-shell-setup__lead">
Open this address on the new screen, then add it with the code it shows.
</p>
<SetupLink url={props.url} />
<div>
<Button data-testid="add-display" onClick={props.onAdd} variant="primary">
<ShellIcon name="addDisplay" size={18} />
Add display
</Button>
</div>
<details className="ek-shell-setup__more" data-testid="display-setup-steps">
<summary className="ek-focusable">Step-by-step instructions</summary>
<ol className="ek-shell-setup-steps">
<li>
On the screen, open the address above in its web browser. Opening it on this device
only previews it.
</li>
<li>The screen shows a six-digit pairing code.</li>
<li>
Choose <strong>Add display</strong>, enter the code, and give the screen the name
everyone will know it by. You can also choose what it shows.
</li>
<li>It appears under Paired displays once it's approved.</li>
</ol>
</details>
</div>
</Panel>
);
@@ -364,10 +391,21 @@ function DisplaySetupPanel(props: {
function DisplayCard(props: {
readonly device: HostDeviceSummary;
readonly now: Date;
readonly modules: DisplayModules;
readonly onRevoke: () => void;
}): ReactElement {
const { device, now } = props;
const facts: string[] = [];
if (!device.revoked && props.modules !== undefined) {
const shows = props.modules.filter((module) =>
device.permissions.includes(module.permissionId),
);
facts.push(
shows.length === 0
? "Not showing a module yet"
: `Shows ${shows.map((module) => module.name).join(", ")}`,
);
}
if (device.revoked) {
// The badge says so; nothing about when it was last seen is worth acting on any more.
} else if (device.lastSeenAt !== undefined) {
@@ -404,9 +442,11 @@ function DisplayCard(props: {
export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactElement {
const { config } = props;
const [list, setList] = useState<ListState>({ kind: "loading" });
const [setup, setSetup] = useState<SetupState>({ kind: "loading" });
const [modules, setModules] = useState<DisplayModules>(undefined);
const [adding, setAdding] = useState(false);
const [paired, setPaired] = useState<string | undefined>(undefined);
const [paired, setPaired] = useState<
{ readonly name: string; readonly shows: string | undefined } | undefined
>(undefined);
/** The display an administrator asked to revoke and has not yet confirmed. */
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
const [revoking, setRevoking] = useState(false);
@@ -432,17 +472,15 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
useEffect(() => {
const controller = new AbortController();
listHostDisplaySetup(config, controller.signal).then(
(displays) => {
// What a display can be set to show. If it cannot be read, pairing still works — the display is
// paired showing nothing yet — so a failure here only hides the choice.
listHostDisplayModules(config, controller.signal).then(
(found) => {
if (!controller.signal.aborted) {
setSetup({ displays, kind: "ready" });
}
},
() => {
if (!controller.signal.aborted) {
setSetup({ kind: "failed" });
setModules(found);
}
},
() => undefined,
);
return () => controller.abort();
}, [config]);
@@ -459,9 +497,9 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
setConfirming(undefined);
};
const onPaired = (name: string): void => {
const onPaired = (name: string, shows: string | undefined): void => {
setAdding(false);
setPaired(name);
setPaired({ name, shows });
// The new display appears without a page refresh (DESIGN.md §11.4).
void refresh();
};
@@ -482,7 +520,13 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
{paired === undefined ? undefined : (
<InlineNotice title="Display paired" tone="success">
<span data-testid="device-pairing-success">
{paired} is paired and can be used by your modules.
{paired.shows === undefined
? `${paired.name} is paired. It shows its name until a module is set to show on it.`
: `${paired.name} is paired and shows ${
modules?.find((module) => module.moduleId === paired.shows)?.name ??
props.moduleName(paired.shows) ??
paired.shows
}.`}
</span>
</InlineNotice>
)}
@@ -497,7 +541,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
setPaired(undefined);
setAdding(true);
}}
setup={setup}
url={displaySetupUrl(config)}
/>
) : undefined}
@@ -539,6 +583,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
<DisplayCard
device={device}
key={device.id}
modules={modules}
now={now}
onRevoke={() => setConfirming(device)}
/>
@@ -551,6 +596,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
<AddDisplayDialog
config={config}
moduleName={props.moduleName}
modules={modules}
onDismiss={dismissAdding}
onPaired={onPaired}
open={adding}
+1 -3
View File
@@ -24,9 +24,7 @@
* — the scroll position too.
*/
export interface ScrollLockRelease {
(options?: { readonly restoreScroll?: boolean }): void;
}
export type ScrollLockRelease = (options?: { readonly restoreScroll?: boolean }) => void;
const LOCKED_PROPERTIES = ["overflow", "overscroll-behavior"] as const;
@@ -0,0 +1,141 @@
import { type ReactElement, useEffect, useState } from "react";
import {
type EkklesiaHostConfig,
type HostDisplaySelf,
HostProtocolError,
loadDisplaySelf,
} from "../host-protocol.js";
import { DisplayNotice, PairingScreen } from "./PairingScreen.js";
/**
* The platform's own display setup page, `/display/setup` (ADR 0065): where any new screen starts,
* whatever modules are installed.
*
* It asks the platform who this screen is (`/api/v1/device-pairing/self`):
*
* - **Not paired** (or revoked): it shows a pairing code, asking to be paired as a platform display
* with no module. An administrator adds it in Settings → Displays, naming it and, optionally,
* choosing which installed module it shows.
* - **Paired, set to show a module:** it opens that module's display page, which takes over from
* there exactly as a screen that had opened it directly.
* - **Paired, nothing set yet:** it shows its name and waits. It keeps asking, every so often, so it
* opens the module as soon as one is set, notices if it is revoked, and keeps its credential
* alive while it waits (ADR 0056).
*
* The screen never says why something failed: anything other than "not paired" is retried.
*/
/** `/display/<name>` for the platform's setup page: a reserved module id in Core. */
export const DISPLAY_SETUP_ROUTE_NAME = "setup";
/** How often a paired screen with nothing to show asks again. */
export const DISPLAY_SETUP_RECHECK_MS = 30_000;
/** How long to wait before asking again after a failure that was not "not paired". */
export const DISPLAY_SETUP_RETRY_MS = 5_000;
type SetupState =
| { readonly status: "loading" }
| { readonly status: "pairing" }
| { readonly status: "waiting"; readonly name: string }
| { readonly status: "opening" }
| { readonly status: "unreachable" };
export interface DisplaySetupAppProps {
readonly config: EkklesiaHostConfig;
/** Opens a module display page in place of this one. The entry point supplies
* `location.replace`, so Back on a kiosk never returns here. */
readonly openDisplay: (url: string) => void;
/** For tests: how often to ask again while paired with nothing to show. */
readonly recheckMs?: number;
/** For tests: how long to wait after a failure. */
readonly retryMs?: number;
}
function isNotPaired(error: unknown): boolean {
return (
error instanceof HostProtocolError &&
(error.code === "HOST_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
);
}
export function DisplaySetupApp(props: DisplaySetupAppProps): ReactElement {
const { config, openDisplay } = props;
const recheckMs = props.recheckMs ?? DISPLAY_SETUP_RECHECK_MS;
const retryMs = props.retryMs ?? DISPLAY_SETUP_RETRY_MS;
const [state, setState] = useState<SetupState>({ status: "loading" });
/** Bumped to ask again: after pairing, on a recheck, or after a failure. */
const [attempt, setAttempt] = useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: `attempt` is the trigger to ask again.
useEffect(() => {
const abort = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const again = (delay: number): void => {
timer = setTimeout(() => setAttempt((value) => value + 1), delay);
};
loadDisplaySelf(config, abort.signal).then(
(self: HostDisplaySelf) => {
if (abort.signal.aborted) {
return;
}
const first = self.displays[0];
if (first !== undefined) {
setState({ status: "opening" });
openDisplay(first.url);
return;
}
setState({ name: self.name, status: "waiting" });
again(recheckMs);
},
(error: unknown) => {
if (abort.signal.aborted) {
return;
}
if (isNotPaired(error)) {
setState({ status: "pairing" });
return;
}
setState((current) => (current.status === "waiting" ? current : { status: "unreachable" }));
again(retryMs);
},
);
return () => {
abort.abort();
if (timer !== undefined) {
clearTimeout(timer);
}
};
}, [attempt, config, openDisplay, recheckMs, retryMs]);
if (state.status === "pairing") {
return (
<PairingScreen
config={config}
hint="Enter this code in Settings → Displays → Add display. Waiting for approval…"
moduleId={undefined}
onRetry={() => setAttempt((value) => value + 1)}
/>
);
}
if (state.status === "waiting") {
return (
<DisplayNotice
headline={state.name}
hint="Nothing is set to show here yet."
testId="display-setup-waiting"
title="This screen is paired"
/>
);
}
if (state.status === "unreachable") {
return (
<DisplayNotice
hint="Trying again…"
testId="display-setup-unreachable"
title="Connecting to the server"
/>
);
}
return <DisplayNotice testId="display-setup-loading" title="Preparing this display…" />;
}
+33 -2
View File
@@ -48,12 +48,42 @@ type PairingState =
export interface PairingScreenProps {
readonly config: EkklesiaHostConfig;
readonly moduleId: string;
/** The module display this screen is on, or `undefined` on the platform's own `/display/setup`,
* which pairs a platform display with no module yet (ADR 0065). */
readonly moduleId: string | undefined;
/** What the screen tells whoever is setting it up. */
readonly hint?: string;
/** Called once the host has issued this device its session, so the display mounts itself without
* anyone touching the television. */
readonly onRetry: () => void;
}
/**
* A calm full-screen message in the pairing screen's own style: a title, an optional large line,
* and a hint. For the platform setup page's "paired, nothing to show yet" (ADR 0065).
*/
export function DisplayNotice(props: {
readonly title: string;
readonly headline?: string;
readonly hint?: string;
readonly testId: string;
}): ReactElement {
return (
<div data-testid={props.testId} style={pairingStyles.root}>
<p style={pairingStyles.title}>{props.title}</p>
{props.headline === undefined ? undefined : (
<p
data-testid={`${props.testId}-headline`}
style={{ ...pairingStyles.code, fontSize: "clamp(2rem, 8vw, 5rem)", letterSpacing: 0 }}
>
{props.headline}
</p>
)}
{props.hint === undefined ? undefined : <p style={pairingStyles.hint}>{props.hint}</p>}
</div>
);
}
export function PairingScreen(props: PairingScreenProps): ReactElement {
const { config, moduleId, onRetry } = props;
const [state, setState] = useState<PairingState>({ status: "requesting" });
@@ -137,7 +167,8 @@ export function PairingScreen(props: PairingScreenProps): ReactElement {
{state.code}
</p>
<p style={pairingStyles.hint}>
Enter this code in the Ekklesia dashboard to connect this screen. Waiting for approval…
{props.hint ??
"Enter this code in the Ekklesia dashboard to connect this screen. Waiting for approval…"}
</p>
</>
) : undefined}
+6 -1
View File
@@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { parseDisplayRoute } from "../host-protocol.js";
import { DisplayApp } from "./DisplayApp.js";
import { DISPLAY_SETUP_ROUTE_NAME, DisplaySetupApp } from "./DisplaySetupApp.js";
import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js";
/**
@@ -70,7 +71,11 @@ createEkklesiaThemeController({
createRoot(container).render(
<>
<EkklesiaUiStyles />
{moduleId ? (
{moduleId === DISPLAY_SETUP_ROUTE_NAME ? (
// The platform's own setup page (ADR 0065). `setup` is a reserved module id, so no module's
// display can live at this address.
<DisplaySetupApp config={config} openDisplay={(url) => window.location.replace(url)} />
) : moduleId ? (
<DisplayApp config={config} connectivity={connectivity} moduleId={moduleId} />
) : (
<div
+98 -23
View File
@@ -456,13 +456,15 @@ async function requestPlatformMutation(
*/
export async function requestDevicePairing(
config: EkklesiaHostConfig,
moduleId: string,
/** The module display this screen is on, or `undefined` on the platform's own `/display/setup`,
* which pairs a platform display with no module yet (ADR 0065). */
moduleId: string | undefined,
signal?: AbortSignal,
): Promise<{ readonly code: string; readonly pollIntervalMs: number }> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/requests",
{ moduleId },
moduleId === undefined ? {} : { moduleId },
signal,
);
if (!hasExactKeys(payload, ["code", "expiresAt", "pollIntervalMs"])) {
@@ -524,11 +526,28 @@ function optionalTimestamp(value: unknown): Date | undefined {
return Number.isNaN(date.getTime()) ? undefined : date;
}
/** A null-or-string field from the server: the string, `undefined` for `null`, or an error. */
function nullableString(value: unknown): string | undefined {
if (value === null) {
return undefined;
}
const text = nonEmptyString(value);
if (text === undefined) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return text;
}
/**
* Looks up a screen waiting to be paired by the code it shows. `moduleId` is the module display it
* asked from, or `undefined` for a platform display from `/display/setup` (ADR 0065), for which the
* administrator may choose a module.
*/
export async function lookupDevicePairing(
config: EkklesiaHostConfig,
code: string,
signal?: AbortSignal,
): Promise<{ readonly moduleId: string; readonly permissionId: string }> {
): Promise<{ readonly moduleId: string | undefined; readonly permissionId: string | undefined }> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/lookups",
@@ -538,24 +557,27 @@ export async function lookupDevicePairing(
if (!hasExactKeys(payload, ["expiresAt", "moduleId", "permissionId", "requestedAt"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const moduleId = nonEmptyString(payload.moduleId);
const permissionId = nonEmptyString(payload.permissionId);
if (moduleId === undefined || permissionId === undefined) {
const moduleId = nullableString(payload.moduleId);
const permissionId = nullableString(payload.permissionId);
if ((moduleId === undefined) !== (permissionId === undefined)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ moduleId, permissionId });
}
/** Approves a waiting screen and names it. `moduleId`, for a platform display, is the installed
* display module the administrator chose for it to show (ADR 0065); omitted, it shows nothing yet. */
export async function approveDevicePairing(
config: EkklesiaHostConfig,
code: string,
deviceName: string,
moduleId?: string,
signal?: AbortSignal,
): Promise<void> {
await requestPlatformMutation(
config,
"/api/v1/device-pairing/approvals",
{ code, deviceName },
moduleId === undefined ? { code, deviceName } : { code, deviceName, moduleId },
signal,
);
}
@@ -622,46 +644,99 @@ export async function revokeHostDevice(
}
}
/** The root-relative address of the platform's own display setup page (ADR 0065). */
export const DISPLAY_SETUP_PATH = "/display/setup";
/**
* Where a new screen starts pairing, for one module that shows content on screens (ADR 0065): its
* name, and the absolute address of the platform display page a screen opens to show its pairing
* code. The address is the API origin's, because the display host lives there in both topologies
* The absolute address a new screen opens to start pairing, whatever is installed: the platform's
* `/display/setup` on the API origin, which is where the display host lives in both topologies
* (ADR 0045).
*/
export interface HostDisplaySetup {
export function displaySetupUrl(config: EkklesiaHostConfig): string {
return resolveApiUrl(config, DISPLAY_SETUP_PATH);
}
/** One installed module that shows content on screens: what a display can be set to show. */
export interface HostDisplayModule {
readonly moduleId: string;
readonly name: string;
readonly url: string;
/** The display permission a device needs to show it; a paired display holding it shows it. */
readonly permissionId: string;
}
/**
* The display pages a new screen can open (`GET /api/v1/devices/setup`), for Settings → Displays.
* Requires `core.devices.manage`, like the device list. An empty list means no installed module
* uses displays yet.
* The modules a display can be set to show (`GET /api/v1/devices/display-modules`, ADR 0065), for
* the Add display choice and for naming what each paired display shows. Requires
* `core.devices.manage`. An empty list means no installed module uses displays yet.
*/
export async function listHostDisplaySetup(
export async function listHostDisplayModules(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<readonly HostDisplaySetup[]> {
const payload = await requestPlatformJson(config, "/api/v1/devices/setup", signal, {
): Promise<readonly HostDisplayModule[]> {
const payload = await requestPlatformJson(config, "/api/v1/devices/display-modules", signal, {
forbidden: "HOST_ACCESS_DENIED",
});
if (!hasExactKeys(payload, ["displays"]) || !Array.isArray(payload.displays)) {
if (!hasExactKeys(payload, ["modules"]) || !Array.isArray(payload.modules)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze(
payload.displays.map((entry) => {
payload.modules.map((entry) => {
const moduleId = nonEmptyString((entry as { moduleId?: unknown }).moduleId);
const name = nonEmptyString((entry as { name?: unknown }).name);
const path = rootRelativePath(entry, "url");
if (!hasExactKeys(entry, ["moduleId", "name", "url"]) || !moduleId || !name || !path) {
const permissionId = nonEmptyString((entry as { permissionId?: unknown }).permissionId);
if (
!hasExactKeys(entry, ["moduleId", "name", "permissionId"]) ||
!moduleId ||
!name ||
!permissionId
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ moduleId, name, url: resolveApiUrl(config, path) });
return Object.freeze({ moduleId, name, permissionId });
}),
);
}
/** A paired display as it sees itself (ADR 0065): its name, and the module displays it may show. */
export interface HostDisplaySelf {
readonly name: string;
readonly displays: readonly { readonly moduleId: string; readonly url: string }[];
}
/**
* Asks the platform who this paired display is (`GET /api/v1/device-pairing/self`). Answered only
* for a device; anyone else — an unpaired or revoked screen, or a person — is `HOST_ACCESS_DENIED`.
* Each display's `url` is the root-relative module display page, on this same origin.
*/
export async function loadDisplaySelf(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<HostDisplaySelf> {
const payload = await requestPlatformJson(config, "/api/v1/device-pairing/self", signal, {
forbidden: "HOST_ACCESS_DENIED",
});
if (!hasExactKeys(payload, ["displays", "name"]) || !Array.isArray(payload.displays)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const name = nonEmptyString(payload.name);
if (name === undefined) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
displays: Object.freeze(
payload.displays.map((entry) => {
const moduleId = nonEmptyString((entry as { moduleId?: unknown }).moduleId);
const url = rootRelativePath(entry, "url");
if (!hasExactKeys(entry, ["moduleId", "url"]) || !moduleId || !url) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ moduleId, url });
}),
),
name,
});
}
/** One installed module as module lifecycle reports it (ADR 0028): what it is and whether it runs. */
export interface HostModuleLifecycleSummary {
readonly id: string;
+2
View File
@@ -18,6 +18,7 @@ export {
type DashboardNavigationEntry,
type DashboardNavigationGroup,
type DisplayHostBootstrap,
DISPLAY_SETUP_PATH,
type EkklesiaHostConfig,
type HostAccount,
type HostAccountPreferences,
@@ -30,6 +31,7 @@ export {
type HostProtocolErrorCode,
loadDashboardNavigation,
loadDashboardSettings,
loadDisplaySelf,
loadHostAccount,
loadHostPrincipal,
loadModuleDisplay,
@@ -0,0 +1,179 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DisplaySetupApp } from "../src/display/DisplaySetupApp.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* The platform's own display setup page, `/display/setup` (ADR 0065): a screen pairs here with no
* module installed at all, then opens whatever module it is set to show — or waits, named, if
* nothing is set yet.
*/
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.useRealTimers();
});
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
interface Self {
readonly name: string;
readonly displays: readonly { moduleId: string; url: string }[];
}
/**
* A fake API. `self` is what `/api/v1/device-pairing/self` answers right now: `undefined` for a
* screen that is not paired (403), or the device as it sees itself.
*/
function api(initial: { self?: Self | "error" } = {}) {
const state: { self: Self | "error" | undefined; claim: "pending" | "paired" } = {
claim: "pending",
self: initial.self,
};
const requests: { path: string; body: unknown }[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = new URL(String(input)).pathname;
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
requests.push({ body, path });
if (path === "/api/v1/device-pairing/self") {
if (state.self === "error") {
return json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503);
}
return state.self === undefined
? json({ code: "AUTHORIZATION_DENIED" }, 403)
: json({ displays: state.self.displays, name: state.self.name });
}
if (path === "/api/v1/device-pairing/requests") {
return json(
{ code: "482913", expiresAt: "2026-10-16T12:10:00.000Z", pollIntervalMs: 1000 },
201,
);
}
if (path === "/api/v1/device-pairing/claims") {
return json({ status: state.claim });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { fetch, requests, state };
}
function query(testId: string): HTMLElement | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
async function flush(ms = 0): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
async function renderSetup(server: ReturnType<typeof api>) {
const openDisplay = vi.fn();
await act(async () => {
root.render(
<DisplaySetupApp
config={{ apiBaseUrl, fetch: server.fetch }}
openDisplay={openDisplay}
recheckMs={30_000}
retryMs={5_000}
/>,
);
});
await flush();
return openDisplay;
}
describe("the platform display setup page", () => {
it("pairs a screen with no module at all, then shows it is paired and waiting", async () => {
const server = api();
const openDisplay = await renderSetup(server);
// Not paired: it asks to be paired as a platform display — no module — and shows the code.
expect(query("display-pairing-code")?.textContent).toBe("482913");
expect(server.requests.find((r) => r.path === "/api/v1/device-pairing/requests")?.body).toEqual(
{},
);
expect(query("display-pairing")?.textContent).toContain("Settings → Displays → Add display");
// An administrator approves it, with nothing for it to show yet.
server.state.claim = "paired";
server.state.self = { displays: [], name: "Foyer TV" };
await flush(1000);
await flush();
expect(query("display-setup-waiting-headline")?.textContent).toBe("Foyer TV");
expect(query("display-setup-waiting")?.textContent).toContain("This screen is paired");
expect(openDisplay).not.toHaveBeenCalled();
});
it("opens the module display a paired screen is set to show", async () => {
const server = api({
self: { displays: [{ moduleId: "signage", url: "/display/signage" }], name: "Hall" },
});
const openDisplay = await renderSetup(server);
expect(openDisplay).toHaveBeenCalledWith("/display/signage");
expect(server.requests.some((r) => r.path === "/api/v1/device-pairing/requests")).toBe(false);
});
it("keeps asking while it waits, and opens the module as soon as one is set", async () => {
const server = api({ self: { displays: [], name: "Foyer TV" } });
const openDisplay = await renderSetup(server);
expect(query("display-setup-waiting")).not.toBeNull();
server.state.self = {
displays: [{ moduleId: "signage", url: "/display/signage" }],
name: "Foyer TV",
};
await flush(30_000);
expect(openDisplay).toHaveBeenCalledWith("/display/signage");
});
it("returns to a pairing code when a waiting screen is revoked", async () => {
const server = api({ self: { displays: [], name: "Foyer TV" } });
await renderSetup(server);
server.state.self = undefined;
await flush(30_000);
await flush();
expect(query("display-pairing-code")?.textContent).toBe("482913");
});
it("never starts pairing on a server failure, and tries again", async () => {
const server = api({ self: "error" });
await renderSetup(server);
expect(query("display-setup-unreachable")).not.toBeNull();
expect(server.requests.some((r) => r.path === "/api/v1/device-pairing/requests")).toBe(false);
server.state.self = { displays: [], name: "Foyer TV" };
await flush(5_000);
expect(query("display-setup-waiting")).not.toBeNull();
});
});
+119 -58
View File
@@ -41,10 +41,16 @@ function json(body: unknown, status = 200): Response {
interface ServerOptions {
readonly devices?: unknown[];
readonly denied?: boolean;
/** What `GET /api/v1/devices/setup` answers: the display pages, or a failure. */
readonly setup?: unknown[] | "fails";
/** What `GET /api/v1/devices/display-modules` answers: the installed display modules (by
* default Signage), none, or a failure. */
readonly modules?: unknown[] | "fails";
}
/** A screen that asked from Signage's own display page. */
const MODULE_CODE = "123456";
/** A screen that asked from the platform's `/display/setup`, with no module (ADR 0065). */
const PLATFORM_CODE = "654321";
function server(options: ServerOptions = {}) {
let devices: unknown[] = options.devices ?? [
{
@@ -61,13 +67,13 @@ function server(options: ServerOptions = {}) {
const path = new URL(String(input)).pathname;
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
requests.push({ body, path });
if (path === "/api/v1/devices/setup") {
if (options.setup === "fails") {
if (path === "/api/v1/devices/display-modules") {
if (options.modules === "fails") {
return json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503);
}
return json({
displays: options.setup ?? [
{ moduleId: "signage", name: "Signage", url: "/display/signage" },
modules: options.modules ?? [
{ moduleId: "signage", name: "Signage", permissionId: "signage.display" },
],
});
}
@@ -79,27 +85,35 @@ function server(options: ServerOptions = {}) {
return new Response(null, { status: 204 });
}
if (path === "/api/v1/device-pairing/lookups") {
return (body as { code: string }).code === "123456"
? json({
expiresAt: "2026-10-15T12:10:00.000Z",
moduleId: "signage",
permissionId: "signage.display",
requestedAt: "2026-10-15T11:59:00.000Z",
})
: json({ code: "DEVICE_PAIRING_NOT_FOUND" }, 404);
const code = (body as { code: string }).code;
if (code !== MODULE_CODE && code !== PLATFORM_CODE) {
return json({ code: "DEVICE_PAIRING_NOT_FOUND" }, 404);
}
const module = code === MODULE_CODE;
return json({
expiresAt: "2026-10-15T12:10:00.000Z",
moduleId: module ? "signage" : null,
permissionId: module ? "signage.display" : null,
requestedAt: "2026-10-15T11:59:00.000Z",
});
}
if (path === "/api/v1/device-pairing/approvals") {
const approval = body as { code: string; deviceName: string; moduleId?: string };
const shows = approval.code === MODULE_CODE ? "signage" : approval.moduleId;
devices = [
...devices,
{
createdAt: "2026-10-15T12:00:00.000Z",
id: "device-2",
name: (body as { deviceName: string }).deviceName,
permissions: [],
name: approval.deviceName,
permissions: shows === "signage" ? ["signage.display"] : [],
revoked: false,
},
];
return json({ moduleId: "signage", permissionId: "signage.display" });
return json({
moduleId: shows ?? null,
permissionId: shows === undefined ? null : `${shows}.display`,
});
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
@@ -140,6 +154,15 @@ async function type(testId: string, value: string): Promise<void> {
});
}
async function choose(testId: string, value: string): Promise<void> {
const select = required(testId) as HTMLSelectElement;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
setter?.call(select, value);
select.dispatchEvent(new Event("change", { bubbles: true }));
});
}
async function submit(testId: string): Promise<void> {
const form = (required(testId) as HTMLButtonElement).form;
await act(async () => {
@@ -173,12 +196,13 @@ describe("Settings → Displays", () => {
expect(card.textContent).toContain("Foyer TV");
expect(card.textContent).toContain("Paired");
expect(card.textContent).toContain("Last seen within the last hour");
expect(card.textContent).toContain("Not showing a module yet");
expect(container.querySelector("h1")?.textContent).toBe("Displays");
// How to set up a new screen comes first, in one place: the platform display page on the API
// origin, then Add display right under it, with the step-by-step version folded away.
// How to set up a new screen comes first, in one place: the platform's own setup page on the
// API origin, then Add display right under it, with the step-by-step version folded away.
const setup = required("display-setup");
expect(setup.textContent).toContain("Open this address on the new screen");
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/signage`);
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
expect(setup.contains(required("add-display"))).toBe(true);
const steps = required("display-setup-steps");
expect(steps.tagName).toBe("DETAILS");
@@ -216,9 +240,11 @@ describe("Settings → Displays", () => {
await submit("device-pairing-lookup");
expect(required("device-pairing-error").textContent).toContain("not waiting for approval");
await type("device-pairing-code-input", "123456");
await type("device-pairing-code-input", MODULE_CODE);
await submit("device-pairing-lookup");
// A screen that asked from a module's own display already shows that module: no choice.
expect(required("device-pairing-found").textContent).toContain("Signage");
expect(query("device-pairing-shows")).toBeNull();
expect(document.activeElement).toBe(required("device-pairing-name"));
await type("device-pairing-name", "Sanctuary left");
@@ -227,10 +253,13 @@ describe("Settings → Displays", () => {
expect(query("add-display-dialog")).toBeNull();
expect(
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
).toEqual({ code: "123456", deviceName: "Sanctuary left" });
).toEqual({ code: MODULE_CODE, deviceName: "Sanctuary left" });
// The new display appears without a page refresh, and the page says so.
expect(required("device-device-2").textContent).toContain("Sanctuary left");
expect(required("device-pairing-success").textContent).toContain("Sanctuary left is paired");
expect(required("device-pairing-success").textContent).toContain(
"Sanctuary left is paired and shows Signage.",
);
expect(required("device-device-2").textContent).toContain("Shows Signage");
// Focus is back on the control that opened the flow.
expect(document.activeElement).toBe(required("add-display"));
});
@@ -243,67 +272,99 @@ describe("Settings → Displays", () => {
expect(query("add-display-dialog")).toBeNull();
expect(platform.requests.map((r) => r.path).sort()).toEqual([
"/api/v1/devices",
"/api/v1/devices/setup",
"/api/v1/devices/display-modules",
]);
expect(document.activeElement).toBe(required("add-display"));
});
});
describe("setting up a new screen (ADR 0065)", () => {
it("copies the setup address, and says so when the browser cannot", async () => {
it("copies the platform setup address, and says so when the browser cannot", async () => {
const writeText = vi.fn(async () => undefined);
Object.defineProperty(window.navigator, "clipboard", {
configurable: true,
value: { writeText },
});
await renderPage();
await click("display-setup-copy-signage");
expect(writeText).toHaveBeenCalledWith(`${apiBaseUrl}/display/signage`);
await click("display-setup-copy");
expect(writeText).toHaveBeenCalledWith(`${apiBaseUrl}/display/setup`);
expect(required("display-setup").textContent).toContain("Link copied.");
Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: undefined });
await click("display-setup-copy-signage");
await click("display-setup-copy");
expect(required("display-setup").textContent).toContain("couldn't copy it");
});
it("names each module's page when more than one module uses displays", async () => {
await renderPage({
setup: [
{ moduleId: "signage", name: "Signage", url: "/display/signage" },
{ moduleId: "parent-call", name: "Parent Call", url: "/display/parent-call" },
],
});
expect(required("display-setup-signage").textContent).toContain("For Signage");
expect(required("display-setup-parent-call").textContent).toContain(
`For Parent Call${apiBaseUrl}/display/parent-call`,
);
});
it("offers the same setup and Add display with zero display modules installed", async () => {
const platform = await renderPage({ modules: [] });
it("makes sense with no module that uses displays installed, Signage included", async () => {
await renderPage({ setup: [] });
expect(required("display-setup-none").textContent).toContain(
// The generic platform address, and nothing that names a module.
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
expect(required("display-setup").textContent).not.toContain("Signage");
expect(query("add-display")).not.toBeNull();
// A screen from /display/setup pairs as a platform display with no module.
await click("add-display");
await type("device-pairing-code-input", PLATFORM_CODE);
await submit("device-pairing-lookup");
expect(required("device-pairing-found").textContent).toContain(
"A new screen is waiting to be paired",
);
expect(query("device-pairing-shows")).toBeNull();
expect(required("device-pairing-no-modules").textContent).toContain(
"No installed module shows content on screens yet",
);
expect(query("display-setup-url")).toBeNull();
// No Add display that could only fail, and nothing names Signage.
expect(query("add-display")).toBeNull();
expect(required("display-setup").textContent).not.toContain("Signage");
await type("device-pairing-name", "Foyer TV 2");
await submit("device-pairing-approve");
expect(
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
).toEqual({ code: PLATFORM_CODE, deviceName: "Foyer TV 2" });
expect(required("device-pairing-success").textContent).toContain(
"Foyer TV 2 is paired. It shows its name until a module is set to show on it.",
);
expect(required("device-device-2").textContent).toContain("Not showing a module yet");
});
it("keeps the rest of the page working when the setup address cannot be read", async () => {
await renderPage({ setup: "fails" });
expect(required("display-setup-failed").textContent).toContain("couldn't be loaded");
expect(required("device-device-1").textContent).toContain("Foyer TV");
it("lets the administrator choose which installed module a new platform display shows", async () => {
const platform = await renderPage();
await click("add-display");
expect(query("add-display-dialog")).not.toBeNull();
await type("device-pairing-code-input", PLATFORM_CODE);
await submit("device-pairing-lookup");
const shows = required("device-pairing-shows") as HTMLSelectElement;
expect([...shows.options].map((option) => option.textContent)).toEqual([
"Nothing yet",
"Signage",
]);
expect(shows.value).toBe("");
await choose("device-pairing-shows", "signage");
await type("device-pairing-name", "Lobby screen");
await submit("device-pairing-approve");
expect(
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
).toEqual({ code: PLATFORM_CODE, deviceName: "Lobby screen", moduleId: "signage" });
expect(required("device-pairing-success").textContent).toContain(
"Lobby screen is paired and shows Signage.",
);
expect(required("device-device-2").textContent).toContain("Shows Signage");
});
it("refuses a setup answer that is not a plain root-relative page", async () => {
await renderPage({
setup: [{ moduleId: "signage", name: "Signage", url: "https://elsewhere.example/display" }],
});
expect(query("display-setup-failed")).not.toBeNull();
expect(query("display-setup-url")).toBeNull();
it("still pairs, showing nothing yet, when the display modules cannot be read", async () => {
const platform = await renderPage({ modules: "fails" });
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
await click("add-display");
await type("device-pairing-code-input", PLATFORM_CODE);
await submit("device-pairing-lookup");
expect(query("device-pairing-shows")).toBeNull();
await type("device-pairing-name", "Hall");
await submit("device-pairing-approve");
expect(
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
).toEqual({ code: PLATFORM_CODE, deviceName: "Hall" });
// Without the module list the card does not guess what the display shows.
expect(required("device-device-1").textContent).not.toContain("showing");
});
});
@@ -156,10 +156,10 @@ async function render(
// The existing data the Settings overview reads (ADR 0064); a 403 for anyone it would refuse.
const visible = options.settings ?? ADMINISTRATOR;
const sections = visible === "fails" ? ADMINISTRATOR.sections : visible.sections;
if (url.endsWith("/api/v1/devices/setup")) {
if (url.endsWith("/api/v1/devices/display-modules")) {
return sections.includes("displays")
? Response.json({
displays: [{ moduleId: "signage", name: "Signage", url: "/display/signage" }],
modules: [{ moduleId: "signage", name: "Signage", permissionId: "signage.display" }],
})
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}