From 85b1377fd3b10678e24462cb5ad8ee5c7b26c5fb Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:31:36 -0500 Subject: [PATCH] fix(devices): confirm before revoking a display Revoke acted on a single click, yet it strands the screen until someone re-pairs it in person. Explain the consequence in the shared in-product Dialog first (DESIGN 21.4), with an obvious Cancel. Co-Authored-By: Claude Opus 5.5 --- surfaces/shell/src/browser/DevicePanel.tsx | 44 ++++++++- surfaces/shell/tests/device-panel.test.tsx | 106 +++++++++++++++++++++ 2 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 surfaces/shell/tests/device-panel.test.tsx diff --git a/surfaces/shell/src/browser/DevicePanel.tsx b/surfaces/shell/src/browser/DevicePanel.tsx index 0e2a44d..15a9555 100644 --- a/surfaces/shell/src/browser/DevicePanel.tsx +++ b/surfaces/shell/src/browser/DevicePanel.tsx @@ -1,5 +1,5 @@ import { designToken } from "@ekklesia/design-tokens"; -import { Button, Field, TextInput } from "@ekklesia/ui"; +import { Button, Dialog, Field, TextInput } from "@ekklesia/ui"; import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react"; import { approveDevicePairing, @@ -61,6 +61,10 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined { const [code, setCode] = useState(""); const [name, setName] = useState(""); const [pairing, setPairing] = useState({ step: "idle" }); + /** The device an administrator asked to revoke and has not yet confirmed. */ + const [confirming, setConfirming] = useState(undefined); + const [revoking, setRevoking] = useState(false); + const dismissConfirmation = useCallback(() => setConfirming(undefined), []); const refresh = useCallback(async (): Promise => { try { @@ -106,13 +110,15 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined { }; const revoke = async (deviceId: string): Promise => { + setRevoking(true); try { await revokeHostDevice(config, deviceId); - await refresh(); } catch { // Left to the next refresh rather than asserting a failure the operator cannot act on. } await refresh(); + setRevoking(false); + setConfirming(undefined); }; return ( @@ -195,7 +201,7 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined { {device.revoked ? undefined : ( + + + } + onDismiss={dismissConfirmation} + open={confirming !== undefined} + title={`Revoke ${confirming?.name ?? "this display"}?`} + > +

+ The screen stops showing content within a few seconds and returns to its pairing code. + This cannot be undone: to use it again, pair it from the screen with a new code. +

+ ); } diff --git a/surfaces/shell/tests/device-panel.test.tsx b/surfaces/shell/tests/device-panel.test.tsx new file mode 100644 index 0000000..5eb1b74 --- /dev/null +++ b/surfaces/shell/tests/device-panel.test.tsx @@ -0,0 +1,106 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DevicePanel } from "../src/browser/DevicePanel.js"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +const apiBaseUrl = "https://ekklesia.example.test"; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); +} + +function server() { + let revoked = false; + const revokes: string[] = []; + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/v1/devices")) { + return json({ + devices: [{ id: "device-1", name: "Foyer TV", permissions: [], revoked }], + }); + } + if (url.endsWith("/api/v1/devices/device-1/revoke")) { + revoked = true; + revokes.push(url); + return new Response(null, { status: 204 }); + } + return json({ code: "NOT_FOUND" }, 404); + }) as unknown as typeof globalThis.fetch; + return { fetch, revokes }; +} + +function query(testId: string): HTMLElement | null { + return container.querySelector(`[data-testid="${testId}"]`); +} + +async function click(testId: string): Promise { + await act(async () => { + query(testId)?.click(); + for (let index = 0; index < 5; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }); +} + +/** + * Revoking a display strands that screen until someone re-pairs it in person, so the panel explains + * the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21). + */ +describe("display device revocation", () => { + it("asks in a product dialog before revoking, and does nothing on cancel", async () => { + const platform = server(); + const confirm = vi.fn(() => true); + Object.assign(window, { confirm }); + await act(async () => { + root.render(); + }); + + await click("device-revoke-device-1"); + const dialog = container.querySelector('[role="dialog"]'); + expect(dialog?.textContent).toContain("Revoke Foyer TV?"); + expect(dialog?.textContent).toContain("pairing code"); + expect(platform.revokes).toEqual([]); + + await click("device-revoke-cancel"); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(platform.revokes).toEqual([]); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("revokes only after the destructive action is confirmed", async () => { + const platform = server(); + await act(async () => { + root.render(); + }); + + await click("device-revoke-device-1"); + await click("device-revoke-confirm"); + + expect(platform.revokes).toHaveLength(1); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(query("device-device-1")?.textContent).toContain("(revoked)"); + }); +});