All checks were successful
React + Vite + TypeScript SPA: scaffold, operations-terminal design system, Bearer-token auth, and the Machines view. - Design system: OKLCH-tinted dark theme (ink-slate + signal-cyan), Hanken Grotesk + JetBrains Mono, status-color language (online/offline/granted/pending/denied/not_required), motion with prefers-reduced-motion honored. - Auth: token in sessionStorage via ref (never React state), protected routes, 401 session teardown, admin-gated per-agent-key UI. - Machines view: data table (sticky header, keyboard-activated rows, skeleton loading, actionable empty/error states), non-blocking detail drawer, delete confirm, admin key management with copy-once reveal. - UI primitives: Modal (focus trap + inert + portal + dialogStack), Drawer, Table, Badge/StatusDot, toast, states. - Typed API client normalizing the two error-envelope shapes. Passed Code Review (no blockers), impeccable critique-and-polish, and local gates (tsc/lint/build green). Dev-only Vite proxy to :3002. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
198 lines
6.1 KiB
TypeScript
198 lines
6.1 KiB
TypeScript
import { useState } from "react";
|
|
import { ApiError } from "../../api/client";
|
|
import type { KeyMetadata, Machine } from "../../api/types";
|
|
import { Button } from "../../components/ui/Button";
|
|
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
|
|
import { Modal } from "../../components/ui/Modal";
|
|
import { Badge } from "../../components/ui/Badge";
|
|
import { Spinner } from "../../components/ui/Spinner";
|
|
import { EmptyState, ErrorState } from "../../components/ui/States";
|
|
import { useToast } from "../../components/ui/toast-context";
|
|
import { absoluteTime, relativeTime } from "../../lib/time";
|
|
import {
|
|
useCreateMachineKey,
|
|
useMachineKeys,
|
|
useRevokeMachineKey,
|
|
} from "./hooks";
|
|
import { KeyRevealModal } from "./KeyRevealModal";
|
|
|
|
interface MachineKeysModalProps {
|
|
machine: Machine | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function keyState(k: KeyMetadata): { tone: "ok" | "neutral"; label: string } {
|
|
return k.revoked_at
|
|
? { tone: "neutral", label: "Revoked" }
|
|
: { tone: "ok", label: "Active" };
|
|
}
|
|
|
|
/**
|
|
* Admin-only per-agent key management. Lists key metadata (never the secret),
|
|
* mints new keys (revealed once via KeyRevealModal), and revokes existing keys.
|
|
*/
|
|
export function MachineKeysModal({ machine, onClose }: MachineKeysModalProps) {
|
|
const toast = useToast();
|
|
const agentId = machine?.agent_id ?? "";
|
|
|
|
const keysQuery = useMachineKeys(machine?.agent_id ?? null, machine != null);
|
|
const createKey = useCreateMachineKey(agentId);
|
|
const revokeKey = useRevokeMachineKey(agentId);
|
|
|
|
const [revealKey, setRevealKey] = useState<string | null>(null);
|
|
const [pendingRevoke, setPendingRevoke] = useState<KeyMetadata | null>(null);
|
|
|
|
function handleCreate() {
|
|
createKey.mutate(undefined, {
|
|
onSuccess: (created) => {
|
|
setRevealKey(created.key);
|
|
toast.success("Key created", "Copy it now — it is shown only once.");
|
|
},
|
|
onError: (err) => {
|
|
toast.error(
|
|
"Could not create key",
|
|
err instanceof ApiError ? err.message : "Unexpected error.",
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
function handleRevoke() {
|
|
if (!pendingRevoke) return;
|
|
const id = pendingRevoke.id;
|
|
revokeKey.mutate(id, {
|
|
onSuccess: () => {
|
|
toast.success("Key revoked");
|
|
setPendingRevoke(null);
|
|
},
|
|
onError: (err) => {
|
|
toast.error(
|
|
"Could not revoke key",
|
|
err instanceof ApiError ? err.message : "Unexpected error.",
|
|
);
|
|
setPendingRevoke(null);
|
|
},
|
|
});
|
|
}
|
|
|
|
const keys = keysQuery.data ?? [];
|
|
|
|
return (
|
|
<>
|
|
<Modal
|
|
open={machine != null && revealKey == null}
|
|
title={
|
|
<>
|
|
Agent keys ·{" "}
|
|
<span className="mono" style={{ fontWeight: 500 }}>
|
|
{machine?.hostname}
|
|
</span>
|
|
</>
|
|
}
|
|
ariaLabel={`Agent keys for ${machine?.hostname ?? "machine"}`}
|
|
onClose={onClose}
|
|
wide
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
Close
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={handleCreate}
|
|
loading={createKey.isPending}
|
|
>
|
|
Create key
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
{keysQuery.isLoading ? (
|
|
<div style={{ padding: "24px 0", display: "grid", placeItems: "center" }}>
|
|
<Spinner label="Loading keys" />
|
|
</div>
|
|
) : keysQuery.isError ? (
|
|
<ErrorState
|
|
title="Failed to load keys"
|
|
message={
|
|
keysQuery.error instanceof ApiError
|
|
? keysQuery.error.message
|
|
: "Unexpected error."
|
|
}
|
|
/>
|
|
) : keys.length === 0 ? (
|
|
<EmptyState
|
|
title="No keys issued"
|
|
message="This machine has no per-agent keys. Create one to enroll it as a persistent identity."
|
|
/>
|
|
) : (
|
|
<table className="minitable">
|
|
<thead>
|
|
<tr>
|
|
<th>State</th>
|
|
<th>Key ID</th>
|
|
<th>Created</th>
|
|
<th>Last used</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{keys.map((k) => {
|
|
const s = keyState(k);
|
|
return (
|
|
<tr key={k.id} className={k.revoked_at ? "key--revoked" : undefined}>
|
|
<td>
|
|
<Badge tone={s.tone} dot>
|
|
{s.label}
|
|
</Badge>
|
|
</td>
|
|
<td className="mono" title={k.id}>
|
|
{k.id}
|
|
</td>
|
|
<td className="mono" title={absoluteTime(k.created_at)}>
|
|
{relativeTime(k.created_at)}
|
|
</td>
|
|
<td className="mono" title={absoluteTime(k.last_used_at)}>
|
|
{k.last_used_at ? relativeTime(k.last_used_at) : "never"}
|
|
</td>
|
|
<td style={{ textAlign: "right" }}>
|
|
{!k.revoked_at && (
|
|
<Button
|
|
variant="danger"
|
|
size="sm"
|
|
onClick={() => setPendingRevoke(k)}
|
|
>
|
|
Revoke
|
|
</Button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</Modal>
|
|
|
|
<KeyRevealModal plaintextKey={revealKey} onClose={() => setRevealKey(null)} />
|
|
|
|
<ConfirmDialog
|
|
open={pendingRevoke != null}
|
|
title="Revoke agent key"
|
|
danger
|
|
busy={revokeKey.isPending}
|
|
confirmLabel="Revoke key"
|
|
body={
|
|
<span>
|
|
Revoking this key immediately blocks any agent authenticating with
|
|
it. This cannot be undone. Key{" "}
|
|
<code className="mono">{pendingRevoke?.id}</code>.
|
|
</span>
|
|
}
|
|
onConfirm={handleRevoke}
|
|
onCancel={() => setPendingRevoke(null)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|