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
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:
co-authored by
Claude Opus 5.5
parent
906c015ca8
commit
300d9eab51
@@ -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}
|
||||
|
||||
@@ -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…" />;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user