Files
guru-connect/dashboard/src/features/machines/MachineKeysModal.tsx
Mike Swanson 43a9432b81
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m56s
Build and Test / Build Server (Linux) (push) Successful in 10m15s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 10s
feat(dashboard): GuruConnect v2 operator console (pass 1)
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>
2026-05-30 12:51:11 -07:00

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)}
/>
</>
);
}