- Bug A: Fix OAuth consent redirect loop (return_url path-relative)
- Bug B: Accept OAuth JWT tokens on /u/{user_id}/{alias}/mcp
- Bug C: Add user OAuth client CRUD (list/create/delete) + new page
- D-1: Sidebar border via Tailwind class, version footer in sidebar
- D-2: Remove broken setInterval targeting #stats-container
- D-3: 404.html purple primary colors + system dark mode support
- D-4: Fix invisible buttons (bg-gray-200 + text-white in light mode)
- D-5: Pin Alpine.js to 3.14.8, remove duplicate CSRF meta tag
- D-6: Fix invisible lang toggle button in settings (dark mode)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
276 lines
14 KiB
HTML
276 lines
14 KiB
HTML
{% extends "dashboard/base.html" %}
|
|
|
|
{% block title %}{{ t.connect }} - MCP Hub{% endblock %}
|
|
{% block page_title %}{{ t.connect }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="space-y-8">
|
|
|
|
<!-- API Keys Section -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.api_keys }}</h3>
|
|
<button onclick="createKey()" id="create-key-btn"
|
|
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
|
|
+ {{ t.generate_key }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- New key alert -->
|
|
{% if new_key %}
|
|
<div class="bg-yellow-50 dark:bg-yellow-500/20 border border-yellow-200 dark:border-yellow-500/50 text-yellow-800 dark:text-yellow-300 px-4 py-3 rounded-lg mb-4">
|
|
<p class="font-medium mb-1">{{ t.your_api_key }}</p>
|
|
<div class="flex items-center gap-2">
|
|
<code class="bg-gray-100 dark:bg-gray-900 px-3 py-1 rounded text-sm flex-1 overflow-x-auto text-gray-800 dark:text-gray-200">{{ new_key }}</code>
|
|
<button onclick="copyText('{{ new_key }}')" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t['copy'] }}</button>
|
|
</div>
|
|
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- New Key Modal (stays open until user clicks Done) -->
|
|
<div id="new-key-display" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
|
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
|
<svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
{{ t.your_api_key }}
|
|
</h3>
|
|
</div>
|
|
<div class="p-6 space-y-4">
|
|
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/30 rounded-lg p-4">
|
|
<p class="text-sm text-yellow-700 dark:text-yellow-400">{{ t.key_shown_once }}</p>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">API Key</label>
|
|
<div class="flex items-center gap-2">
|
|
<code id="new-key-value" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
|
|
<button onclick="copyNewKey()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">{{ t['copy'] }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
|
<button onclick="closeNewKeyModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
|
|
{% if lang == 'fa' %}تأیید{% else %}Done{% endif %}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if api_keys %}
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full">
|
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
|
<tr>
|
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.api_key_name }}</th>
|
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Prefix</th>
|
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">
|
|
{% if lang == 'fa' %}دسترسی{% else %}Access{% endif %}
|
|
</th>
|
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Uses</th>
|
|
<th class="px-4 py-3 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 key in api_keys %}
|
|
<tr id="key-{{ key.id }}">
|
|
<td class="px-4 py-3 text-gray-900 dark:text-white">{{ key.name }}</td>
|
|
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 font-mono text-sm">mhu_{{ key.key_prefix }}...</td>
|
|
<td class="px-4 py-3">
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400">
|
|
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-sm">{{ key.use_count }}</td>
|
|
<td class="px-4 py-3">
|
|
<button onclick="deleteKey('{{ key.id }}')" class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">{{ t.delete }}</button>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_api_keys }}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Config Snippets Section -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t.config_snippets }}</h3>
|
|
|
|
{% if sites %}
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.select_site }}</label>
|
|
<select id="config-site" onchange="updateConfig()"
|
|
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">
|
|
{% for site in sites %}
|
|
<option value="{{ site.alias }}">{{ site.alias }} ({{ site.plugin_type }})</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.select_client }}</label>
|
|
<select id="config-client" onchange="updateConfig()"
|
|
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">
|
|
{% for client in clients %}
|
|
<option value="{{ client.id }}">{{ client.label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="relative">
|
|
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[120px]">Select a site and client to generate configuration...</pre>
|
|
<button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else 'right-2' }} text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors" id="copy-config-btn">{{ t['copy'] }}</button>
|
|
</div>
|
|
{% else %}
|
|
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_sites }}. <a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a></p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Claude.ai Connection Guide -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
{% if lang == 'fa' %}اتصال به Claude.ai{% else %}Connect to Claude.ai{% endif %}
|
|
</h3>
|
|
<ol class="space-y-3 text-sm text-gray-700 dark:text-gray-300 {% if lang == 'fa' %}list-decimal list-inside{% else %}list-decimal list-inside{% endif %}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
|
<li>
|
|
{% if lang == 'fa' %}
|
|
در صفحه <strong>OAuth Clients</strong> یک کلاینت جدید بسازید
|
|
{% else %}
|
|
Create an OAuth Client on the <strong>OAuth Clients</strong> page
|
|
{% endif %}
|
|
</li>
|
|
<li>
|
|
{% if lang == 'fa' %}
|
|
در Claude.ai → Settings → Connectors → Add → Custom
|
|
{% else %}
|
|
Go to Claude.ai → Settings → Connectors → Add → Custom
|
|
{% endif %}
|
|
</li>
|
|
<li>
|
|
{% if lang == 'fa' %}
|
|
آدرس MCP Endpoint خود را وارد کنید:
|
|
{% else %}
|
|
Enter your MCP Endpoint URL:
|
|
{% endif %}
|
|
{% if sites %}
|
|
<code class="block mt-1 bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">
|
|
{{ public_url }}/u/{{ session.user_id }}/{{ sites[0].alias }}/mcp
|
|
</code>
|
|
{% else %}
|
|
<code class="block mt-1 bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">
|
|
{{ public_url }}/u/{{ session.user_id }}/YOUR-ALIAS/mcp
|
|
</code>
|
|
{% endif %}
|
|
</li>
|
|
<li>
|
|
{% if lang == 'fa' %}Client ID و Client Secret را از OAuth Clients وارد کنید{% else %}Enter Client ID and Client Secret from your OAuth Client{% endif %}
|
|
</li>
|
|
<li>
|
|
{% if lang == 'fa' %}با GitHub یا Google وارد شوید{% else %}Log in with GitHub or Google when prompted{% endif %}
|
|
</li>
|
|
</ol>
|
|
<a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
|
class="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium transition-colors">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
|
</svg>
|
|
{% if lang == 'fa' %}مدیریت OAuth Clients{% else %}Manage OAuth Clients{% endif %}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
async function createKey() {
|
|
const name = prompt('Key name (e.g., "Claude Desktop"):');
|
|
if (!name) return;
|
|
|
|
const btn = document.getElementById('create-key-btn');
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
const resp = await fetch('/api/keys', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: name }),
|
|
});
|
|
const data = await resp.json();
|
|
if (resp.ok && data.key) {
|
|
document.getElementById('new-key-value').textContent = data.key.key;
|
|
document.getElementById('new-key-display').classList.remove('hidden');
|
|
document.getElementById('new-key-display').classList.add('flex');
|
|
// Modal stays open — user clicks "Done" to dismiss
|
|
} else {
|
|
alert(data.error || 'Failed to create key');
|
|
}
|
|
} catch (e) {
|
|
alert('Error: ' + e.message);
|
|
}
|
|
btn.disabled = false;
|
|
}
|
|
|
|
async function deleteKey(keyId) {
|
|
if (!confirm('Delete this API key?')) return;
|
|
try {
|
|
const resp = await fetch('/api/keys/' + keyId, { method: 'DELETE' });
|
|
if (resp.ok) {
|
|
document.getElementById('key-' + keyId).remove();
|
|
}
|
|
} catch (e) {
|
|
alert('Failed to delete key');
|
|
}
|
|
}
|
|
|
|
async function updateConfig() {
|
|
const alias = document.getElementById('config-site')?.value;
|
|
const client = document.getElementById('config-client')?.value;
|
|
if (!alias || !client) return;
|
|
|
|
try {
|
|
const resp = await fetch('/api/config/' + alias + '?client=' + client);
|
|
const data = await resp.json();
|
|
if (resp.ok) {
|
|
document.getElementById('config-output').textContent = data.config;
|
|
}
|
|
} catch (e) {
|
|
document.getElementById('config-output').textContent = 'Error loading config';
|
|
}
|
|
}
|
|
|
|
function copyText(text) {
|
|
navigator.clipboard.writeText(text);
|
|
}
|
|
|
|
function copyNewKey() {
|
|
const key = document.getElementById('new-key-value').textContent;
|
|
navigator.clipboard.writeText(key);
|
|
}
|
|
|
|
function closeNewKeyModal() {
|
|
document.getElementById('new-key-display').classList.add('hidden');
|
|
document.getElementById('new-key-display').classList.remove('flex');
|
|
location.reload();
|
|
}
|
|
|
|
function copyConfig() {
|
|
const text = document.getElementById('config-output').textContent;
|
|
navigator.clipboard.writeText(text);
|
|
const btn = document.getElementById('copy-config-btn');
|
|
btn.textContent = '{{ t.copied }}';
|
|
setTimeout(() => btn.textContent = '{{ t['copy'] }}', 2000);
|
|
}
|
|
|
|
// Load config on page load
|
|
{% if sites %}
|
|
document.addEventListener('DOMContentLoaded', updateConfig);
|
|
{% endif %}
|
|
</script>
|
|
{% endblock %}
|