* 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.
277 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|