diff --git a/apps/api/src/device-routes.ts b/apps/api/src/device-routes.ts index da1cdd2..784d06d 100644 --- a/apps/api/src/device-routes.ts +++ b/apps/api/src/device-routes.ts @@ -432,6 +432,56 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { } }); + /** + * Changes what a paired display shows (ADR 0065): `{ "moduleId": "" }` for a running display + * module, or `{ "moduleId": null }` for nothing. The permission granted is that module's declared + * display permission, never one the request names. It is the same display — the same identity, + * name, and credential — before and after; the screen notices on its next check and opens the new + * module's page, or the platform's setup page, without being paired again. + */ + routes.put( + `${DEVICES_PREFIX}/:deviceId/display`, + requireOrigin, + mutationBodyLimit, + requireManage, + async (c: Context) => { + const parsed = safeParseEkklesiaDeviceId(c.req.param("deviceId")); + const body = await readJsonObject(c); + if (!parsed.success || body === undefined || !("moduleId" in body)) { + return sanitizedFailure(c, 400, "INVALID_REQUEST"); + } + let permissionId: PermissionId | undefined; + if (body.moduleId !== null) { + const moduleId = readString(body, "moduleId", 128); + const display = moduleId === undefined ? undefined : findDisplay(moduleId); + if (display === undefined) { + return sanitizedFailure(c, 400, "INVALID_REQUEST"); + } + permissionId = display.permissionId; + } + const principal = getAuthorizedPrincipal(c); + if (principal.kind !== "authenticated") { + return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED"); + } + try { + const changed = await options.devices.setDisplayTarget({ + changedBy: principal.userId, + deviceId: parsed.data, + now: now(), + permissionId, + }); + if (!changed) { + // Unknown and revoked alike: neither is a display whose content can change. + return sanitizedFailure(c, 404, "NOT_FOUND"); + } + noStore(c); + return c.json({ moduleId: body.moduleId === null ? null : body.moduleId }, 200); + } catch { + return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE"); + } + }, + ); + routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => { try { const devices = await options.devices.listDevices(); diff --git a/apps/api/tests/device-pairing.integration.ts b/apps/api/tests/device-pairing.integration.ts index bb7b92e..0beef29 100644 --- a/apps/api/tests/device-pairing.integration.ts +++ b/apps/api/tests/device-pairing.integration.ts @@ -105,10 +105,14 @@ async function createEmptyModulesDirectory(): Promise { return directory; } -async function createDisplayModuleDirectory(): Promise { - const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-")); - trackedDirectories.push(directory); - const moduleDirectory = path.join(directory, "announcements"); +/** A second display module's permission, for moving a display from one module to another. */ +const bulletinPermission = "bulletin.display.view"; + +async function writeDisplayModule( + directory: string, + module: { readonly id: string; readonly name: string; readonly permission: string }, +): Promise { + const moduleDirectory = path.join(directory, module.id); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await Promise.all([ @@ -116,7 +120,7 @@ async function createDisplayModuleDirectory(): Promise { path.join(moduleDirectory, "package.json"), JSON.stringify({ ekklesia: { layer: "module" }, - name: "@test/module-announcements", + name: `@test/module-${module.id}`, type: "module", version: "1.0.0", }), @@ -126,17 +130,17 @@ async function createDisplayModuleDirectory(): Promise { JSON.stringify({ compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" }, entrypoints: { - display: { artifact: "./dist/ui/display.js", permission: displayPermission }, + display: { artifact: "./dist/ui/display.js", permission: module.permission }, server: "./dist/server/index.js", }, - id: "announcements", + id: module.id, manifestVersion: 8, - name: "Announcements fixture", + name: module.name, permissions: [ { baseline: "restricted", - description: "View the announcement slideshow.", - id: displayPermission, + description: "View this module's display.", + id: module.permission, }, ], version: "1.0.0", @@ -145,10 +149,10 @@ async function createDisplayModuleDirectory(): Promise { writeFile( path.join(moduleDirectory, "dist/server/index.js"), `export default { - id: "announcements", + id: ${JSON.stringify(module.id)}, version: "1.0.0", register({ realtime }) { - realtime.channel({ channel: "state", permission: ${JSON.stringify(displayPermission)} }); + realtime.channel({ channel: "state", permission: ${JSON.stringify(module.permission)} }); }, start({ realtime }) { realtime.publish("state", { slides: [] }); @@ -158,6 +162,25 @@ async function createDisplayModuleDirectory(): Promise { ), writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"), ]); +} + +async function createDisplayModuleDirectory( + options: { readonly second?: boolean } = {}, +): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-")); + trackedDirectories.push(directory); + await writeDisplayModule(directory, { + id: "announcements", + name: "Announcements fixture", + permission: displayPermission, + }); + if (options.second === true) { + await writeDisplayModule(directory, { + id: "bulletin", + name: "Bulletin fixture", + permission: bulletinPermission, + }); + } return directory; } @@ -275,7 +298,7 @@ interface Platform { async function platform( label: string, - options: { readonly modules?: boolean } = {}, + options: { readonly modules?: boolean | "two" } = {}, ): Promise { const url = await createMigratedDatabase(label); // `modules: false` is an installation with no module at all — nothing that shows content on a @@ -283,7 +306,7 @@ async function platform( const modulesDirectory = options.modules === false ? await createEmptyModulesDirectory() - : await createDisplayModuleDirectory(); + : await createDisplayModuleDirectory({ second: options.modules === "two" }); const api = await startPlatform(url, modulesDirectory); const origin = apiOrigin(api); const ownerCookie = await createOwner(url, origin); @@ -1147,4 +1170,106 @@ describe.sequential("platform display pairing (ADR 0065)", () => { await running.close(); } }); + + it("changes what a paired display shows, on the same display, without pairing it again", async () => { + const running = await platform("platform-change", { modules: "two" }); + const device = new DeviceBrowser(running.origin); + const put = (deviceId: string, moduleId: string | null) => + adminRequest( + running.origin, + running.ownerCookie, + `/api/v1/devices/${deviceId}/display`, + { moduleId }, + { method: "PUT" }, + ); + const listed = async () => + ( + (await ( + await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices") + ).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> } + ).devices; + const self = async () => + (await (await device.request("/api/v1/device-pairing/self")).json()) as { + displays: Array<{ moduleId: string }>; + }; + try { + // Paired from /display/setup with nothing to show. + const { code } = (await ( + await device.post("/api/v1/device-pairing/requests", {}) + ).json()) as { code: string }; + await adminRequest(running.origin, running.ownerCookie, "/api/v1/device-pairing/approvals", { + code, + deviceName: "Foyer TV", + }); + await device.post("/api/v1/device-pairing/claims", {}); + const [paired] = await listed(); + const id = paired?.id ?? ""; + expect(paired?.permissions).toEqual([]); + + // Nothing yet → a module: it may now show it, and the screen learns so. + expect((await put(id, "announcements")).status).toBe(200); + expect((await self()).displays.map((display) => display.moduleId)).toEqual(["announcements"]); + expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200); + + // Module A → module B: A's permission is withdrawn, B's granted, in one change. + expect((await put(id, "bulletin")).status).toBe(200); + expect((await self()).displays.map((display) => display.moduleId)).toEqual(["bulletin"]); + expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403); + expect((await device.request("/api/v1/module-displays/bulletin")).status).toBe(200); + + // Module → nothing: it shows nothing, and is still the same paired display. + expect((await put(id, null)).status).toBe(200); + expect((await self()).displays).toEqual([]); + expect((await device.request("/api/v1/module-displays/bulletin")).status).toBe(403); + + // Always the one identity, the one name, and the one credential. + const after = await listed(); + expect(after).toHaveLength(1); + expect(after[0]).toMatchObject({ id, name: "Foyer TV", permissions: [] }); + expect(device.has("ekklesia_device_session")).toBe(true); + // Every change is kept as history: granted rows are revoked, never deleted. + const rows = await query<{ permission_id: string; revoked: boolean }>( + running.url, + `select permission_id, revoked_at is not null as revoked from ekklesia_core_device_permission order by granted_at`, + ); + expect(rows).toEqual([ + { permission_id: displayPermission, revoked: true }, + { permission_id: bulletinPermission, revoked: true }, + ]); + + // A module that is not a running display, or a missing body, is refused; so is a revoked + // display, and a person without a session. + expect((await put(id, "not-a-display")).status).toBe(400); + expect( + ( + await adminRequest( + running.origin, + running.ownerCookie, + `/api/v1/devices/${id}/display`, + {}, + { method: "PUT" }, + ) + ).status, + ).toBe(400); + expect( + ( + await fetch(`${running.origin}/api/v1/devices/${id}/display`, { + body: JSON.stringify({ moduleId: null }), + headers: { "content-type": "application/json", origin: dashboardOrigin }, + method: "PUT", + }) + ).status, + ).toBe(403); + await adminRequest(running.origin, running.ownerCookie, `/api/v1/devices/${id}/revoke`, {}); + expect((await put(id, "announcements")).status).toBe(404); + // Revoking keeps the identity as history: the row is still there, marked revoked. + const revoked = await query<{ revoked: boolean }>( + running.url, + `select revoked_at is not null as revoked from ekklesia_core_device`, + ); + expect(revoked).toEqual([{ revoked: true }]); + } finally { + await running.close(); + } + }); }); diff --git a/core/src/devices/store.ts b/core/src/devices/store.ts index ca588f6..6138941 100644 --- a/core/src/devices/store.ts +++ b/core/src/devices/store.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes, randomInt } from "node:crypto"; import type { EkklesiaDeviceId, EkklesiaUserId, PermissionId } from "@ekklesia/contracts"; -import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; +import { and, eq, gt, isNull, lt, ne, or, sql } from "drizzle-orm"; import type { DeviceDrizzleDatabase } from "./database.js"; import { ekklesiaCoreDevice, @@ -184,6 +184,22 @@ export interface DeviceStore { listDevices(): Promise; /** A live, unrevoked device's own name and active permissions, or `undefined`. */ describeDevice(deviceId: EkklesiaDeviceId): Promise; + /** + * Sets what a live display shows (ADR 0065): the one module display permission it holds, or none. + * + * In this single-target model a display's active display permission *is* its current target, so + * there is one source of truth for "may it show this" and "is it showing this". The change is one + * transaction on the same device: every other active permission row is revoked — kept, with its + * `revokedAt`, as history — and the new permission is granted if the device does not already hold + * it. The device row, its name, and its credential never change. Returns `false` for a device that + * does not exist or is revoked. + */ + setDisplayTarget(input: { + readonly deviceId: EkklesiaDeviceId; + readonly permissionId: PermissionId | undefined; + readonly changedBy: EkklesiaUserId; + readonly now: Date; + }): Promise; revokeDevice(input: { readonly deviceId: EkklesiaDeviceId; readonly revokedBy: EkklesiaUserId; @@ -576,6 +592,61 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore { } }, + async setDisplayTarget(input): Promise { + try { + return await db.transaction(async (tx) => { + // Locks the device row, so a concurrent revoke or change waits rather than interleaving. + const devices = await tx + .select({ id: ekklesiaCoreDevice.id }) + .from(ekklesiaCoreDevice) + .where( + and(eq(ekklesiaCoreDevice.id, input.deviceId), isNull(ekklesiaCoreDevice.revokedAt)), + ) + .for("update") + .limit(1); + if (devices.length === 0) { + return false; + } + const target = input.permissionId; + await tx + .update(ekklesiaCoreDevicePermission) + .set({ revokedAt: input.now }) + .where( + and( + eq(ekklesiaCoreDevicePermission.deviceId, input.deviceId), + isNull(ekklesiaCoreDevicePermission.revokedAt), + ...(target === undefined + ? [] + : [ne(ekklesiaCoreDevicePermission.permissionId, target)]), + ), + ); + if (target !== undefined) { + const held = await tx + .select({ id: ekklesiaCoreDevicePermission.id }) + .from(ekklesiaCoreDevicePermission) + .where( + and( + eq(ekklesiaCoreDevicePermission.deviceId, input.deviceId), + eq(ekklesiaCoreDevicePermission.permissionId, target), + isNull(ekklesiaCoreDevicePermission.revokedAt), + ), + ) + .limit(1); + if (held.length === 0) { + await tx.insert(ekklesiaCoreDevicePermission).values({ + deviceId: input.deviceId, + grantedByUserId: input.changedBy, + permissionId: target, + }); + } + } + return true; + }); + } catch (error) { + rethrowAsStoreError(error); + } + }, + async revokeDevice(input): Promise { try { const rows = await db diff --git a/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md b/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md index c0dc677..bf26c70 100644 --- a/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md +++ b/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md @@ -133,6 +133,77 @@ If the module list cannot be read, the choice is simply not offered and the disp nothing yet. **Copy setup link** reaches the clipboard through the clicked button's own window, never a browser global (ADR 0058). +**Show on this screen** is `@ekklesia/ui`'s `SelectMenu`, not the browser's native select. That is +a shared, Ekklesia-drawn single choice: the WAI-ARIA select-only combobox (a `role="combobox"` button +and a `role="listbox"` of options). + +- **Look:** a token-themed list surface, a chevron inside the control's own padding, and hover, + active, and selected states in both themes. The control is an `ek-control`, so it keeps the 16px + phone size. +- **Keyboard:** the arrows, Home, End, and type-ahead move; Enter, Space, or Tab chooses; Escape + closes without closing a dialog it sits in. +- **Pointer and touch:** a press chooses, and a press outside closes. +- **Focus:** stays on the control throughout. + +### The normal list shows active displays, current as they change + +The Paired displays list shows **active** displays only. A revoked display leaves it at once — the +page asks for the list again right after revoking — and the page says so, with focus on that notice +because the display's own card is gone. The revoked device row and its permission rows stay in the +database, marked revoked, as security history; a history view can show them later. + +A newly paired display appears without a reload. Approval does not create the display: the screen +does, when it collects its credential on its next poll, a few seconds later (ADR 0036). So after +approving, the page shows a "Waiting for the screen to connect…" placeholder and asks for the list +again every second until a display it did not have appears, for up to a minute. Each answer replaces +the list, so the display can never appear twice. The page also refreshes the list every 30 seconds +on its own. + +Each active display shows: + +- its one name; +- **Online** (seen in the last 30 minutes), **Offline**, or **Not connected yet**. This is coarse on + purpose: last-seen is recorded at most every 15 minutes; +- **Showing:** a module, or *Nothing yet*; +- last seen and paired dates; +- **Change** and **Revoke**. + +### Changing what a display shows: one target, the active display permission + +In this Stage 1 model a display shows **at most one** module, and its current target **is** the one +active display permission it holds. There is no second "assignment" record: authorization ("may it +show Signage?") and assignment ("is it set to show Signage?") cannot disagree, because they are the +same row. No new migration is needed. + +**Change** opens *Change what Foyer TV shows*, with the same **Show on this screen** choice. Saving +calls `PUT /api/v1/devices//display` with `{ "moduleId": "" | null }`. That route requires +`core.devices.manage`, an exact trusted `Origin`, and a bounded body. The module must be a running +display, and the permission is that module's declared display permission. + +The store applies the change in one transaction on the same device row (locked): + +- every other active permission row is revoked — kept, with its `revokedAt`, as history; +- the new one is granted, unless the display already holds it. + +The device's identity, name, and credential never change, and a revoked or unknown display is a +`404`. Nothing yet → module, module A → module B, and module → nothing are all this one operation. + +**The screen notices on its own,** within about 30 seconds, whichever page it is on: + +- **On `/display/setup`, waiting:** it rechecks `/api/v1/device-pairing/self` every 30 seconds, and + opens the module as soon as it holds one. +- **On a module's display page:** a paired device asks `/api/v1/device-pairing/self` every 30 + seconds. If it is no longer set to show that module, it goes to `/display/setup`, which opens what + it shows now or waits, named. A refused descriptor for a screen that is still paired does the same, + instead of asking to be paired again. Only an unpaired or revoked screen shows a pairing code, as + before. +- **A person previewing a display page is never moved.** + +This does not decide how a screen is shared between modules later. Showing more than one module, or +arbitrating between them (DESIGN.md §11.6), may add a separate target or arbitration record beside +the permissions then. What stays fixed is the ownership: the platform owns the physical display +identity and its current target, and a module never creates its own display identity. + ### Module display routes are unchanged `/display/` keeps working exactly as it did: a screen that opens it directly still pairs @@ -198,9 +269,16 @@ unchanged. PR.** Rejected by the maintainer: with Signage installed, the platform's own setup flow would lead to a screen that could not show Signage, while Signage's page still worked. Choosing at approval closes the loop with the permission table that already exists. -- **Changing or reassigning a paired display's module after pairing.** Deferred: it needs revocation - and arbitration rules between modules (DESIGN.md §11.6). Today a display's module is chosen once, at - approval, or by the module page it was opened on. +- **A separate "current target" column beside the permissions.** Rejected for the single-target Stage + 1 model: it would be a second source of truth that must be kept equal to the permission that + actually authorizes the screen. If displays later show more than one module, a target or + arbitration record can be added then. +- **Revoking and re-pairing to change what a display shows.** Rejected: it would mint a new identity + for the same physical screen, and send someone to it with a new code. +- **Deleting revoked displays.** Rejected: revocation is security history. They leave the normal list + but stay in the database. +- **Polishing the native select.** Rejected: its open menu is drawn by the browser and cannot be + themed. `SelectMenu` is shared, so later choices get the same treatment. - **`maximum-scale=1` or `user-scalable=no`.** Rejected: it removes zoom for people who need it, and hides the cause instead of fixing it. - **16px controls everywhere.** Rejected: desktop does not zoom, and the compact size is the design. @@ -222,6 +300,10 @@ unchanged. address still works; Settings now sends administrators to `/display/setup`. - Text fields are 16px on phones and touch screens everywhere `@ekklesia/ui` is used, module UIs included. +- `@ekklesia/ui` gains `SelectMenu`, additively. The native `Select` remains for anything that wants + the browser's own control. +- A display's target changes without re-pairing. A paired screen on a module page now asks the + platform every 30 seconds whether it should still show that module. - A real-iPhone recheck remains necessary. Headless Chromium does not reproduce iOS Safari's focus-zoom or pull-to-refresh, so automated checks prove the computed font size and the cancelled gestures, not Safari's own behaviour. diff --git a/docs/development/api.md b/docs/development/api.md index e78fda3..25b4dfc 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -1007,6 +1007,7 @@ sets, never the code): | `POST /api/v1/device-pairing/approvals` | Body `{code, deviceName, moduleId?}`. The administrator names the screen; the screen never names itself. `moduleId`, for a platform display, is the running display module it shows; its declared display permission is granted. A module display's own request cannot be given a different module. | | `GET /api/v1/devices` | Paired devices: name, permissions, created, last seen, revoked. Never a credential. | | `GET /api/v1/devices/display-modules` | `{modules: [{moduleId, name, permissionId}]}`: the running modules a display can be set to show. | +| `PUT /api/v1/devices/:deviceId/display` | Body `{moduleId}` (a running display module, or `null`). Sets what the display shows: its other active permissions are revoked (kept as history) and that module's declared display permission is granted. Same identity, name, and credential. `404` for a revoked or unknown display. | | `POST /api/v1/devices/:deviceId/revoke` | `204`. Server-side revocation, checked on every principal resolution. | The pairing code travels in request **bodies**, never a path or query, so it does not land in access diff --git a/docs/development/testing.md b/docs/development/testing.md index 06fa1fd..215f6d2 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -497,6 +497,19 @@ proven here: - `apps/api/tests/module-display-routes.test.ts` serves `/display/setup` and the display host with zero display modules, and only with device support. - `core/tests/device-pairing-schema.test.ts` pins migration `0009` to its three additive statements. +- `apps/api/tests/device-pairing.integration.ts` also changes a display from nothing to a module, + between two modules, and back. It checks that the display keeps one identity, name, and credential, + that the module descriptors follow, that the history rows are kept, and that revoked displays and + bad requests are refused. +- `packages/ui/tests/select-menu.test.tsx` covers `SelectMenu`: + - its combobox and listbox semantics, and no native select; + - the keyboard: arrows, Home, End, type-ahead, Enter, Space, and Tab; + - Escape closing it without closing a surrounding dialog; + - pointer and outside presses, the disabled state, and token-only styling. +- `surfaces/shell/tests/display-app.test.tsx` covers a running module display: + - moved to another module, or refused while still paired, it goes to `/display/setup`; + - an unpaired screen still shows a code; + - a person previewing the page is never moved. - `surfaces/shell/tests/display-setup-app.test.tsx` covers the setup page: - it pairs with no module and waits, named; - it opens the module it is set to show; @@ -504,6 +517,10 @@ proven here: - it returns to a code when revoked; - it never pairs on a server failure. - `surfaces/shell/tests/displays-settings.test.tsx` covers: + - active displays only, with status and what each shows; + - a newly paired display appearing without a reload, and never twice; + - **Change** moving a display between nothing and modules as the same display; + - a revoked display leaving the list at once; - the platform setup address, and **Copy setup link**; - **Add display** with zero display modules; - the **Show on this screen** choice; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 57488ef..6b25d86 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -49,6 +49,7 @@ export { type ConfirmationTone, } from "./confirmation.js"; export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js"; +export { SelectMenu, type SelectMenuOption, type SelectMenuProps } from "./select-menu.js"; export { Badge, type BadgeProps, diff --git a/packages/ui/src/select-menu.tsx b/packages/ui/src/select-menu.tsx new file mode 100644 index 0000000..4a47b8c --- /dev/null +++ b/packages/ui/src/select-menu.tsx @@ -0,0 +1,254 @@ +import { + type FocusEvent, + type KeyboardEvent, + type ReactElement, + useCallback, + useEffect, + useId, + useRef, + useState, +} from "react"; +import { classNames } from "./class-names.js"; + +/** One choice in a {@link SelectMenu}. */ +export interface SelectMenuOption { + readonly value: Value; + readonly label: string; +} + +export interface SelectMenuProps { + readonly value: Value; + readonly options: readonly SelectMenuOption[]; + readonly onChange: (value: Value) => void; + /** The control's id, so a `Field` label names it. */ + readonly id?: string; + readonly disabled?: boolean | undefined; + readonly "aria-describedby"?: string | undefined; + readonly "aria-invalid"?: boolean | undefined; + readonly className?: string; + readonly "data-testid"?: string; +} + +/** + * A single choice from a short list, drawn by Ekklesia rather than the browser. + * + * A native ` setShows(event.target.value)} + onChange={setShows} + options={targetOptions(modules)} value={shows} - > - - {modules.map((module) => ( - - ))} - + /> )} ) : undefined} @@ -388,30 +416,30 @@ function DisplaySetupPanel(props: { ); } +/** + * One active display: its one name, whether it is online (coarsely — see {@link ONLINE_WITHIN_MS}), + * what it shows now, and the two things an administrator does with it — change what it shows, and + * revoke it. Revoked displays are not listed: they are history, not something to manage. + */ function DisplayCard(props: { readonly device: HostDeviceSummary; readonly now: Date; readonly modules: DisplayModules; - readonly onRevoke: () => void; + readonly onChange: (event: MouseEvent) => void; + readonly onRevoke: (event: MouseEvent) => void; }): ReactElement { - const { device, now } = props; + const { device, now, modules } = props; + const seen = device.lastSeenAt; + const status = + seen === undefined + ? { label: "Not connected yet", tone: "neutral" as const } + : now.getTime() - seen.getTime() <= ONLINE_WITHIN_MS + ? { label: "Online", tone: "success" as const } + : { label: "Offline", tone: "warning" as const }; + const target = modules === undefined ? undefined : currentTarget(device, modules); 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) { - facts.push(`Last seen ${lastSeenPhrase(device.lastSeenAt, now)}`); - } else { - facts.push("Not seen since it was paired"); + if (seen !== undefined) { + facts.push(`Last seen ${lastSeenPhrase(seen, now)}`); } if (device.pairedAt !== undefined) { facts.push(`Paired ${calendarDate(device.pairedAt)}`); @@ -424,38 +452,166 @@ function DisplayCard(props: {
{device.name} - - {device.revoked ? "Revoked" : "Paired"} - + {status.label}
-

{facts.join(" · ")}

+ {modules === undefined ? undefined : ( +

+ Showing: {target?.name ?? "Nothing yet"} +

+ )} + {facts.length === 0 ? undefined : ( +

{facts.join(" · ")}

+ )}
- {device.revoked ? undefined : ( +
+ {modules === undefined ? undefined : ( + + )} - )} +
); } +/** "Change what Foyer TV shows": the one current target, nothing yet or an installed module. */ +function ChangeTargetDialog(props: { + readonly config: EkklesiaHostConfig; + readonly device: HostDeviceSummary | undefined; + readonly modules: readonly HostDisplayModule[]; + readonly onDismiss: () => void; + readonly onChanged: (device: HostDeviceSummary, shows: HostDisplayModule | undefined) => void; +}): ReactElement { + const { config, device, modules, onChanged } = props; + const initial = + device === undefined ? NO_MODULE : (currentTarget(device, modules)?.moduleId ?? NO_MODULE); + const [shows, setShows] = useState(initial); + const [saving, setSaving] = useState(false); + const [failed, setFailed] = useState(false); + const selectRef = useRef(null); + const initialFocusRef = useRef(null); + + // Each time it opens, it starts from what the display shows now. + useEffect(() => { + setShows(initial); + setFailed(false); + setSaving(false); + initialFocusRef.current = + selectRef.current?.querySelector('[role="combobox"]') ?? null; + }, [initial]); + + const save = async (): Promise => { + if (device === undefined) { + return; + } + setSaving(true); + setFailed(false); + try { + await setHostDisplayTarget(config, device.id, shows === NO_MODULE ? undefined : shows); + onChanged( + device, + modules.find((module) => module.moduleId === shows), + ); + } catch { + setFailed(true); + setSaving(false); + } + }; + + return ( + + + + + } + data-testid="device-change-dialog" + description="The screen switches on its own within a minute. It stays the same display, with the same name." + dismissible={!saving} + initialFocusRef={initialFocusRef} + onDismiss={props.onDismiss} + open={device !== undefined} + title={`Change what ${device?.name ?? "this display"} shows`} + > +
+ + {(control) => ( + + )} + +
+ {failed ? ( + + + That change could not be saved. Try again in a moment. + + + ) : undefined} +
+ ); +} + +/** What the page last told the administrator, after a pairing, a change, or a revocation. */ +type Outcome = + | { readonly kind: "paired"; readonly name: string; readonly shows: string | undefined } + | { readonly kind: "changed"; readonly name: string; readonly shows: string | undefined } + | { readonly kind: "revoked"; readonly name: string }; + +/** + * A pairing approved a moment ago. The display itself comes into being only when the screen + * collects its credential on its next poll (ADR 0036), a few seconds later, so the page checks again + * until a display it did not have before appears — and shows a placeholder in its place meanwhile. + */ +interface Awaiting { + readonly name: string; + readonly known: ReadonlySet; + readonly until: number; +} + export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactElement { const { config } = props; + const pollMs = props.pollMs ?? 1000; + const refreshMs = props.refreshMs ?? LIST_REFRESH_MS; const [list, setList] = useState({ kind: "loading" }); const [modules, setModules] = useState(undefined); const [adding, setAdding] = useState(false); - const [paired, setPaired] = useState< - { readonly name: string; readonly shows: string | undefined } | undefined - >(undefined); + const [outcome, setOutcome] = useState(undefined); + const [awaiting, setAwaiting] = useState(undefined); + /** The display whose content an administrator is changing. */ + const [changing, setChanging] = useState(undefined); /** The display an administrator asked to revoke and has not yet confirmed. */ const [confirming, setConfirming] = useState(undefined); const [revoking, setRevoking] = useState(false); + const outcomeRef = useRef(null); const dismissConfirmation = useCallback(() => setConfirming(undefined), []); const dismissAdding = useCallback(() => setAdding(false), []); + const dismissChanging = useCallback(() => setChanging(undefined), []); - const refresh = useCallback(async (): Promise => { + const refresh = useCallback(async (): Promise => { try { - setList({ devices: await listHostDevices(config), kind: "ready" }); + const devices = await listHostDevices(config); + setList({ devices, kind: "ready" }); + return devices; } catch (error) { setList({ kind: @@ -463,6 +619,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle ? "denied" : "failed", }); + return undefined; } }, [config]); @@ -470,6 +627,15 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle void refresh(); }, [refresh]); + // The page keeps itself current — status, and changes made from another tab — on its own. + useEffect(() => { + if (refreshMs <= 0) { + return; + } + const timer = setInterval(() => void refresh(), refreshMs); + return () => clearInterval(timer); + }, [refresh, refreshMs]); + useEffect(() => { const controller = new AbortController(); // What a display can be set to show. If it cannot be read, pairing still works — the display is @@ -485,25 +651,88 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle return () => controller.abort(); }, [config]); - const revoke = async (deviceId: string): Promise => { + /** Revoked displays are history, not something to manage: the list shows active ones only. */ + const active = list.kind === "ready" ? list.devices.filter((device) => !device.revoked) : []; + + // While a just-approved display has not appeared, check again shortly — each answer replaces the + // list, so it can never appear twice — and stop once it is there, or after a while regardless. + const arrived = awaiting !== undefined && active.some((device) => !awaiting.known.has(device.id)); + // biome-ignore lint/correctness/useExhaustiveDependencies: each new list answer is what schedules the next check, so `list` is the trigger even though it is not read here. + useEffect(() => { + if (awaiting === undefined) { + return; + } + if (arrived || Date.now() > awaiting.until) { + setAwaiting(undefined); + return; + } + const timer = setTimeout(() => void refresh(), pollMs); + return () => clearTimeout(timer); + }, [arrived, awaiting, list, pollMs, refresh]); + + // The control that started a revocation is gone with its card, so focus lands on what happened + // once it is on screen, without scrolling the page. + const focusOutcome = useRef(false); + useEffect(() => { + if (outcome !== undefined && focusOutcome.current) { + focusOutcome.current = false; + outcomeRef.current?.focus({ preventScroll: true }); + } + }, [outcome]); + const showOutcome = (next: Outcome): void => { + focusOutcome.current = true; + setOutcome(next); + }; + + const revoke = async (device: HostDeviceSummary): Promise => { setRevoking(true); try { - await revokeHostDevice(config, deviceId); + await revokeHostDevice(config, device.id); } catch { // Left to the refresh below rather than asserting a failure the operator cannot act on. } - await refresh(); + const devices = await refresh(); setRevoking(false); setConfirming(undefined); + if (devices?.find((candidate) => candidate.id === device.id)?.revoked ?? true) { + showOutcome({ kind: "revoked", name: device.name }); + } }; const onPaired = (name: string, shows: string | undefined): void => { setAdding(false); - setPaired({ name, shows }); + setOutcome({ kind: "paired", name, shows }); + setAwaiting({ + known: new Set(list.kind === "ready" ? list.devices.map((device) => device.id) : []), + name, + until: Date.now() + AWAIT_PAIRED_MS, + }); // The new display appears without a page refresh (DESIGN.md §11.4). void refresh(); }; + const onChanged = (device: HostDeviceSummary, shows: HostDisplayModule | undefined): void => { + setChanging(undefined); + setOutcome({ kind: "changed", name: device.name, shows: shows?.moduleId }); + void refresh(); + }; + + /** Focuses the control that opens a dialog before it opens, so the dialog returns focus there — + * some browsers (Safari) do not focus a clicked button — without scrolling the page. */ + const opener = + (open: () => void) => + (event: MouseEvent): void => { + event.currentTarget.focus({ preventScroll: true }); + open(); + }; + + const moduleLabel = (moduleId: string | undefined): string | undefined => + moduleId === undefined + ? undefined + : (modules?.find((module) => module.moduleId === moduleId)?.name ?? + props.moduleName(moduleId) ?? + moduleId); + const now = props.now?.() ?? new Date(); const canAdd = list.kind === "ready"; return ( @@ -517,30 +746,40 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle title="Displays" /> - {paired === undefined ? undefined : ( - - - {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 - }.`} - - + {outcome === undefined ? undefined : ( +
+ {outcome.kind === "paired" ? ( + + + {outcome.shows === undefined + ? `${outcome.name} is paired. It shows its name until a module is set to show on it.` + : `${outcome.name} is paired and shows ${moduleLabel(outcome.shows)}.`} + + + ) : outcome.kind === "changed" ? ( + + + {outcome.shows === undefined + ? `${outcome.name} now shows nothing. It shows its name until a module is set.` + : `${outcome.name} now shows ${moduleLabel(outcome.shows)}.`} + + + ) : ( + + + {outcome.name} is revoked. Its screen returns to its pairing code. + + + )} +
)} {canAdd ? ( { - // Focus returns here when the dialog closes. Some browsers (Safari) do not focus a button - // that is clicked, so it is focused deliberately before the dialog opens — without - // scrolling, which would move a page whose button sits under the phone header. - event.currentTarget.focus({ preventScroll: true }); - setPaired(undefined); + onAdd={opener(() => { + setOutcome(undefined); setAdding(true); - }} + })} url={displaySetupUrl(config)} /> ) : undefined} @@ -570,7 +809,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle ) : undefined} {list.kind === "ready" ? ( - {list.devices.length === 0 ? ( + {active.length === 0 && awaiting === undefined ? (
) : (
    - {list.devices.map((device) => ( + {active.map((device) => ( setConfirming(device)} + onChange={opener(() => { + setOutcome(undefined); + setChanging(device); + })} + onRevoke={opener(() => setConfirming(device))} /> ))} + {awaiting !== undefined && !arrived ? ( +
  • + +
    + {awaiting.name} +

    + Waiting for the screen to connect… +

    +
    +
  • + ) : undefined}
)} @@ -602,6 +861,16 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle open={adding} /> + {modules === undefined ? undefined : ( + + )} + {/* Revoking strands the screen until someone re-pairs it in person, so the consequence is spelled out before it happens — never a browser-native confirm. */} (confirming ? void revoke(confirming.id) : undefined)} + onClick={() => (confirming ? void revoke(confirming) : undefined)} variant="danger" > Revoke display diff --git a/surfaces/shell/src/browser/shell-styles.ts b/surfaces/shell/src/browser/shell-styles.ts index f93cdf0..7bea980 100644 --- a/surfaces/shell/src/browser/shell-styles.ts +++ b/surfaces/shell/src/browser/shell-styles.ts @@ -338,6 +338,24 @@ export const SHELL_STYLESHEET = ` overflow-wrap: anywhere; } +.ek-shell-display-card__showing { + color: ${designToken("text.primary")}; + font-size: ${designToken("typography.size-sm")}; + margin: 0; +} + +.ek-shell-display-card__actions { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + gap: ${designToken("spacing.xs")}; +} + +/* A display just approved, waiting for its screen to collect its credential. */ +.ek-shell-display-card--pending { + border-style: dashed; +} + .ek-shell-display-card__facts { color: ${designToken("text.secondary")}; font-size: ${designToken("typography.size-sm")}; diff --git a/surfaces/shell/src/display/DisplayApp.tsx b/surfaces/shell/src/display/DisplayApp.tsx index bcf8f00..ee06e4e 100644 --- a/surfaces/shell/src/display/DisplayApp.tsx +++ b/surfaces/shell/src/display/DisplayApp.tsx @@ -3,9 +3,11 @@ import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import { type ReactElement, useCallback, useEffect, useRef, useState } from "react"; import { bootstrapDisplayHost, + DISPLAY_SETUP_PATH, type DisplayHostBootstrap, type EkklesiaHostConfig, HostProtocolError, + loadDisplaySelf, } from "../host-protocol.js"; import { DisplayUiFrame } from "./DisplayUiFrame.js"; import { @@ -26,6 +28,9 @@ type DisplayState = } /** No identity this display can use yet: show a pairing code rather than a human login form. */ | { readonly status: "pairing" } + /** Still a paired display, but set to show something else now: on its way to the platform's + * setup page, which opens whatever it shows now (ADR 0065). */ + | { readonly status: "moving" } | { readonly status: "unavailable" } | { readonly status: "failed" }; @@ -47,8 +52,17 @@ export interface DisplayAppProps { /** The first automatic retry delay after a failed start; doubles to a one-minute ceiling. Test seam * only. */ readonly retryBaseMs?: number; + /** Opens another display page in place of this one. The entry point supplies `location.replace`; + * without it a display changed to show something else falls back to showing a pairing code. */ + readonly openDisplay?: (url: string) => void; + /** How often a paired screen asks the platform what it should show. Test seam only. */ + readonly assignmentCheckMs?: number; } +/** A paired screen asks the platform this often whether it should still show this module, so a + * change in Settings → Displays reaches it within this interval (ADR 0065). */ +export const DISPLAY_ASSIGNMENT_CHECK_MS = 30_000; + /** A Wi-Fi reassociation or a switch reboot lasts seconds and needs no comment on a lobby wall. Only * an outage that outlasts this is worth the corner indicator. */ export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000; @@ -210,7 +224,32 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS, retryBaseMs = DISPLAY_RETRY_BASE_MS, revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS, + openDisplay, + assignmentCheckMs = DISPLAY_ASSIGNMENT_CHECK_MS, } = props; + + /** + * Refused (ADR 0065): either the screen is not paired at all — revoked, expired, or never — and + * shows a pairing code as it always has, or it is still a paired display that is now set to show + * something else, and goes to the platform's setup page, which opens whatever it shows now. Only + * the platform can tell the two apart, so this asks it; a person previewing the page is never a + * device, and keeps the old behaviour. + */ + const onDenied = useCallback(async (): Promise => { + if (openDisplay !== undefined) { + try { + const self = await loadDisplaySelf(config); + if (!self.displays.some((display) => display.moduleId === moduleId)) { + setState({ status: "moving" }); + openDisplay(DISPLAY_SETUP_PATH); + return; + } + } catch { + // Not a paired display: pair, as before. + } + } + setState({ status: "pairing" }); + }, [config, moduleId, openDisplay]); /** Consecutive automatic retries since the display last started successfully. */ const failures = useRef(0); const stateRef = useRef(state); @@ -261,7 +300,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { }) .catch((error: unknown) => { if (!abort.signal.aborted && isDenied(error)) { - setState({ status: "pairing" }); + void onDenied(); } }) .finally(() => { @@ -270,7 +309,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { } }); }, - [config, moduleId, revalidationIntervalMs], + [config, moduleId, onDenied, revalidationIntervalMs], ); useEffect( @@ -292,6 +331,40 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { return () => clearInterval(timer); }, [periodicRevalidationMs, revalidate, state.status]); + // A paired screen asks the platform, now and then, whether it should still show this module, so a + // change made in Settings → Displays reaches it without anyone touching the screen (ADR 0065). A + // person previewing the page is not a device, and is never moved. + const viewerIsDevice = state.status === "ready" && state.bootstrap.principal.kind === "device"; + useEffect(() => { + if (!viewerIsDevice || openDisplay === undefined || assignmentCheckMs <= 0) { + return; + } + const abort = new AbortController(); + const timer = setInterval(() => { + loadDisplaySelf(config, abort.signal).then( + (self) => { + if ( + !abort.signal.aborted && + !self.displays.some((display) => display.moduleId === moduleId) + ) { + setState({ status: "moving" }); + openDisplay(DISPLAY_SETUP_PATH); + } + }, + (error: unknown) => { + // Revoked: the descriptor will say so too, and return the screen to pairing. + if (!abort.signal.aborted && isDenied(error)) { + revalidate("authorization"); + } + }, + ); + }, assignmentCheckMs); + return () => { + abort.abort(); + clearInterval(timer); + }; + }, [assignmentCheckMs, config, moduleId, openDisplay, revalidate, viewerIsDevice]); + // Could not start: try again on its own, backing off. Pairing is not a failure — it has its own // polling — and a ready display has nothing to retry. useEffect(() => { @@ -349,7 +422,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { // A denied or unauthenticated descriptor is the ordinary state of a screen nobody has // paired yet, not an error worth logging on every wall in the building. if (isDenied(error)) { - setState({ status: "pairing" }); + void onDenied(); return; } console.error("[ekklesia] display host bootstrap failed", error); @@ -360,7 +433,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { ); }); return () => abort.abort(); - }, [attempt, config, moduleId]); + }, [attempt, config, moduleId, onDenied]); const retry = (): void => setAttempt((current) => current + 1); const showOffline = useSustained( @@ -377,6 +450,9 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { {state.status === "pairing" ? ( ) : undefined} + {state.status === "moving" ? ( + Switching this display… + ) : undefined} {state.status === "unavailable" ? ( This display is unavailable. diff --git a/surfaces/shell/src/display/main.tsx b/surfaces/shell/src/display/main.tsx index 6153835..6d8da62 100644 --- a/surfaces/shell/src/display/main.tsx +++ b/surfaces/shell/src/display/main.tsx @@ -76,7 +76,12 @@ createRoot(container).render( // display can live at this address. window.location.replace(url)} /> ) : moduleId ? ( - + window.location.replace(url)} + /> ) : (
{ const performRequest = config.fetch ?? fetch; let response: Response; @@ -737,6 +737,26 @@ export async function loadDisplaySelf( }); } +/** + * Changes what a paired display shows (`PUT /api/v1/devices//display`, ADR 0065): an installed + * display module, or `undefined` for nothing. The same display — same identity, name, and + * credential — before and after; the screen picks the change up on its own. + */ +export async function setHostDisplayTarget( + config: EkklesiaHostConfig, + deviceId: string, + moduleId: string | undefined, + signal?: AbortSignal, +): Promise { + await requestPlatformMutation( + config, + `/api/v1/devices/${encodeURIComponent(deviceId)}/display`, + { moduleId: moduleId ?? null }, + signal, + "PUT", + ); +} + /** One installed module as module lifecycle reports it (ADR 0028): what it is and whether it runs. */ export interface HostModuleLifecycleSummary { readonly id: string; diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index a289339..070ebc8 100644 --- a/surfaces/shell/tests/display-app.test.tsx +++ b/surfaces/shell/tests/display-app.test.tsx @@ -1239,3 +1239,133 @@ describe("display host — continuity across reconnects and revocation", () => { expect(display.counts.mounts).toBe(2); }); }); + +describe("display host — following what the screen is set to show (ADR 0065)", () => { + async function wait(ms: number): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); + } + + /** + * A paired display (`viewer.kind === "device"`) on the announcements page. `shows` is what the + * platform says it may show now; `null` means it is not a paired display at all (403). + */ + function assignedServer(initial: { shows: string[] | null; descriptor?: number }) { + const state = { descriptor: initial.descriptor ?? 200, shows: initial.shows }; + const calls: string[] = []; + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input)).pathname; + calls.push(url); + if (url === "/api/v1/module-displays/announcements") { + return state.descriptor === 200 + ? json( + descriptorPayload("announcements", "1.2.0", { deviceId: "device-1", kind: "device" }), + ) + : json({ code: "AUTHORIZATION_DENIED" }, state.descriptor); + } + if (url === "/api/v1/device-pairing/self") { + return state.shows === null + ? json({ code: "AUTHORIZATION_DENIED" }, 403) + : json({ + displays: state.shows.map((moduleId) => ({ moduleId, url: `/display/${moduleId}` })), + name: "Foyer TV", + }); + } + if (url === "/api/v1/device-pairing/requests") { + return json( + { code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 }, + 201, + ); + } + if (url === "/api/v1/device-pairing/claims") { + return json({ status: "pending" }); + } + return json({ code: "NOT_FOUND" }, 404); + }) as unknown as typeof globalThis.fetch; + return { calls, fetch, state }; + } + + async function renderAssigned(server: ReturnType) { + const openDisplay = vi.fn(); + await act(async () => { + root.render( + artifact()} + moduleId="announcements" + offlineIndicatorDelayMs={0} + openDisplay={openDisplay} + />, + ); + }); + await wait(0); + return openDisplay; + } + + it("goes to the platform setup page when a running screen is set to show something else", async () => { + const server = assignedServer({ shows: ["announcements"] }); + const openDisplay = await renderAssigned(server); + expect(query("display-module-container")).not.toBeNull(); + + // Still this module: nothing happens, however often it asks. + await wait(60); + expect(openDisplay).not.toHaveBeenCalled(); + + // Settings → Displays moves it to another module (or to nothing). + server.state.shows = ["bulletin"]; + await wait(60); + expect(openDisplay).toHaveBeenCalledWith("/display/setup"); + expect(query("display-host-moving")).not.toBeNull(); + // It was never asked to pair again. + expect(server.calls).not.toContain("/api/v1/device-pairing/requests"); + }); + + it("goes to setup, not to a new pairing code, when its descriptor is refused but it is still paired", async () => { + const server = assignedServer({ descriptor: 403, shows: [] }); + const openDisplay = await renderAssigned(server); + await wait(10); + + expect(openDisplay).toHaveBeenCalledWith("/display/setup"); + expect(query("display-pairing")).toBeNull(); + expect(server.calls).not.toContain("/api/v1/device-pairing/requests"); + }); + + it("still shows a pairing code when it is not a paired display at all", async () => { + const server = assignedServer({ descriptor: 403, shows: null }); + const openDisplay = await renderAssigned(server); + await wait(10); + + expect(openDisplay).not.toHaveBeenCalled(); + expect(query("display-pairing")).not.toBeNull(); + }); + + it("never moves a person previewing the page", async () => { + const server = assignedServer({ shows: [] }); + const fetch = server.fetch; + // The descriptor reports a person, not a device. + const personFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith("/api/v1/module-displays/announcements")) { + return json(descriptorPayload()); + } + return fetch(input, init); + }) as unknown as typeof globalThis.fetch; + const openDisplay = vi.fn(); + await act(async () => { + root.render( + artifact()} + moduleId="announcements" + offlineIndicatorDelayMs={0} + openDisplay={openDisplay} + />, + ); + }); + await wait(80); + expect(openDisplay).not.toHaveBeenCalled(); + expect(server.calls).not.toContain("/api/v1/device-pairing/self"); + }); +}); diff --git a/surfaces/shell/tests/displays-settings.test.tsx b/surfaces/shell/tests/displays-settings.test.tsx index a6f5b68..f20f7f1 100644 --- a/surfaces/shell/tests/displays-settings.test.tsx +++ b/surfaces/shell/tests/displays-settings.test.tsx @@ -9,13 +9,18 @@ declare global { } /** - * Settings → Displays (ADR 0064): the device surface that used to sit in the sidebar, moved into - * Settings — the paired displays, a deliberate Add display flow, and revocation in a product dialog. + * Settings → Displays (ADR 0064, ADR 0065): the active platform displays, setting up a new screen, + * pairing it, changing what it shows, and revoking it. */ const apiBaseUrl = "https://ekklesia.example.test"; const NOW = new Date("2026-10-15T12:00:00.000Z"); +/** 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. */ +const PLATFORM_CODE = "654321"; + let container: HTMLDivElement; let root: Root; @@ -38,21 +43,31 @@ function json(body: unknown, status = 200): Response { }); } +interface Device { + id: string; + name: string; + createdAt: string; + lastSeenAt?: string; + permissions: string[]; + revoked: boolean; +} + interface ServerOptions { - readonly devices?: unknown[]; + readonly devices?: Device[]; readonly denied?: boolean; - /** What `GET /api/v1/devices/display-modules` answers: the installed display modules (by - * default Signage), none, or a failure. */ + /** What `GET /api/v1/devices/display-modules` answers. */ 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"; +const SIGNAGE = { moduleId: "signage", name: "Signage", permissionId: "signage.display" }; +const BULLETIN = { moduleId: "bulletin", name: "Bulletin", permissionId: "bulletin.display" }; +/** + * A fake API that behaves like the real one where it matters: an approved display does not exist + * until its screen collects its credential, which here happens a couple of list requests later. + */ function server(options: ServerOptions = {}) { - let devices: unknown[] = options.devices ?? [ + const devices: Device[] = options.devices ?? [ { createdAt: "2026-10-01T09:00:00.000Z", id: "device-1", @@ -62,26 +77,52 @@ function server(options: ServerOptions = {}) { revoked: false, }, ]; - const requests: { path: string; body: unknown }[] = []; + const modules = + options.modules === "fails" ? [] : ((options.modules ?? [SIGNAGE]) as (typeof SIGNAGE)[]); + let pending: { device: Device; listsUntilClaimed: number } | undefined; + const requests: { path: string; method: string; body: unknown }[] = []; const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const path = new URL(String(input)).pathname; + const method = init?.method ?? "GET"; const body = init?.body === undefined ? undefined : JSON.parse(String(init.body)); - requests.push({ body, path }); + requests.push({ body, method, path }); if (path === "/api/v1/devices/display-modules") { - if (options.modules === "fails") { - return json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503); - } - return json({ - modules: options.modules ?? [ - { moduleId: "signage", name: "Signage", permissionId: "signage.display" }, - ], - }); + return options.modules === "fails" + ? json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503) + : json({ modules }); } if (path === "/api/v1/devices") { - return options.denied ? json({ code: "AUTHORIZATION_DENIED" }, 403) : json({ devices }); + if (options.denied) { + return json({ code: "AUTHORIZATION_DENIED" }, 403); + } + if (pending !== undefined) { + pending.listsUntilClaimed -= 1; + if (pending.listsUntilClaimed <= 0) { + devices.push(pending.device); + pending = undefined; + } + } + return json({ devices }); } - if (path === "/api/v1/devices/device-1/revoke") { - devices = devices.map((device) => ({ ...(device as object), revoked: true })); + const display = /^\/api\/v1\/devices\/([^/]+)\/display$/u.exec(path); + if (display && method === "PUT") { + const device = devices.find((candidate) => candidate.id === display[1]); + const moduleId = (body as { moduleId: string | null }).moduleId; + const module = modules.find((candidate) => candidate.moduleId === moduleId); + if (!device || device.revoked || (moduleId !== null && !module)) { + return json({ code: "NOT_FOUND" }, 404); + } + device.permissions = module ? [module.permissionId] : []; + return json({ moduleId }); + } + const revoke = /^\/api\/v1\/devices\/([^/]+)\/revoke$/u.exec(path); + if (revoke) { + for (const device of devices) { + if (device.id === revoke[1]) { + device.revoked = true; + device.permissions = []; + } + } return new Response(null, { status: 204 }); } if (path === "/api/v1/device-pairing/lookups") { @@ -100,24 +141,22 @@ function server(options: ServerOptions = {}) { 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, - { + const module = modules.find((candidate) => candidate.moduleId === shows); + pending = { + device: { createdAt: "2026-10-15T12:00:00.000Z", - id: "device-2", + id: `device-${devices.length + 1}`, name: approval.deviceName, - permissions: shows === "signage" ? ["signage.display"] : [], + permissions: module ? [module.permissionId] : [], revoked: false, }, - ]; - return json({ - moduleId: shows ?? null, - permissionId: shows === undefined ? null : `${shows}.display`, - }); + listsUntilClaimed: 2, + }; + return json({ moduleId: shows ?? null, permissionId: module?.permissionId ?? null }); } return json({ code: "NOT_FOUND" }, 404); }) as unknown as typeof globalThis.fetch; - return { fetch, requests }; + return { devices, fetch, requests }; } function query(testId: string): HTMLElement | null { @@ -138,6 +177,19 @@ async function settle(): Promise { } } +/** Waits, in real time, for something the page does on its own (a revalidation). */ +async function eventually(condition: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) { + throw new Error("timed out"); + } + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } +} + async function click(testId: string): Promise { await act(async () => { required(testId).click(); @@ -154,15 +206,6 @@ async function type(testId: string, value: string): Promise { }); } -async function choose(testId: string, value: string): Promise { - 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 { const form = (required(testId) as HTMLButtonElement).form; await act(async () => { @@ -171,6 +214,22 @@ async function submit(testId: string): Promise { }); } +/** Opens a SelectMenu and chooses an option by its label, as a pointer would. */ +async function choose(testId: string, label: string): Promise { + await click(testId); + const listId = required(testId).getAttribute("aria-controls") ?? ""; + const option = [...(container.querySelector(`#${CSS.escape(listId)}`)?.children ?? [])].find( + (candidate) => candidate.textContent === label, + ) as HTMLElement | undefined; + if (!option) { + throw new Error(`no option ${label}`); + } + await act(async () => { + option.click(); + await settle(); + }); +} + async function renderPage(options: ServerOptions = {}) { const platform = server(options); await act(async () => { @@ -180,6 +239,8 @@ async function renderPage(options: ServerOptions = {}) { config={{ apiBaseUrl, fetch: platform.fetch }} moduleName={(moduleId) => (moduleId === "signage" ? "Signage" : undefined)} now={() => NOW} + pollMs={5} + refreshMs={0} />, ); await settle(); @@ -188,34 +249,61 @@ async function renderPage(options: ServerOptions = {}) { } describe("Settings → Displays", () => { - it("lists the paired displays with what is known about each, and takes no focus", async () => { + it("lists each active display with its status, what it shows, and its actions", async () => { const before = document.activeElement; await renderPage(); const card = required("device-device-1"); expect(card.textContent).toContain("Foyer TV"); - expect(card.textContent).toContain("Paired"); + expect(card.textContent).toContain("Online"); + expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet"); expect(card.textContent).toContain("Last seen within the last hour"); - expect(card.textContent).toContain("Not showing a module yet"); + expect(query("device-change-device-1")?.textContent).toBe("Change"); + expect(query("device-revoke-device-1")?.textContent).toBe("Revoke"); expect(container.querySelector("h1")?.textContent).toBe("Displays"); - // 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. + // Setting up a new screen comes first, in one place, at the platform's own setup address. 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/setup`); expect(setup.contains(required("add-display"))).toBe(true); - const steps = required("display-setup-steps"); - expect(steps.tagName).toBe("DETAILS"); - expect((steps as HTMLDetailsElement).open).toBe(false); - expect(steps.querySelector("summary")?.textContent).toBe("Step-by-step instructions"); - expect(steps.textContent).toContain("six-digit pairing code"); - // Not a second Add display up in the header. - expect(container.querySelectorAll('[data-testid="add-display"]')).toHaveLength(1); + expect((required("display-setup-steps") as HTMLDetailsElement).open).toBe(false); // No pairing form until someone asks for one, and nothing grabbed focus. expect(container.querySelector("input")).toBeNull(); expect(document.activeElement).toBe(before); }); + it("shows only active displays: revoked ones are history, not the normal list", async () => { + await renderPage({ + devices: [ + { + createdAt: "2026-10-01T09:00:00.000Z", + id: "a", + name: "Hall", + permissions: [], + revoked: false, + }, + { + createdAt: "2026-09-01T09:00:00.000Z", + id: "b", + name: "Old test screen", + permissions: [], + revoked: true, + }, + { + createdAt: "2026-09-02T09:00:00.000Z", + id: "c", + name: "Another old one", + permissions: [], + revoked: true, + }, + ], + }); + expect(query("device-a")).not.toBeNull(); + expect(query("device-b")).toBeNull(); + expect(query("device-c")).toBeNull(); + expect(container.textContent).not.toContain("Revoked"); + expect(required("device-a").textContent).toContain("Not connected yet"); + }); + it("says when nothing is paired yet, and when the list is not this person's to see", async () => { await renderPage({ devices: [] }); expect(required("device-list-empty").textContent).toContain("No displays are paired yet"); @@ -227,40 +315,37 @@ describe("Settings → Displays", () => { expect(query("add-display")).toBeNull(); }); - it("pairs a display through a deliberate Add display flow, focusing each next step", async () => { + it("shows a newly paired display without a page refresh, once its screen connects", async () => { const platform = await renderPage(); await click("add-display"); - const dialog = required("add-display-dialog"); - expect(dialog.getAttribute("role")).toBe("dialog"); - // Typing the code is now plainly the next thing to do, so the field has focus. expect(document.activeElement).toBe(required("device-pairing-code-input")); - await type("device-pairing-code-input", "999999"); await submit("device-pairing-lookup"); expect(required("device-pairing-error").textContent).toContain("not waiting for approval"); 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"); await submit("device-pairing-approve"); + // The dialog closes and the page says so at once; the screen has not connected yet. expect(query("add-display-dialog")).toBeNull(); - expect( - platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body, - ).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 and shows Signage.", ); - expect(required("device-device-2").textContent).toContain("Shows Signage"); - // Focus is back on the control that opened the flow. + expect(required("device-awaiting").textContent).toContain("Waiting for the screen to connect"); + + // The page checks again on its own until the display exists — no reload. + await eventually(() => query("device-device-2") !== null); + expect(query("device-awaiting")).toBeNull(); + expect(required("device-device-2-showing").textContent).toBe("Showing: Signage"); + // Never twice, however many times it is fetched again. + expect(container.querySelectorAll('[data-testid="device-device-2"]')).toHaveLength(1); + expect(platform.requests.filter((r) => r.path === "/api/v1/devices").length).toBeGreaterThan(2); expect(document.activeElement).toBe(required("add-display")); }); @@ -295,15 +380,11 @@ describe("setting up a new screen (ADR 0065)", () => { expect(required("display-setup").textContent).toContain("couldn't copy it"); }); - it("offers the same setup and Add display with zero display modules installed", async () => { + it("pairs a platform display with zero display modules installed", async () => { const platform = await renderPage({ modules: [] }); - - // 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"); @@ -320,40 +401,35 @@ describe("setting up a new screen (ADR 0065)", () => { 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"); + await eventually(() => query("device-device-2") !== null); + expect(required("device-device-2-showing").textContent).toBe("Showing: Nothing yet"); }); - it("lets the administrator choose which installed module a new platform display shows", async () => { + it("chooses what a new platform display shows with the themed select, not a native one", async () => { const platform = await renderPage(); await click("add-display"); 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"); + const dialog = required("add-display-dialog"); + expect(dialog.querySelector("select")).toBeNull(); + const shows = required("device-pairing-shows"); + expect(shows.getAttribute("role")).toBe("combobox"); + expect(shows.textContent).toBe("Nothing yet"); + await choose("device-pairing-shows", "Signage"); + expect(shows.textContent).toBe("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"); + await eventually(() => query("device-device-2") !== null); + expect(required("device-device-2-showing").textContent).toBe("Showing: Signage"); }); 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"); @@ -363,8 +439,66 @@ describe("setting up a new screen (ADR 0065)", () => { 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"); + // Without the module list the card neither guesses what it shows nor offers to change it. + expect(query("device-device-1-showing")).toBeNull(); + expect(query("device-change-device-1")).toBeNull(); + }); +}); + +describe("changing what a display shows (ADR 0065)", () => { + it("moves a display from nothing to a module, between modules, and back, as the same display", async () => { + const platform = await renderPage({ modules: [SIGNAGE, BULLETIN] }); + const puts = () => + platform.requests.filter((r) => r.method === "PUT").map((r) => [r.path, r.body]); + + // Nothing yet → Signage. + await click("device-change-device-1"); + const dialog = required("device-change-dialog"); + expect(dialog.textContent).toContain("Change what Foyer TV shows"); + expect(dialog.querySelector("select")).toBeNull(); + expect(required("device-change-shows").textContent).toBe("Nothing yet"); + // Nothing to save until something changes. + expect((required("device-change-save") as HTMLButtonElement).disabled).toBe(true); + await choose("device-change-shows", "Signage"); + await click("device-change-save"); + expect(query("device-change-dialog")).toBeNull(); + expect(required("device-device-1-showing").textContent).toBe("Showing: Signage"); + expect(required("device-change-success").textContent).toBe("Foyer TV now shows Signage."); + expect(document.activeElement).toBe(required("device-change-device-1")); + + // Signage → Bulletin. + await click("device-change-device-1"); + expect(required("device-change-shows").textContent).toBe("Signage"); + await choose("device-change-shows", "Bulletin"); + await click("device-change-save"); + expect(required("device-device-1-showing").textContent).toBe("Showing: Bulletin"); + + // Bulletin → nothing. + await click("device-change-device-1"); + await choose("device-change-shows", "Nothing yet"); + await click("device-change-save"); + expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet"); + expect(required("device-change-success").textContent).toContain("Foyer TV now shows nothing"); + + expect(puts()).toEqual([ + ["/api/v1/devices/device-1/display", { moduleId: "signage" }], + ["/api/v1/devices/device-1/display", { moduleId: "bulletin" }], + ["/api/v1/devices/device-1/display", { moduleId: null }], + ]); + // Always the one display: no new identity, and the same name. + expect(platform.devices.map((device) => device.id)).toEqual(["device-1"]); + expect(container.querySelectorAll('[data-testid^="device-device-"]').length).toBeGreaterThan(0); + expect(container.querySelectorAll('[data-testid="device-device-1"]')).toHaveLength(1); + }); + + it("closes on Cancel or Escape without changing anything", async () => { + const platform = await renderPage(); + await click("device-change-device-1"); + await choose("device-change-shows", "Signage"); + await click("device-change-cancel"); + expect(query("device-change-dialog")).toBeNull(); + expect(platform.requests.some((r) => r.method === "PUT")).toBe(false); + expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet"); }); }); @@ -389,15 +523,21 @@ describe("display revocation", () => { expect(confirm).not.toHaveBeenCalled(); }); - it("revokes only after the destructive action is confirmed", async () => { + it("removes a revoked display from the list at once, and says what happened", async () => { const platform = await renderPage(); await click("device-revoke-device-1"); await click("device-revoke-confirm"); + await act(async () => settle()); expect(platform.requests.filter((r) => r.path.endsWith("/revoke"))).toHaveLength(1); expect(container.querySelector('[role="dialog"]')).toBeNull(); - expect(required("device-device-1").textContent).toContain("Revoked"); - expect(query("device-revoke-device-1")).toBeNull(); + // Gone from the active list without a reload, though the server keeps it as history. + expect(query("device-device-1")).toBeNull(); + expect(platform.devices[0]?.revoked).toBe(true); + expect(required("device-list-empty")).not.toBeNull(); + expect(required("device-revoke-success").textContent).toContain("Foyer TV is revoked"); + // Its card is gone, so focus lands on what happened rather than on the page. + expect(document.activeElement).toBe(required("device-outcome")); }); });