feat: v3.1.0 — Live Platform Foundation (Track E.1-E.3)
Major release introducing the Live Platform architecture:
- SQLite database backend with async operations and migrations (E.1)
- AES-256-GCM credential encryption with HKDF key derivation (E.1)
- OAuth Social Login with GitHub and Google (E.2)
- Site management API with encrypted credential storage (E.3)
- Per-user MCP endpoints at /u/{user_id}/{alias}/mcp (E.3)
- User API keys with bcrypt hashing (E.3)
- Auto-generated config snippets for 5 MCP clients (E.3)
- Dashboard: dark/light mode, RBAC, My Sites, Connect page
- Active background health checks
- 452 tests (up from 303), all passing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
151
core/templates/dashboard/sites/add.html
Normal file
151
core/templates/dashboard/sites/add.html
Normal file
@@ -0,0 +1,151 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.add_site }} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{{ t.add_site }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.add_site }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
|
||||
|
||||
<form id="add-site-form" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
|
||||
<!-- Plugin Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||
<select id="plugin_type" name="plugin_type" required
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
onchange="updateFields()">
|
||||
<option value="">{{ t.select_plugin }}</option>
|
||||
{% for ptype, pname in plugin_names.items() %}
|
||||
<option value="{{ ptype }}">{{ pname }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
|
||||
<input type="url" id="url" name="url" required placeholder="https://example.com"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
|
||||
<!-- Alias -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||
<input type="text" id="alias" name="alias" required placeholder="myblog" pattern="[a-zA-Z0-9_-]+"
|
||||
minlength="2" maxlength="50"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t.site_alias_hint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Credential Fields -->
|
||||
<div id="credential-fields" class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex items-center gap-4 pt-4">
|
||||
<button type="submit" id="submit-btn"
|
||||
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
|
||||
{{ t.add_site }}
|
||||
</button>
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
{{ t.cancel }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const pluginFields = {{ plugin_fields_json| safe }};
|
||||
|
||||
function updateFields() {
|
||||
const ptype = document.getElementById('plugin_type').value;
|
||||
const container = document.getElementById('credential-fields');
|
||||
|
||||
if (!ptype || !pluginFields[ptype]) {
|
||||
container.innerHTML = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label><p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>';
|
||||
pluginFields[ptype].forEach(field => {
|
||||
const req = field.required ? 'required' : '';
|
||||
const hintHtml = field.hint ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">${field.hint}</p>` : '';
|
||||
html += `
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">${field.label}${field.required ? ' *' : ''}</label>
|
||||
<input type="${field.type}" name="cred_${field.name}" ${req}
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="${field.label}">
|
||||
${hintHtml}
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
document.getElementById('add-site-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submit-btn');
|
||||
const errorEl = document.getElementById('error-msg');
|
||||
errorEl.classList.add('hidden');
|
||||
|
||||
btn.textContent = '{{ t.adding_site }}';
|
||||
btn.disabled = true;
|
||||
|
||||
const ptype = document.getElementById('plugin_type').value;
|
||||
const url = document.getElementById('url').value;
|
||||
const alias = document.getElementById('alias').value;
|
||||
|
||||
// Collect credentials
|
||||
const creds = {};
|
||||
if (pluginFields[ptype]) {
|
||||
pluginFields[ptype].forEach(field => {
|
||||
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||
if (input) creds[field.name] = input.value;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/sites', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
plugin_type: ptype,
|
||||
url: url,
|
||||
alias: alias,
|
||||
credentials: creds,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Failed to add site';
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.textContent = '{{ t.add_site }}';
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = 'Network error: ' + err.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.textContent = '{{ t.add_site }}';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
143
core/templates/dashboard/sites/list.html
Normal file
143
core/templates/dashboard/sites/list.html
Normal file
@@ -0,0 +1,143 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.my_sites }} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{{ t.my_sites }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Header with Add Site button -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.my_sites }}</h2>
|
||||
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium"
|
||||
title="{{ t.add_site }}">
|
||||
+ {{ t.add_site }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Flash messages -->
|
||||
{% if msg %}
|
||||
<div class="bg-green-50 dark:bg-green-500/20 border border-green-200 dark:border-green-500/50 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if sites %}
|
||||
<!-- Sites table -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.site_alias }}</th>
|
||||
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.plugin_type }}</th>
|
||||
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.site_url }}</th>
|
||||
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.status }}</th>
|
||||
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{% for site in sites %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors" id="site-{{ site.id }}">
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ site.alias }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300">
|
||||
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm max-w-xs truncate">{{ site.url }}</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if site.status == 'active' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">{{ t.active }}</span>
|
||||
{% elif site.status == 'error' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">{{ t.error }}</span>
|
||||
{% elif site.status == 'pending' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-300">Pending</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">{{ site.status }}</span>
|
||||
{% endif %}
|
||||
{% if site.status_msg %}
|
||||
<p class="text-xs text-gray-500 mt-1">{{ site.status_msg[:60] }}</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="testSite('{{ site.id }}')"
|
||||
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
|
||||
id="test-btn-{{ site.id }}">
|
||||
{{ t.test_connection }}
|
||||
</button>
|
||||
<button onclick="deleteSite('{{ site.id }}')"
|
||||
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
|
||||
{{ t.delete }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Empty state -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.no_sites }}</h3>
|
||||
<p class="text-gray-500 mb-6">{{ t.add_first_site }}</p>
|
||||
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="btn-primary px-6 py-2 rounded-lg text-white text-sm font-medium">
|
||||
+ {{ t.add_site }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function testSite(siteId) {
|
||||
const btn = document.getElementById('test-btn-' + siteId);
|
||||
btn.textContent = '{{ t.testing }}';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/sites/' + siteId + '/test', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
btn.textContent = '{{ t.connection_ok }}';
|
||||
btn.className = 'text-sm text-green-600 dark:text-green-400';
|
||||
} else {
|
||||
btn.textContent = data.message || '{{ t.connection_failed }}';
|
||||
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = '{{ t.error }}';
|
||||
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
setTimeout(() => {
|
||||
btn.textContent = '{{ t.test_connection }}';
|
||||
btn.className = 'text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300';
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function deleteSite(siteId) {
|
||||
if (!confirm('Are you sure you want to delete this site?')) return;
|
||||
try {
|
||||
const resp = await fetch('/api/sites/' + siteId, { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
document.getElementById('site-' + siteId).remove();
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Failed to delete site');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user