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]>
247 lines
8.0 KiB
TypeScript
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>
|
|
);
|
|
}
|