Add mcp-gateway/gateway-proxy/user_dashboard_ui.py
This commit is contained in:
parent
be1da764a1
commit
4dd85799ea
1 changed files with 402 additions and 0 deletions
402
mcp-gateway/gateway-proxy/user_dashboard_ui.py
Normal file
402
mcp-gateway/gateway-proxy/user_dashboard_ui.py
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
"""
|
||||
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">■ 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 • {{ 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"> • 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)
|
||||
Loading…
Reference in a new issue