Files
Ekklesia/surfaces/shell/src/browser/DevicePanel.tsx
T
ekklesiaandClaude Opus 5.5 85b1377fd3 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 <[email protected]>
2026-10-12 21:31:36 -05:00

247 lines
8.0 KiB
TypeScript

import { designToken } from "@ekklesia/design-tokens";
import { Button, Dialog, Field, TextInput } from "@ekklesia/ui";
import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react";
import {
approveDevicePairing,
type EkklesiaHostConfig,
type HostDeviceSummary,
listHostDevices,
lookupDevicePairing,
revokeHostDevice,
} from "../host-protocol.js";
/**
* The administrator's whole device surface: type the code from a screen, name it, approve it, and
* later take it away again.
*
* Deliberately small. Device identity belongs to the platform rather than to any module, so it has
* to live in the host — but this is infrastructure plumbing, not a fleet-management product, and
* there is no grouping, filtering, bulk action, targeting, health, or remote control here on
* purpose (ADR 0036). It renders only for an administrator who actually holds `core.devices.manage`:
* a viewer without it is told nothing about which devices exist.
*/
const panelStyles = {
meta: {
color: designToken("text.muted"),
fontSize: designToken("typography.size-xs"),
margin: 0,
},
row: {
alignItems: "center",
borderTop: `${designToken("border.width")} solid ${designToken("border.divider")}`,
display: "flex",
gap: designToken("spacing.sm"),
justifyContent: "space-between",
padding: `${designToken("spacing.sm")} 0`,
},
section: { display: "grid", gap: designToken("spacing.sm") },
title: {
color: designToken("text.secondary"),
fontSize: designToken("typography.size-xs"),
fontWeight: designToken("typography.weight-bold"),
letterSpacing: "0.05em",
margin: 0,
},
} as const;
type PairingStep =
| { readonly step: "idle" }
| { readonly step: "busy" }
| { readonly step: "found"; readonly moduleId: string; readonly permissionId: string }
| { readonly step: "error"; readonly message: string };
export interface DevicePanelProps {
readonly config: EkklesiaHostConfig;
}
export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
const { config } = props;
const [devices, setDevices] = useState<readonly HostDeviceSummary[] | undefined>(undefined);
const [permitted, setPermitted] = useState(true);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [pairing, setPairing] = useState<PairingStep>({ step: "idle" });
/** The device an administrator asked to revoke and has not yet confirmed. */
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
const [revoking, setRevoking] = useState(false);
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
const refresh = useCallback(async (): Promise<void> => {
try {
setDevices(await listHostDevices(config));
} catch {
// The panel simply does not render for someone who may not manage devices; it never explains
// what it would have shown.
setPermitted(false);
}
}, [config]);
useEffect(() => {
void refresh();
}, [refresh]);
if (!permitted) {
return undefined;
}
const lookUp = async (event: FormEvent): Promise<void> => {
event.preventDefault();
setPairing({ step: "busy" });
try {
const found = await lookupDevicePairing(config, code.trim());
setPairing({ moduleId: found.moduleId, permissionId: found.permissionId, step: "found" });
} catch {
// One message for expired, consumed, mistyped, and never-existed alike.
setPairing({ message: "That code is not waiting for approval.", step: "error" });
}
};
const approve = async (): Promise<void> => {
setPairing({ step: "busy" });
try {
await approveDevicePairing(config, code.trim(), name.trim());
setCode("");
setName("");
setPairing({ step: "idle" });
await refresh();
} catch {
setPairing({ message: "That code could not be approved.", step: "error" });
}
};
const revoke = async (deviceId: string): Promise<void> => {
setRevoking(true);
try {
await revokeHostDevice(config, deviceId);
} catch {
// Left to the next refresh rather than asserting a failure the operator cannot act on.
}
await refresh();
setRevoking(false);
setConfirming(undefined);
};
return (
<section data-testid="host-devices" style={panelStyles.section}>
<h2 style={panelStyles.title}>DISPLAY DEVICES</h2>
<form
onSubmit={(event) => void lookUp(event)}
style={{ display: "grid", gap: designToken("spacing.sm") }}
>
<Field label="Pairing code shown on the screen">
{(control) => (
<TextInput
{...control}
autoComplete="off"
data-testid="device-pairing-code-input"
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value)}
value={code}
/>
)}
</Field>
<Button data-testid="device-pairing-lookup" size="sm" type="submit">
Find display
</Button>
</form>
{pairing.step === "found" ? (
<div
data-testid="device-pairing-found"
style={{ display: "grid", gap: designToken("spacing.sm") }}
>
<p style={panelStyles.meta}>
A display is asking to show <strong>{pairing.moduleId}</strong>.
</p>
<Field label="Name this display">
{(control) => (
<TextInput
{...control}
autoComplete="off"
data-testid="device-pairing-name"
maxLength={128}
onChange={(event) => setName(event.target.value)}
placeholder="Foyer TV"
value={name}
/>
)}
</Field>
<Button
data-testid="device-pairing-approve"
disabled={name.trim().length === 0}
onClick={() => void approve()}
size="sm"
variant="primary"
>
Approve
</Button>
</div>
) : undefined}
{pairing.step === "error" ? (
<p data-testid="device-pairing-error" style={panelStyles.meta}>
{pairing.message}
</p>
) : undefined}
{devices !== undefined && devices.length === 0 ? (
<p data-testid="device-list-empty" style={panelStyles.meta}>
No displays are paired.
</p>
) : undefined}
{devices?.map((device) => (
<div data-testid={`device-${device.id}`} key={device.id} style={panelStyles.row}>
<span>
{device.name}
{device.revoked ? " (revoked)" : ""}
</span>
{device.revoked ? undefined : (
<Button
data-testid={`device-revoke-${device.id}`}
onClick={() => setConfirming(device)}
size="sm"
>
Revoke
</Button>
)}
</div>
))}
{/* 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. */}
<Dialog
actions={
<>
<Button
data-testid="device-revoke-cancel"
disabled={revoking}
onClick={dismissConfirmation}
>
Cancel
</Button>
<Button
data-testid="device-revoke-confirm"
disabled={revoking}
onClick={() => (confirming ? void revoke(confirming.id) : undefined)}
variant="danger"
>
Revoke display
</Button>
</>
}
onDismiss={dismissConfirmation}
open={confirming !== undefined}
title={`Revoke ${confirming?.name ?? "this display"}?`}
>
<p style={{ margin: 0 }}>
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.
</p>
</Dialog>
</section>
);
}