mcp-servers/gateway-proxy/user_dashboard_ui.py

403 lines
20 KiB
Python
Raw Normal View History

2026-03-31 15:33:40 -04:00
"""
Enhanced Dashboard with User Management UI
===========================================
Provides a comprehensive web interface for managing users and API keys.
Vue 3 compatible.
"""
from starlette.responses import HTMLResponse
USER_DASHBOARD_HTML = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Gateway Admin</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<style>
[v-cloak] { display: none; }
</style>
</head>
<body class="bg-slate-900 text-slate-100">
<div id="app" v-cloak class="min-h-screen">
<!-- Nav -->
<nav class="bg-slate-800 border-b border-slate-700 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 py-3 flex justify-between items-center">
<h1 class="text-xl font-bold text-blue-400">&#9632; MCP Gateway Admin</h1>
<div class="flex gap-2">
<button @click="activeTab='dashboard'"
:class="activeTab==='dashboard' ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-300'"
class="px-4 py-2 rounded text-sm font-medium">Dashboard</button>
<button @click="activeTab='users'; loadUsers()"
:class="activeTab==='users' ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-300'"
class="px-4 py-2 rounded text-sm font-medium">Users</button>
<button @click="activeTab='docs'"
:class="activeTab==='docs' ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-300'"
class="px-4 py-2 rounded text-sm font-medium">API Docs</button>
</div>
</div>
</nav>
<!-- DASHBOARD TAB -->
<div v-if="activeTab==='dashboard'" class="max-w-7xl mx-auto p-6">
<h2 class="text-2xl font-bold mb-6">Gateway Status</h2>
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-slate-800 rounded-lg p-5 border border-slate-700">
<div class="text-sm text-slate-400 mb-1">Healthy Services</div>
<div class="text-4xl font-bold text-blue-400">{{ servicesHealthy }}/{{ services.length }}</div>
</div>
<div class="bg-slate-800 rounded-lg p-5 border border-slate-700">
<div class="text-sm text-slate-400 mb-1">Total Tools Available</div>
<div class="text-4xl font-bold text-green-400">{{ totalTools }}</div>
</div>
</div>
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
<div class="p-4 border-b border-slate-700 flex justify-between items-center">
<h3 class="font-semibold text-lg">MCP Services</h3>
<button @click="loadDashboard()" class="text-xs bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded">Refresh</button>
</div>
<div v-if="services.length === 0" class="p-6 text-center text-slate-400">Loading services...</div>
<div v-else class="divide-y divide-slate-700">
<div v-for="svc in services" :key="svc.name" class="p-4 flex justify-between items-center">
<div>
<div class="font-semibold">{{ svc.name }}</div>
<div class="text-sm text-slate-400">{{ svc.toolCount }} tools &bull; {{ svc.responseTime }}ms</div>
</div>
<span :class="svc.status === 'healthy' ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'"
class="px-3 py-1 rounded text-xs font-semibold uppercase">{{ svc.status }}</span>
</div>
</div>
</div>
<div class="mt-4 text-xs text-slate-500 text-right">Auto-refreshes every 10s</div>
</div>
<!-- USERS TAB -->
<div v-if="activeTab==='users'" class="max-w-7xl mx-auto p-6">
<h2 class="text-2xl font-bold mb-6">User Management</h2>
<!-- Create User -->
<div class="bg-slate-800 rounded-lg p-5 border border-slate-700 mb-6">
<h3 class="font-semibold mb-4">Create New User</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<input v-model="newUser.username" placeholder="Username *"
class="bg-slate-700 border border-slate-600 rounded px-3 py-2 text-white placeholder-slate-400 focus:outline-none focus:border-blue-500">
<input v-model="newUser.email" placeholder="Email" type="email"
class="bg-slate-700 border border-slate-600 rounded px-3 py-2 text-white placeholder-slate-400 focus:outline-none focus:border-blue-500">
<input v-model="newUser.description" placeholder="Description"
class="bg-slate-700 border border-slate-600 rounded px-3 py-2 text-white placeholder-slate-400 focus:outline-none focus:border-blue-500">
</div>
<button @click="createUser()" class="mt-3 bg-blue-600 hover:bg-blue-700 px-5 py-2 rounded font-medium text-sm">
+ Create User
</button>
<span v-if="createMsg" class="ml-3 text-sm" :class="createMsg.ok ? 'text-green-400' : 'text-red-400'">{{ createMsg.text }}</span>
</div>
<!-- Users List -->
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
<div class="p-4 border-b border-slate-700">
<h3 class="font-semibold">Users ({{ users.length }})</h3>
</div>
<div v-if="users.length === 0" class="p-8 text-center text-slate-400">
No users yet. Create one above.
</div>
<div v-else class="divide-y divide-slate-700">
<div v-for="user in users" :key="user.username" class="p-5">
<!-- User Header -->
<div class="flex justify-between items-center">
<div>
<span class="font-semibold text-lg">{{ user.username }}</span>
<span class="ml-2 text-sm text-slate-400">{{ user.email }}</span>
<div class="text-xs text-slate-500 mt-0.5">Created {{ formatDate(user.created_at) }}</div>
</div>
<div class="flex gap-2 items-center">
<span :class="user.enabled ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'"
class="px-2 py-0.5 rounded text-xs font-semibold cursor-pointer"
@click="toggleUser(user.username, !user.enabled)">
{{ user.enabled ? 'ENABLED' : 'DISABLED' }}
</span>
<button @click="toggleEdit(user.username)"
class="bg-blue-600 hover:bg-blue-700 px-3 py-1 rounded text-sm">
{{ editingUser === user.username ? 'Close' : 'Manage' }}
</button>
<button @click="deleteUser(user.username)"
class="bg-red-700 hover:bg-red-600 px-3 py-1 rounded text-sm">
Delete
</button>
</div>
</div>
<!-- Expanded Management Panel -->
<div v-if="editingUser === user.username" class="mt-4 bg-slate-900 rounded-lg p-4 border border-slate-600">
<!-- API Keys Section -->
<h4 class="font-semibold mb-3 text-blue-300">API Keys</h4>
<div v-if="!userKeys[user.username] || userKeys[user.username].length === 0"
class="text-slate-400 text-sm mb-3">No API keys yet.</div>
<div v-else class="mb-3 space-y-2">
<div v-for="key in userKeys[user.username]" :key="key.key_hash"
class="bg-slate-800 rounded p-3 flex justify-between items-center text-sm">
<div>
<div class="font-mono text-blue-400 text-xs">{{ key.key_name || 'Unnamed key' }}</div>
<div class="text-slate-400 text-xs mt-0.5">Created {{ formatDate(key.created_at) }}
<span v-if="key.expires_at"> &bull; Expires {{ formatDate(key.expires_at) }}</span>
</div>
</div>
<span v-if="key.revoked" class="text-red-400 text-xs font-semibold">REVOKED</span>
<button v-else @click="revokeKey(user.username, key.key_hash)"
class="bg-red-700 hover:bg-red-600 px-2 py-1 rounded text-xs">Revoke</button>
</div>
</div>
<button @click="generateKey(user.username)"
class="bg-green-700 hover:bg-green-600 px-4 py-2 rounded text-sm font-medium w-full mb-5">
+ Generate New API Key
</button>
<!-- MCP Access Control -->
<div class="border-t border-slate-700 pt-4">
<h4 class="font-semibold mb-3 text-blue-300">MCP Access Control</h4>
<p class="text-xs text-slate-400 mb-3">Leave both empty = access to all MCPs. Toggle to explicitly allow or block.</p>
<div class="mb-3">
<div class="text-sm font-medium text-slate-300 mb-2">Allowed MCPs
<span class="text-xs text-slate-500 ml-1">(only these are accessible)</span>
</div>
<div class="flex flex-wrap gap-2">
<button v-for="mcp in availableMcps" :key="'a-'+mcp"
@click="toggleMcpAccess(user, mcp, 'allowed')"
:class="user.mcp_allowed && user.mcp_allowed.includes(mcp)
? 'bg-blue-600 text-white'
: 'bg-slate-700 text-slate-400 hover:bg-slate-600'"
class="px-3 py-1 rounded text-sm transition-colors">
{{ mcp }}
</button>
</div>
</div>
<div>
<div class="text-sm font-medium text-slate-300 mb-2">Blocked MCPs
<span class="text-xs text-slate-500 ml-1">(these are always denied)</span>
</div>
<div class="flex flex-wrap gap-2">
<button v-for="mcp in availableMcps" :key="'b-'+mcp"
@click="toggleMcpAccess(user, mcp, 'blocked')"
:class="user.mcp_blocked && user.mcp_blocked.includes(mcp)
? 'bg-red-600 text-white'
: 'bg-slate-700 text-slate-400 hover:bg-slate-600'"
class="px-3 py-1 rounded text-sm transition-colors">
{{ mcp }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- API DOCS TAB -->
<div v-if="activeTab==='docs'" class="max-w-7xl mx-auto p-6">
<h2 class="text-2xl font-bold mb-6">API Documentation</h2>
<div class="space-y-6">
<div class="bg-slate-800 rounded-lg p-5 border border-slate-700">
<h3 class="font-semibold mb-2">Create User</h3>
<pre class="bg-slate-900 rounded p-3 text-sm overflow-x-auto text-green-300">POST /users
{ "username": "alice", "email": "alice@example.com", "description": "Engineering" }</pre>
</div>
<div class="bg-slate-800 rounded-lg p-5 border border-slate-700">
<h3 class="font-semibold mb-2">Generate API Key</h3>
<pre class="bg-slate-900 rounded p-3 text-sm overflow-x-auto text-green-300">POST /users/{username}/keys
{ "key_name": "my-key", "ttl_days": 90 }
=> { "api_key": "mcpgw_..." } save immediately, shown once</pre>
</div>
<div class="bg-slate-800 rounded-lg p-5 border border-slate-700">
<h3 class="font-semibold mb-2">Set MCP Access</h3>
<pre class="bg-slate-900 rounded p-3 text-sm overflow-x-auto text-green-300">PUT /users/{username}/mcp-access
{ "allowed_mcps": ["erpnext", "wave"], "blocked_mcps": [] }</pre>
</div>
<div class="bg-slate-800 rounded-lg p-5 border border-slate-700">
<h3 class="font-semibold mb-2">Using an API Key with Claude</h3>
<pre class="bg-slate-900 rounded p-3 text-sm overflow-x-auto text-green-300">MCP Server URL: https://mcp.wilddragon.net/mcp
Authorization: Bearer mcpgw_...</pre>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp, reactive, ref, computed, onMounted } = Vue;
createApp({
setup() {
const activeTab = ref('dashboard');
const services = ref([]);
const users = ref([]);
const userKeys = reactive({});
const editingUser = ref(null);
const newUser = reactive({ username: '', email: '', description: '' });
const createMsg = ref(null);
const availableMcps = ['erpnext', 'truenas', 'homeassistant', 'wave', 'linkedin'];
const servicesHealthy = computed(() => services.value.filter(s => s.status === 'healthy').length);
const totalTools = computed(() => services.value.reduce((sum, s) => sum + (s.toolCount || 0), 0));
async function loadDashboard() {
try {
const res = await axios.get('/dashboard/status');
services.value = res.data.services || [];
} catch(e) {
console.error('Dashboard load error:', e);
}
}
async function loadUsers() {
try {
const res = await axios.get('/users');
users.value = res.data.users || [];
for (const user of users.value) {
await loadUserKeys(user.username);
}
} catch(e) {
console.error('Users load error:', e);
}
}
async function loadUserKeys(username) {
try {
const res = await axios.get(`/users/${username}`);
userKeys[username] = res.data.api_keys || [];
} catch(e) {
console.error(`Keys load error for ${username}:`, e);
}
}
async function createUser() {
if (!newUser.username) {
createMsg.value = { ok: false, text: 'Username is required.' };
return;
}
try {
await axios.post('/users', { ...newUser });
createMsg.value = { ok: true, text: `User "${newUser.username}" created!` };
newUser.username = ''; newUser.email = ''; newUser.description = '';
await loadUsers();
setTimeout(() => createMsg.value = null, 4000);
} catch(e) {
createMsg.value = { ok: false, text: e.response?.data?.error || e.message };
}
}
async function deleteUser(username) {
if (!confirm(`Delete user "${username}"? This also revokes all their API keys.`)) return;
try {
await axios.delete(`/users/${username}`);
if (editingUser.value === username) editingUser.value = null;
await loadUsers();
} catch(e) {
alert('Error: ' + (e.response?.data?.error || e.message));
}
}
async function toggleUser(username, enabled) {
try {
await axios.patch(`/users/${username}/enable`, { enabled });
await loadUsers();
} catch(e) {
alert('Error: ' + (e.response?.data?.error || e.message));
}
}
function toggleEdit(username) {
editingUser.value = editingUser.value === username ? null : username;
if (editingUser.value) loadUserKeys(username);
}
async function generateKey(username) {
const keyName = prompt('Key name (optional):') || '';
try {
const res = await axios.post(`/users/${username}/keys`, { key_name: keyName });
alert('Your new API Key:\\n\\n' + res.data.api_key + '\\n\\nSave this immediately — it will NOT be shown again!');
await loadUserKeys(username);
} catch(e) {
alert('Error: ' + (e.response?.data?.error || e.message));
}
}
async function revokeKey(username, keyHash) {
if (!confirm('Revoke this API key? This cannot be undone.')) return;
try {
await axios.post('/keys/revoke', { api_key: keyHash });
await loadUserKeys(username);
} catch(e) {
alert('Error: ' + (e.response?.data?.error || e.message));
}
}
async function toggleMcpAccess(user, mcp, type) {
const allowed = [...(user.mcp_allowed || [])];
const blocked = [...(user.mcp_blocked || [])];
if (type === 'allowed') {
const idx = allowed.indexOf(mcp);
if (idx >= 0) allowed.splice(idx, 1);
else {
allowed.push(mcp);
const bi = blocked.indexOf(mcp);
if (bi >= 0) blocked.splice(bi, 1);
}
} else {
const idx = blocked.indexOf(mcp);
if (idx >= 0) blocked.splice(idx, 1);
else {
blocked.push(mcp);
const ai = allowed.indexOf(mcp);
if (ai >= 0) allowed.splice(ai, 1);
}
}
try {
await axios.put(`/users/${user.username}/mcp-access`, { allowed_mcps: allowed, blocked_mcps: blocked });
await loadUsers();
} catch(e) {
alert('Error: ' + (e.response?.data?.error || e.message));
}
}
function formatDate(d) {
if (!d) return '';
return new Date(d).toLocaleDateString();
}
onMounted(() => {
loadDashboard();
setInterval(loadDashboard, 10000);
});
return {
activeTab, services, users, userKeys, editingUser,
newUser, createMsg, availableMcps,
servicesHealthy, totalTools,
loadDashboard, loadUsers, createUser, deleteUser,
toggleUser, toggleEdit, generateKey, revokeKey,
toggleMcpAccess, formatDate
};
}
}).mount('#app');
</script>
</body>
</html>
"""
async def user_management_dashboard(request):
"""Serve the user management dashboard."""
return HTMLResponse(USER_DASHBOARD_HTML)