Manage paired displays: themed select, reassignment, live list, no revoked clutter
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s
Real-device review of the platform display flow found four defects in display management: - Shared SelectMenu in @ekklesia/ui (additive): an Ekklesia-drawn single choice following the WAI-ARIA select-only combobox pattern, replacing the native select for "Show on this screen". It has a themed list, a chevron inside the control, keyboard support (arrows, Home/End, type-ahead, Enter/Space/Tab, and Escape that closes only the list), pointer and touch support, and focus that stays on the control. It is an ek-control, so it keeps the 16px phone size. - Newly paired displays appear without a reload. Approval doesn't create the display; the screen does when it claims its credential a few seconds later. The page now shows a "Waiting for the screen to connect" placeholder and refetches every second until the new display appears (for up to a minute), and refreshes the list every 30 seconds. - What a display shows can change after pairing. In the single-target Stage 1 model, a display's current target is its one active display permission, so no migration is needed. PUT /api/v1/devices/:id/display, with core.devices.manage, a trusted origin and a bounded body, revokes its other active permission rows (kept as history) and grants the chosen module's declared display permission, in one transaction on the same device. The screen notices within about 30 seconds: a paired device on a module page that is no longer set to show it goes to /display/setup instead of re-pairing, and /display/setup opens the new target. - Revoked displays leave the normal list at once. Their rows stay in the database as history. Each display card now shows its name, Online/Offline/Not connected yet, "Showing: ...", last seen and paired dates, and Change and Revoke. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
300d9eab51
commit
d9dafcb0f7
@@ -432,6 +432,56 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Changes what a paired display shows (ADR 0065): `{ "moduleId": "<id>" }` 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();
|
||||
|
||||
Reference in New Issue
Block a user