Replaces Bearer-token-only auth on GUI routes with a proper browser login flow. Visiting /admin or /dashboard now redirects to /gui-login if no valid session exists. Submitting the OAUTH_PASSWORD sets a secure httpOnly session cookie (8h TTL). /gui-logout clears it. - /dashboard/status also accepts session cookie (for the dashboard JS to call back without needing a separate token) - API routes (/users/*, /keys/*) still require Bearer token as before - /gui-login, /gui-logout added as new public routes
406 lines
20 KiB
Python
406 lines
20 KiB
Python
"""
|
|
Enhanced Dashboard with User Management UI
|
|
===========================================
|
|
Provides a comprehensive web interface for managing users and API keys.
|
|
Vue 3 compatible.
|
|
"""
|
|
|
|
from starlette.requests import Request
|
|
from starlette.responses import HTMLResponse, RedirectResponse
|
|
|
|
|
|
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: Request):
|
|
"""Serve the user management dashboard. Requires GUI session cookie."""
|
|
from gateway_proxy import _validate_gui_session
|
|
if not _validate_gui_session(request):
|
|
return RedirectResponse("/gui-login?next=/admin", status_code=303)
|
|
return HTMLResponse(USER_DASHBOARD_HTML)
|