Files
hermes-agent/web/src/pages/PairingPage.tsx
T
Teknium b571ec298d feat(dashboard): full administration panel — MCP, pairing, webhooks, credentials, memory, gateway, ops (#36704)
* feat(dashboard): backend API for MCP, pairing, webhooks, credential pool, memory, gateway lifecycle

Adds REST endpoints so a remote admin can manage these without CLI access:
- MCP servers: list/add/remove/test (config.yaml parity with hermes mcp)
- Pairing: list/approve/revoke/clear-pending messaging codes
- Webhooks: list/subscribe/remove (hot-reloaded JSON store)
- Credential pool: list/add/remove rotation keys (via CredentialPool API)
- Memory provider: status/select/disable/reset
- Gateway lifecycle: start/stop (restart+update already existed)

Secrets redacted on read; usable values only reach the agent at session start.
All endpoints sit behind the existing dashboard auth gate.

* feat(dashboard): backend API for ops + skills hub

- Ops actions (spawned, log-tailed via /api/actions): doctor, security audit,
  backup, import, checkpoints prune
- Ops reads (structured JSON): hooks list + allowlist status, checkpoints list
  with per-session size
- Skills hub actions (spawned): install / uninstall / update
- Registers new action log files for all spawn-based endpoints

All gated by the existing dashboard auth middleware.

* feat(dashboard): admin pages for MCP, pairing, webhooks, and system ops

Adds four new dashboard pages + nav entries so a remote admin can manage
Hermes without CLI access:
- MCP: list/add/remove/test MCP servers
- Webhooks: list/create/delete subscriptions (one-time secret reveal)
- Pairing: approve/revoke/clear messaging pairing codes
- System: gateway start/stop/restart, memory provider + reset, credential
  pool add/remove, ops (doctor/audit/backup/import/skills update) with a
  live action-log viewer, checkpoints prune, shell-hooks status

api.ts: client methods + types for all new endpoints.
App.tsx: routes + sidebar nav (plain labels, no i18n key required).

Verified: tsc -b clean, production build succeeds, new pages lint clean,
zero new eslint errors in App.tsx.

* test(dashboard): cover admin API endpoints

20 tests across MCP, credential pool, memory, pairing, webhooks, ops, plus
an auth-gate parametrize that asserts every admin endpoint requires the
session token. Asserts request contract + CLI-config parity, not catalog
values (per the no-change-detector-tests rule).

* docs(dashboard): document MCP, Webhooks, Pairing, and System admin pages

Adds Pages sections for the four new admin tabs and an Admin-endpoints table
to the REST API reference. Updates the page description to reflect the
dashboard's expanded role as a full administration panel.
2026-06-01 02:58:02 -07:00

277 lines
8.8 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Check, ShieldCheck, Trash2, Users, X } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { PairingResponse, PairingUser } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { usePageHeader } from "@/contexts/usePageHeader";
function getUserKey(user: PairingUser): string {
return `${user.platform}:${user.user_id}`;
}
function splitUserKey(key: string): { platform: string; user_id: string } {
const idx = key.indexOf(":");
if (idx === -1) return { platform: "", user_id: key };
return { platform: key.slice(0, idx), user_id: key.slice(idx + 1) };
}
function getUserLabel(user: PairingUser): string {
return user.user_name || user.user_id;
}
export default function PairingPage() {
const [pending, setPending] = useState<PairingUser[]>([]);
const [approved, setApproved] = useState<PairingUser[]>([]);
const [loading, setLoading] = useState(true);
const [approving, setApproving] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const { toast, showToast } = useToast();
const { setEnd } = usePageHeader();
const loadPairing = useCallback(() => {
api
.getPairing()
.then((res: PairingResponse) => {
setPending(res.pending);
setApproved(res.approved);
})
.catch(() => showToast("Failed to load pairing requests", "error"))
.finally(() => setLoading(false));
}, [showToast]);
useEffect(() => {
loadPairing();
}, [loadPairing]);
const handleApprove = async (user: PairingUser) => {
if (!user.code) {
showToast("Missing pairing code", "error");
return;
}
const key = getUserKey(user);
setApproving(key);
try {
await api.approvePairing(user.platform, user.code);
showToast(`Approved: "${getUserLabel(user)}"`, "success");
loadPairing();
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setApproving(null);
}
};
const handleClearPending = async () => {
if (!window.confirm("Clear all pending pairing requests?")) return;
setClearing(true);
try {
const res = await api.clearPendingPairing();
showToast(`Cleared ${res.cleared} pending request(s)`, "success");
loadPairing();
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setClearing(false);
}
};
const userRevoke = useConfirmDelete({
onDelete: useCallback(
async (key: string) => {
const { platform, user_id } = splitUserKey(key);
const user = approved.find((u) => getUserKey(u) === key);
try {
await api.revokePairing(platform, user_id);
showToast(
`Revoked: "${user ? getUserLabel(user) : user_id}"`,
"success",
);
loadPairing();
} catch (e) {
showToast(`Error: ${e}`, "error");
throw e;
}
},
[approved, loadPairing, showToast],
),
});
// Put "Clear pending" button in page header
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
onClick={handleClearPending}
disabled={clearing}
prefix={clearing ? <Spinner /> : <Trash2 className="h-4 w-4" />}
>
Clear pending
</Button>,
);
return () => {
setEnd(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEnd, clearing]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const pendingRevokeUser = userRevoke.pendingId
? approved.find((u) => getUserKey(u) === userRevoke.pendingId)
: null;
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<DeleteConfirmDialog
open={userRevoke.isOpen}
onCancel={userRevoke.cancel}
onConfirm={userRevoke.confirm}
title="Revoke access"
description={
pendingRevokeUser
? `"${getUserLabel(pendingRevokeUser)}" will lose access. This cannot be undone.`
: "This user will lose access. This cannot be undone."
}
confirmLabel="Revoke"
loading={userRevoke.isDeleting}
/>
{/* Pending requests */}
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Users className="h-4 w-4" />
Pending requests ({pending.length})
</H2>
{pending.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No pending pairing requests
</CardContent>
</Card>
)}
{pending.map((user) => {
const key = getUserKey(user);
return (
<Card key={key}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge tone="outline">{user.platform}</Badge>
{user.code && (
<span className="font-mono text-sm">{user.code}</span>
)}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="truncate">{user.user_id}</span>
{user.user_name && (
<span className="truncate">{user.user_name}</span>
)}
{typeof user.age_minutes === "number" && (
<span>{user.age_minutes}m ago</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
className="uppercase"
onClick={() => handleApprove(user)}
disabled={approving === key || !user.code}
prefix={
approving === key ? (
<Spinner />
) : (
<Check className="h-4 w-4" />
)
}
>
Approve
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Approved users */}
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<ShieldCheck className="h-4 w-4" />
Approved users ({approved.length})
</H2>
{approved.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No approved users
</CardContent>
</Card>
)}
{approved.map((user) => {
const key = getUserKey(user);
return (
<Card key={key}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge tone="outline">{user.platform}</Badge>
<span className="font-medium text-sm truncate">
{user.user_id}
</span>
</div>
{user.user_name && (
<div className="text-xs text-muted-foreground truncate">
{user.user_name}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="icon"
title="Revoke"
aria-label="Revoke"
className="text-destructive"
onClick={() => userRevoke.requestDelete(key)}
>
<X />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}