Settings fixes: - MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings table (DB > ENV > default), so dashboard/settings changes apply without restart. Sync cache refreshed on every save or delete. - /api/me reports the live DB value for max_sites_per_user. Admin improvements: - Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS). - Admin Overview now shows platform stats: registered users, new users (7d), total user sites, available tools. Plugin cleanup: - Appwrite and Directus plugins removed from the active registry (8 plugins now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Plugin code is retained for future re-enabling. - Settings page plugin visibility list updated to match. Mobile onboarding: - Stepper steps on narrow viewports stack vertically with correct full border and rounded corners on each step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
277 lines
16 KiB
HTML
277 lines
16 KiB
HTML
{% 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-3xl mx-auto space-y-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center gap-4 mb-6">
|
|
{# When the user arrives from /dashboard-v2, link the back arrow
|
|
there so they don't dump out into the legacy list. #}
|
|
{% set _qs = request.query_params if request else None %}
|
|
{% set _from_v2 = _qs and _qs.get('from') == 'dashboard-v2' %}
|
|
<a href="{% if _from_v2 %}/dashboard-v2/sites{% else %}/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}{% 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>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<!-- Section 1: Add Site / Connect Service -->
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
|
<button type="button" onclick="toggleSection('addsite')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
|
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
|
{% if lang == 'fa' %}اتصال سرویس{% else %}Connect Service{% endif %}
|
|
</h3>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{% if lang == 'fa' %}نوع پلاگین و اطلاعات اتصال{% else %}Plugin type and connection details{% endif %}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<svg id="addsite-chevron" class="w-5 h-5 text-gray-400 transition-transform rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</button>
|
|
<div id="addsite-section" class="border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
|
<form id="add-site-form" class="space-y-4">
|
|
<!-- 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 }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ 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">
|
|
<p id="url-hint" class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden"></p>
|
|
</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-3 pt-2">
|
|
<button type="submit" id="submit-btn"
|
|
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm font-medium disabled:opacity-50 transition-colors">
|
|
{{ 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 text-sm">
|
|
{{ t.cancel }}
|
|
</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<!-- Section 2: What happens next (info card) -->
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
|
<button type="button" onclick="toggleSection('info')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center">
|
|
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
</div>
|
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
|
{% if lang == 'fa' %}مراحل بعدی{% else %}What happens next{% endif %}
|
|
</h3>
|
|
</div>
|
|
<svg id="info-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</button>
|
|
<div id="info-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6">
|
|
<div class="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
|
<div class="flex items-start gap-3">
|
|
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">1</span>
|
|
<p>{% if lang == 'fa' %}سرویس خود را با انتخاب نوع پلاگین و وارد کردن URL و اعتبارنامه اضافه کنید.{% else %}Add your service by selecting the plugin type and entering the URL and credentials.{% endif %}</p>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">2</span>
|
|
<p>{% if lang == 'fa' %}سطح دسترسی ابزارها و تنظیمات اتصال را از صفحه مدیریت سایت پیکربندی کنید.{% else %}Configure tool access levels and connection settings from the site management page.{% endif %}</p>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">3</span>
|
|
<p>{% if lang == 'fa' %}یک کلید API بسازید و از قطعه کد پیکربندی برای اتصال MCP client خود استفاده کنید.{% else %}Create an API key and use the configuration snippet to connect your MCP client.{% endif %}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
const pluginFields = {{ plugin_fields_json| safe }};
|
|
|
|
const urlHints = {
|
|
'openpanel': 'OpenPanel API URL — Cloud: https://api.openpanel.dev | Self-hosted: your API service URL (e.g., https://analytics.example.com)',
|
|
'supabase': 'Your Supabase project URL (e.g., https://xxxxx.supabase.co)',
|
|
};
|
|
|
|
// ── Section toggle ──────────────────────────────────────────────
|
|
function toggleSection(name) {
|
|
const section = document.getElementById(name + '-section');
|
|
const chevron = document.getElementById(name + '-chevron');
|
|
section.classList.toggle('hidden');
|
|
chevron.classList.toggle('rotate-180');
|
|
}
|
|
|
|
function updateFields() {
|
|
const ptype = document.getElementById('plugin_type').value;
|
|
const container = document.getElementById('credential-fields');
|
|
const urlHintEl = document.getElementById('url-hint');
|
|
|
|
// Update URL hint
|
|
if (urlHints[ptype]) {
|
|
urlHintEl.textContent = urlHints[ptype];
|
|
urlHintEl.classList.remove('hidden');
|
|
} else {
|
|
urlHintEl.classList.add('hidden');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const mainFields = pluginFields[ptype].filter(f => !f.advanced);
|
|
const advFields = pluginFields[ptype].filter(f => f.advanced);
|
|
|
|
let html = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>';
|
|
|
|
function renderField(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>` : '';
|
|
return `
|
|
<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>`;
|
|
}
|
|
|
|
mainFields.forEach(field => { html += renderField(field); });
|
|
|
|
if (advFields.length > 0) {
|
|
html += `
|
|
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
|
|
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
|
|
Advanced Settings
|
|
</summary>
|
|
<div class="px-4 pb-4 pt-2">`;
|
|
advFields.forEach(field => { html += renderField(field); });
|
|
html += `</div></details>`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// Auto-fill credential fields if plugin_type is pre-selected
|
|
if (document.getElementById('plugin_type').value) {
|
|
updateFields();
|
|
}
|
|
|
|
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) {
|
|
// If the user came in from the v2 SPA shell, send them
|
|
// back there instead of the legacy Jinja list.
|
|
const fromV2 = new URLSearchParams(window.location.search).get('from') === 'dashboard-v2';
|
|
const siteId = data.site?.id || data.site_id;
|
|
if (fromV2) {
|
|
window.location.href = '/dashboard-v2/sites?msg={{ t.site_added }}';
|
|
} else if (siteId) {
|
|
window.location.href = '/dashboard/sites/' + siteId + '{% if lang and lang != "en" %}?lang={{ lang }}{% endif %}';
|
|
} else {
|
|
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 %}
|