260 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|