Files
nexus-mcp/src/components/SecurityGatewayTab.tsx

260 lines
11 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { ShieldCheck, Key, Lock, Unlock, Plus, Trash2, Copy, Check, AlertCircle, RefreshCw, Layers } from 'lucide-react';
interface AgentKey {
id: string;
name: string;
key: string;
scope: 'full' | 'read_only' | 'restricted';
rateLimitPerMin: number;
requestCount: number;
lastUsedAt?: string;
createdAt: string;
}
export const SecurityGatewayTab: React.FC = () => {
const [authRequired, setAuthRequired] = useState(false);
const [keys, setKeys] = useState<AgentKey[]>([]);
const [loading, setLoading] = useState(true);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyScope, setNewKeyScope] = useState<'full' | 'read_only'>('full');
const [newRateLimit, setNewRateLimit] = useState(120);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const fetchSecurityState = async () => {
setLoading(true);
try {
const res = await fetch('/api/v1/mcp/security/keys');
if (res.ok) {
const data = await res.json();
setAuthRequired(data.authRequired);
setKeys(data.keys || []);
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSecurityState();
}, []);
const handleToggleAuth = async () => {
try {
const res = await fetch('/api/v1/mcp/security/toggle-auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ required: !authRequired }),
});
if (res.ok) {
const data = await res.json();
setAuthRequired(data.authRequired);
}
} catch (e) {
console.error(e);
}
};
const handleCreateKey = async (e: React.FormEvent) => {
e.preventDefault();
if (!newKeyName.trim()) return;
try {
const res = await fetch('/api/v1/mcp/security/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newKeyName,
scope: newKeyScope,
rateLimitPerMin: newRateLimit,
}),
});
if (res.ok) {
setNewKeyName('');
fetchSecurityState();
}
} catch (e) {
console.error(e);
}
};
const handleRevokeKey = async (id: string) => {
if (!confirm('Are you sure you want to revoke this Agent Access Key?')) return;
try {
await fetch(`/api/v1/mcp/security/keys/${id}`, { method: 'DELETE' });
fetchSecurityState();
} catch (e) {
console.error(e);
}
};
const copyKey = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
};
return (
<div className="space-y-6">
{/* Header Banner */}
<div className="bg-gradient-to-r from-slate-900 via-sky-950 to-slate-900 rounded-2xl p-6 sm:p-8 text-white shadow-xl relative overflow-hidden">
<div className="relative z-10 max-w-3xl">
<span className="inline-flex items-center space-x-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-sky-500/20 text-sky-300 border border-sky-500/30 mb-3">
<ShieldCheck className="h-3.5 w-3.5" />
<span>MCP Security Gateway</span>
</span>
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight">Agent Authentication & Security Gateway</h2>
<p className="mt-2 text-slate-300 text-sm leading-relaxed">
Manage Bearer tokens and access security policies for AI agents connecting to your self-hosted MCP Hub. Enforce rate limiting per agent and restrict permissions to protect sensitive internal tools.
</p>
</div>
</div>
{/* Auth Toggle Banner */}
<div className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center space-x-4">
<div className={`p-3 rounded-2xl ${authRequired ? 'bg-amber-100 text-amber-700' : 'bg-emerald-100 text-emerald-700'}`}>
{authRequired ? <Lock className="h-6 w-6" /> : <Unlock className="h-6 w-6" />}
</div>
<div>
<div className="flex items-center space-x-2">
<h3 className="text-base font-bold text-slate-900">Security Enforcement Mode</h3>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-semibold ${authRequired ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800'}`}>
{authRequired ? 'STRICT (Bearer Key Required)' : 'OPEN LOCAL ACCESS'}
</span>
</div>
<p className="text-xs text-slate-500 mt-1">
{authRequired
? 'All incoming requests to /mcp must supply a valid Authorization: Bearer <mcp_live_sec_key_...> header.'
: 'Local and internal requests to /mcp are accepted without token header check.'}
</p>
</div>
</div>
<button
onClick={handleToggleAuth}
className={`px-5 py-2.5 rounded-xl font-semibold text-xs transition-all shadow-md shrink-0 ${
authRequired
? 'bg-slate-200 text-slate-800 hover:bg-slate-300'
: 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-indigo-600/30'
}`}
>
{authRequired ? 'Disable Enforcement' : 'Enable Bearer Token Enforcement'}
</button>
</div>
{/* Keys Management */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Create Key Form */}
<div className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm space-y-4">
<h3 className="text-base font-bold text-slate-900 flex items-center space-x-2">
<Key className="h-5 w-5 text-indigo-600" />
<span>Issue Agent Key</span>
</h3>
<form onSubmit={handleCreateKey} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-700 mb-1">Agent Name / Identifier:</label>
<input
type="text"
required
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="e.g. Cursor IDE Prod Agent"
className="w-full rounded-lg border border-slate-300 p-2.5 text-xs text-slate-900 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-700 mb-1">Permission Scope:</label>
<select
value={newKeyScope}
onChange={(e: any) => setNewKeyScope(e.target.value)}
className="w-full rounded-lg border border-slate-300 p-2.5 text-xs text-slate-900 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="full">Full Access (Read & Execute All Tools)</option>
<option value="read_only">Read-Only (tools/list & prompts/list)</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-700 mb-1">Rate Limit (req/min):</label>
<input
type="number"
value={newRateLimit}
onChange={(e) => setNewRateLimit(Number(e.target.value))}
className="w-full rounded-lg border border-slate-300 p-2.5 text-xs text-slate-900 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
type="submit"
className="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-xs transition-all shadow-md shadow-indigo-600/20 flex items-center justify-center space-x-2"
>
<Plus className="h-4 w-4" />
<span>Generate Agent Key</span>
</button>
</form>
</div>
{/* Keys List */}
<div className="lg:col-span-2 bg-white rounded-2xl border border-slate-200 p-6 shadow-sm space-y-4">
<h3 className="text-base font-bold text-slate-900">Active Agent Access Keys</h3>
{loading ? (
<div className="p-8 text-center text-slate-400">
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2 text-indigo-600" />
<span className="text-xs">Loading keys...</span>
</div>
) : keys.length === 0 ? (
<div className="p-8 text-center text-slate-400 text-xs">No keys generated yet</div>
) : (
<div className="space-y-3">
{keys.map((k) => (
<div key={k.id} className="p-4 rounded-xl border border-slate-200 bg-slate-50 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<span className="font-bold text-slate-900 text-sm">{k.name}</span>
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-indigo-100 text-indigo-800">
{k.scope}
</span>
<span className="text-xs text-slate-500 font-mono">({k.rateLimitPerMin} req/m)</span>
</div>
<div className="flex items-center space-x-2">
<code className="text-xs font-mono bg-slate-900 text-emerald-400 px-2.5 py-1 rounded-md">
{k.key}
</code>
<button
onClick={() => copyKey(k.key)}
className="p-1 rounded hover:bg-slate-200 text-slate-600 transition-colors"
title="Copy Key"
>
{copiedKey === k.key ? <Check className="h-4 w-4 text-emerald-600" /> : <Copy className="h-4 w-4" />}
</button>
</div>
<div className="text-[10px] text-slate-500">
Requests handled: <strong className="text-slate-800">{k.requestCount}</strong> Last used: {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleTimeString() : 'Never'}
</div>
</div>
<button
onClick={() => handleRevokeKey(k.id)}
className="p-2 rounded-lg text-red-600 hover:bg-red-50 hover:text-red-700 transition-colors self-start sm:self-center"
title="Revoke Key"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
};