feat(dashboard): GuruConnect v2 operator console (pass 1)
All checks were successful
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>
This commit is contained in:
197
dashboard/src/features/machines/MachineKeysModal.tsx
Normal file
197
dashboard/src/features/machines/MachineKeysModal.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user