release: v3.3.0 — platform hardening & admin unification (Track F.1–F.8)
Plugin visibility control, UI/UX fixes, unified admin panel, database-backed settings, and security hardening. Highlights: - ENABLED_PLUGINS env var for plugin visibility (F.1) - Admin designation via ADMIN_EMAILS (F.4a) - Master key scope control (F.4b) - Panel unification + settings from UI (F.4c) - exec() removal, shell injection fix, bcrypt migration (F.8) - 5 new test suites Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -173,7 +173,8 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<!-- Divider + Admin Login Link (hidden when DISABLE_MASTER_KEY_LOGIN=true) -->
|
||||
{% if not master_login_disabled %}
|
||||
<div class="my-6 flex items-center">
|
||||
<div class="flex-1 border-t border-gray-600"></div>
|
||||
<span class="px-4 text-sm text-gray-400">
|
||||
@@ -182,7 +183,6 @@
|
||||
<div class="flex-1 border-t border-gray-600"></div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Login Link -->
|
||||
<a href="/dashboard/login{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="btn-admin transition-all flex items-center justify-center w-full py-3 px-4 text-gray-300 font-medium rounded-lg text-sm">
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -191,6 +191,7 @@
|
||||
</svg>
|
||||
{% if lang == 'fa' %}ورود مدیر با کلید API{% else %}Admin Login with API Key{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<div class="mt-6 text-center">
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
|
||||
{# ── Derive RBAC info from session ── #}
|
||||
{% set _is_admin = is_admin_session(session) %}
|
||||
{% set _is_oauth_admin = _is_admin and session is mapping %}
|
||||
{% set _display = get_session_display_info(session) %}
|
||||
|
||||
<body class="bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200"
|
||||
@@ -134,6 +135,7 @@
|
||||
('my_sites', t.get('my_sites', 'My Sites'), 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0
|
||||
01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
'/dashboard/sites'),
|
||||
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
|
||||
('connect', t.get('connect', 'Connect'), 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656
|
||||
5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', '/dashboard/connect'),
|
||||
] %}
|
||||
@@ -172,8 +174,8 @@
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{# Render user nav (non-admin only) #}
|
||||
{% if not _is_admin %}
|
||||
{# Render user nav (non-admin users + OAuth admins) #}
|
||||
{% if not _is_admin or _is_oauth_admin %}
|
||||
{% for item_id, label, icon_path, url in user_nav %}
|
||||
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
|
||||
@@ -206,15 +208,28 @@
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<!-- Version footer -->
|
||||
<div x-show="sidebarOpen" class="px-4 py-2 text-xs text-gray-400 dark:text-gray-600">
|
||||
v{{ project_version | default('') }}
|
||||
<!-- Support link -->
|
||||
<div x-show="sidebarOpen" class="px-4 py-2">
|
||||
<a href="https://nowpayments.io/donation/airano" target="_blank" rel="noopener noreferrer"
|
||||
class="flex items-center text-xs text-gray-400 dark:text-gray-500 hover:text-pink-400 dark:hover:text-pink-400 transition-colors">
|
||||
<svg class="w-4 h-4 mr-1.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||
</svg>
|
||||
{% if lang == 'fa' %}حمایت از MCP Hub{% else %}Support MCP Hub{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Version footer -->
|
||||
{% if project_version %}
|
||||
<div x-show="sidebarOpen" class="px-4 py-2 text-xs text-gray-400 dark:text-gray-600">
|
||||
v{{ project_version }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- User Section -->
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{# Profile link for OAuth users #}
|
||||
{% if not _is_admin %}
|
||||
{# Profile link for OAuth users (including OAuth admins) #}
|
||||
{% if not _is_admin or _is_oauth_admin %}
|
||||
<a href="/dashboard/profile{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded-lg transition-colors mb-1 {% if current_page == 'profile' %}bg-primary-600 text-white{% endif %}">
|
||||
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Config Snippets Section -->
|
||||
<!-- Config Snippets + Connection Guide (unified) -->
|
||||
<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>
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
<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>
|
||||
<option value="{{ site.alias }}" data-plugin="{{ site.plugin_type }}">{{ site.alias }} ({{ site.plugin_type }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -128,8 +128,8 @@
|
||||
<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>
|
||||
|
||||
<!-- Transport type info -->
|
||||
<div class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||
<!-- Transport type info (hidden for web-based clients via JS) -->
|
||||
<div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>نکته:</strong> تنظیمات بالا شامل نوع اتصال (<code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code) میباشد. از <code>sse</code> استفاده نکنید — باعث خطای <code>400 Bad Request</code> میشود.
|
||||
@@ -139,76 +139,50 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- WordPress plugin requirement -->
|
||||
<div class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||
<!-- WordPress SEO plugin note (shown/hidden dynamically by JS based on selected site) -->
|
||||
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
|
||||
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
|
||||
را روی سایت وردپرسی خود نصب کنید. همچنین یک <strong>Application Password</strong> در وردپرس (Users → Profile) ایجاد و هنگام افزودن سایت وارد کنید.
|
||||
را روی سایت وردپرسی خود نصب کنید.
|
||||
{% else %}
|
||||
<strong>WordPress:</strong> For SEO tools, install the
|
||||
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
|
||||
plugin on your WordPress site. Also create an <strong>Application Password</strong> in WordPress (Users → Profile) and enter it when adding your site.
|
||||
plugin on your WordPress site.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Connection tip (shown only for web-based clients via JS) -->
|
||||
<div id="connection-tip" style="display:none" class="mt-3 bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4">
|
||||
<p class="text-sm text-green-700 dark:text-green-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال میتوانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
|
||||
ساخت <a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
|
||||
{% else %}
|
||||
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
|
||||
Creating an <a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- API Key hint for desktop/CLI clients (hidden for web clients via JS) -->
|
||||
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>API Key:</strong> از بخش API Keys بالا بسازید و مقدار آن را در تنظیمات وارد کنید:
|
||||
{% else %}
|
||||
<strong>API Key:</strong> Create one above and use it in your config:
|
||||
{% endif %}
|
||||
</p>
|
||||
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
|
||||
</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 %}
|
||||
|
||||
@@ -254,11 +228,35 @@ async function deleteKey(keyId) {
|
||||
}
|
||||
}
|
||||
|
||||
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
|
||||
const WP_TYPES = ['wordpress', 'woocommerce'];
|
||||
|
||||
async function updateConfig() {
|
||||
const alias = document.getElementById('config-site')?.value;
|
||||
const siteSelect = document.getElementById('config-site');
|
||||
const alias = siteSelect?.value;
|
||||
const client = document.getElementById('config-client')?.value;
|
||||
if (!alias || !client) return;
|
||||
|
||||
const isWebClient = WEB_CLIENTS.includes(client);
|
||||
|
||||
// Show/hide transport note
|
||||
const transportNote = document.getElementById('transport-note');
|
||||
if (transportNote) transportNote.style.display = isWebClient ? 'none' : '';
|
||||
|
||||
// Show/hide connection tip (only for web clients)
|
||||
const connTip = document.getElementById('connection-tip');
|
||||
if (connTip) connTip.style.display = isWebClient ? '' : 'none';
|
||||
|
||||
// Show/hide bearer hint (only for desktop/CLI clients)
|
||||
const bearerHint = document.getElementById('bearer-hint');
|
||||
if (bearerHint) bearerHint.style.display = isWebClient ? 'none' : '';
|
||||
|
||||
// Show/hide WordPress SEO note based on selected site's plugin type
|
||||
const selectedOption = siteSelect.options[siteSelect.selectedIndex];
|
||||
const pluginType = selectedOption?.dataset?.plugin || '';
|
||||
const wpNote = document.getElementById('wp-seo-note');
|
||||
if (wpNote) wpNote.style.display = WP_TYPES.includes(pluginType) ? '' : 'none';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/config/' + alias + '?client=' + client);
|
||||
const data = await resp.json();
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{# ══════════════════════════════════════════════════════ #}
|
||||
|
||||
<!-- Admin Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Projects Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -94,6 +94,42 @@
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Users Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کاربران{% else %}Total Users{% endif %}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.users_count|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-cyan-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}کاربران ثبتنام شده{% else %}Registered users{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- User Sites Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سایتهای کاربران{% else %}User Sites{% endif %}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.user_sites_count|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-teal-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}سایتهای متصل شده{% else %}Connected sites{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Main Content Grid -->
|
||||
@@ -252,33 +288,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support / Donate -->
|
||||
<div class="bg-gradient-to-br from-primary-900/40 to-gray-800 rounded-xl border border-primary-700/30">
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-10 h-10 bg-primary-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-white mb-1">
|
||||
{% if lang == 'fa' %}حمایت از پروژه{% else %}Support MCP Hub{% endif %}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-400 mb-4">
|
||||
{% if lang == 'fa' %}با حمایت مالی به توسعه این پروژه کمک کنید{% else %}Help us keep this project alive and growing{% endif %}
|
||||
</p>
|
||||
<a
|
||||
href="https://nowpayments.io/donation/airano"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{% if lang == 'fa' %}حمایت با رمزارز{% else %}Donate with Crypto{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -288,7 +297,7 @@
|
||||
{# ══════════════════════════════════════════════════════ #}
|
||||
|
||||
<!-- User Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- My Sites Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -349,24 +358,6 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Available Tools Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('available_tools', 'Available Tools') }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.tools_count|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}ابزارهای MCP قابل استفاده{% else %}MCP tools available{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Site Status -->
|
||||
@@ -439,33 +430,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support / Donate (User Dashboard) -->
|
||||
<div class="bg-gradient-to-br from-primary-900/40 to-gray-800 rounded-xl border border-primary-700/30">
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-10 h-10 bg-primary-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-white mb-1">
|
||||
{% if lang == 'fa' %}حمایت از پروژه{% else %}Support MCP Hub{% endif %}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-400 mb-4">
|
||||
{% if lang == 'fa' %}با حمایت مالی به توسعه این پروژه کمک کنید{% else %}Help us keep this project alive and growing{% endif %}
|
||||
</p>
|
||||
<a
|
||||
href="https://nowpayments.io/donation/airano"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{% if lang == 'fa' %}حمایت با رمزارز{% else %}Donate with Crypto{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -212,8 +212,8 @@
|
||||
required
|
||||
rows="3"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="https://example.com/callback https://app.example.com/oauth/callback"
|
||||
></textarea>
|
||||
>https://claude.ai/api/mcp/auth_callback
|
||||
https://claude.com/api/mcp/auth_callback</textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{% if lang == 'fa' %}هر آدرس را در یک خط جداگانه وارد کنید{% else %}Enter one URI per line{% endif %}
|
||||
</p>
|
||||
@@ -227,11 +227,11 @@
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">read</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="scopes" value="write" class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<input type="checkbox" name="scopes" value="write" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">write</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="scopes" value="admin" class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<input type="checkbox" name="scopes" value="admin" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">admin</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
185
core/templates/dashboard/service.html
Normal file
185
core/templates/dashboard/service.html
Normal file
@@ -0,0 +1,185 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ service.display_name }} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{{ service.display_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Back button + Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/dashboard/services{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-400 hover:text-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5{% if lang == 'fa' %} rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ service.display_name }}</h2>
|
||||
{% if service.is_public %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">
|
||||
{% if lang == 'fa' %}عمومی{% else %}Public{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}فقط مدیر{% else %}Admin Only{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="/dashboard/sites/add?plugin_type={{ service.plugin_type }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
|
||||
+ {{ t.get('add_site', 'Add Site') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Tools Count -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}تعداد ابزار{% else %}Available Tools{% endif %}
|
||||
</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ service.tools_count }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Read Tools -->
|
||||
{% set read_tools = service.tools|selectattr('scope', 'equalto', 'read')|list %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}ابزار خواندن{% else %}Read Tools{% endif %}
|
||||
</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ read_tools|length }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Write Tools -->
|
||||
{% set write_tools = service.tools|selectattr('scope', 'equalto', 'write')|list %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}ابزار نوشتن{% else %}Write Tools{% endif %}
|
||||
</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ write_tools|length }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Requirements -->
|
||||
{% if service.credential_fields %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<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">
|
||||
{% if lang == 'fa' %}اطلاعات مورد نیاز{% else %}Setup Requirements{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{% for field in service.credential_fields %}
|
||||
<div class="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="w-8 h-8 bg-purple-500/20 rounded flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
{% if field.type == 'password' %}
|
||||
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ field.label }}</p>
|
||||
{% if field.hint %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||
{% endif %}
|
||||
{% if field.required %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 mt-1">
|
||||
{% if lang == 'fa' %}الزامی{% else %}Required{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 mt-1">
|
||||
{% if lang == 'fa' %}اختیاری{% else %}Optional{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tools List -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}لیست ابزارها{% else %}Tools{% endif %}
|
||||
<span class="text-sm font-normal text-gray-400 dark:text-gray-500">({{ service.tools_count }})</span>
|
||||
</h3>
|
||||
<div class="relative">
|
||||
<input type="text" id="tool-search" placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search tools...{% endif %}"
|
||||
class="w-48 px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:border-primary-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700" id="tools-list">
|
||||
{% for tool in service.tools %}
|
||||
<div class="tool-item p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" data-name="{{ tool.name }}" data-desc="{{ tool.description|lower }}">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-sm font-mono text-primary-600 dark:text-primary-400">{{ tool.name }}</code>
|
||||
{% if tool.scope == 'read' %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">read</span>
|
||||
{% elif tool.scope == 'write' %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-300">write</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">admin</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 truncate">{{ tool.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('tool-search').addEventListener('input', function(e) {
|
||||
const q = e.target.value.toLowerCase();
|
||||
document.querySelectorAll('.tool-item').forEach(function(item) {
|
||||
const name = item.dataset.name || '';
|
||||
const desc = item.dataset.desc || '';
|
||||
item.style.display = (name.includes(q) || desc.includes(q)) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
57
core/templates/dashboard/services_list.html
Normal file
57
core/templates/dashboard/services_list.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.get('services', 'Services') }} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{{ t.get('services', 'Services') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}سرویسهای MCP{% else %}MCP Services{% endif %}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% set plugin_colors = {
|
||||
'wordpress': 'blue',
|
||||
'woocommerce': 'purple',
|
||||
'supabase': 'emerald',
|
||||
'gitea': 'green',
|
||||
'n8n': 'orange',
|
||||
'openpanel': 'cyan',
|
||||
'appwrite': 'pink',
|
||||
'directus': 'violet',
|
||||
'wordpress_advanced': 'indigo',
|
||||
} %}
|
||||
|
||||
{% for svc in services %}
|
||||
{% set color = plugin_colors.get(svc.plugin_type, 'gray') %}
|
||||
<a href="/dashboard/services/{{ svc.plugin_type }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="block bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-all group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white transition-colors">
|
||||
{{ svc.display_name }}
|
||||
</h3>
|
||||
{% if svc.is_public %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">
|
||||
{% if lang == 'fa' %}عمومی{% else %}Public{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}فقط مدیر{% else %}Admin Only{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{{ svc.tools_count }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
||||
<span>·</span>
|
||||
{% set read_count = svc.tools|selectattr('scope', 'equalto', 'read')|list|length %}
|
||||
{% set write_count = svc.tools|selectattr('scope', 'equalto', 'write')|list|length %}
|
||||
<span class="text-green-600 dark:text-green-400">{{ read_count }} read</span>
|
||||
<span class="text-orange-600 dark:text-orange-400">{{ write_count }} write</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -93,6 +93,63 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Managed Settings (4C.3) -->
|
||||
{% if managed_settings %}
|
||||
<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' %}تنظیمات قابل مدیریت{% else %}Managed Settings{% endif %}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{% if lang == 'fa' %}این تنظیمات از پنل قابل تغییر هستند. اولویت: دیتابیس > متغیر محیطی > پیشفرض{% else %}These settings can be changed from the panel. Priority: Database > Environment > Default{% endif %}
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
{% for s in managed_settings %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4" id="setting-{{ s.key }}">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}{{ s.label_fa }}{% else %}{{ s.label }}{% endif %}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{% if lang == 'fa' %}{{ s.hint_fa }}{% else %}{{ s.hint }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium
|
||||
{% if s.source == 'database' %}bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300
|
||||
{% elif s.source == 'environment' %}bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300
|
||||
{% else %}bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400{% endif %}"
|
||||
id="source-{{ s.key }}">
|
||||
{{ s.source }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" value="{{ s.value }}" id="input-{{ s.key }}"
|
||||
class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<button onclick="saveSetting('{{ s.key }}')"
|
||||
class="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
|
||||
id="save-{{ s.key }}">
|
||||
{% if lang == 'fa' %}ذخیره{% else %}Save{% endif %}
|
||||
</button>
|
||||
{% if s.source == 'database' %}
|
||||
<button onclick="resetSetting('{{ s.key }}')"
|
||||
class="px-3 py-2 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-300 text-sm rounded-lg transition-colors"
|
||||
id="reset-{{ s.key }}"
|
||||
title="{% if lang == 'fa' %}بازگشت به پیشفرض{% else %}Reset to default{% endif %}">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1" id="default-{{ s.key }}">
|
||||
{% if lang == 'fa' %}پیشفرض: {{ s.default }}{% else %}Default: {{ s.default }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Registered Plugins -->
|
||||
<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">
|
||||
@@ -208,3 +265,59 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function saveSetting(key) {
|
||||
const input = document.getElementById('input-' + key);
|
||||
const btn = document.getElementById('save-' + key);
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
|
||||
btn.textContent = '...';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/dashboard/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value, action: 'save' }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
btn.textContent = '✓';
|
||||
btn.className = btn.className.replace('bg-blue-600', 'bg-green-600').replace('hover:bg-blue-700', 'hover:bg-green-700');
|
||||
const source = document.getElementById('source-' + key);
|
||||
if (source) { source.textContent = 'database'; source.className = 'px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'; }
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
alert(data.error || 'Failed to save');
|
||||
btn.textContent = '{% if lang == "fa" %}ذخیره{% else %}Save{% endif %}';
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
btn.textContent = '{% if lang == "fa" %}ذخیره{% else %}Save{% endif %}';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSetting(key) {
|
||||
if (!confirm('Reset this setting to default?')) return;
|
||||
try {
|
||||
const resp = await fetch('/api/dashboard/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, action: 'reset' }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
alert(data.error || 'Failed to reset');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
onchange="updateFields()">
|
||||
<option value="">{{ t.select_plugin }}</option>
|
||||
{% for ptype, pname in plugin_names.items() %}
|
||||
<option value="{{ ptype }}">{{ pname }}</option>
|
||||
<option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -82,11 +82,15 @@
|
||||
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>';
|
||||
pluginFields[ptype].forEach(field => {
|
||||
|
||||
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>` : '';
|
||||
html += `
|
||||
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}
|
||||
@@ -94,10 +98,29 @@
|
||||
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 (e.g., from service page)
|
||||
if (document.getElementById('plugin_type').value) {
|
||||
updateFields();
|
||||
}
|
||||
|
||||
document.getElementById('add-site-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submit-btn');
|
||||
|
||||
@@ -53,18 +53,48 @@
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>
|
||||
{% set fields = plugin_fields.get(site.plugin_type, []) %}
|
||||
{% for field in fields %}
|
||||
{% if not field.get('advanced', False) %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{{ field.label }}{% if field.required %} *{% endif %}
|
||||
</label>
|
||||
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||
{% if field.type == 'password' %}placeholder="{{ t.keep_existing }}"{% endif %}
|
||||
placeholder="{% if field.required %}{{ t.keep_existing }}{% else %}{{ t.leave_blank_to_clear }}{% endif %}"
|
||||
data-required="{{ field.required | lower }}"
|
||||
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">
|
||||
{% if field.hint %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% set has_advanced = fields | map(attribute='advanced') | select | list | length > 0 if fields else false %}
|
||||
{% if has_advanced %}
|
||||
<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 space-y-4">
|
||||
{% for field in fields %}
|
||||
{% if field.get('advanced', False) %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||
placeholder="{{ t.leave_blank_to_clear }}"
|
||||
data-required="false"
|
||||
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">
|
||||
{% if field.hint %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
@@ -99,12 +129,20 @@
|
||||
|
||||
const url = document.getElementById('url').value;
|
||||
|
||||
// Collect credentials — only include non-empty values
|
||||
// Collect credentials — non-empty values always sent,
|
||||
// optional fields sent as empty string to allow clearing
|
||||
const creds = {};
|
||||
if (pluginFields[pluginType]) {
|
||||
pluginFields[pluginType].forEach(field => {
|
||||
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||
if (input) creds[field.name] = input.value;
|
||||
if (!input) return;
|
||||
const val = input.value.trim();
|
||||
if (val) {
|
||||
creds[field.name] = val;
|
||||
} else if (input.dataset.required === 'false') {
|
||||
// Send empty string for optional fields so server can clear them
|
||||
creds[field.name] = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -52,19 +52,26 @@
|
||||
</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">
|
||||
<td class="px-6 py-4" id="status-cell-{{ site.id }}">
|
||||
{% 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>
|
||||
<span class="status-badge 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>
|
||||
<span class="status-badge 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>
|
||||
<span class="status-badge 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>
|
||||
<span class="status-badge 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>
|
||||
<p class="text-xs text-gray-500 mt-1 status-msg">{{ site.status_msg[:60] }}</p>
|
||||
{% endif %}
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1 last-tested-time">
|
||||
{% if site.last_tested_at %}
|
||||
{{ t.last_tested }}: {{ site.last_tested_at[:16] }}
|
||||
{% else %}
|
||||
{{ t.never_tested }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -107,29 +114,68 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function updateStatusBadge(siteId, status) {
|
||||
const cell = document.getElementById('status-cell-' + siteId);
|
||||
if (!cell) return;
|
||||
const badge = cell.querySelector('.status-badge');
|
||||
if (badge) {
|
||||
const base = 'status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
|
||||
if (status === 'active') {
|
||||
badge.className = base + ' bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300';
|
||||
badge.textContent = '{{ t.active }}';
|
||||
} else {
|
||||
badge.className = base + ' bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300';
|
||||
badge.textContent = '{{ t.error }}';
|
||||
}
|
||||
}
|
||||
const msgEl = cell.querySelector('.status-msg');
|
||||
if (msgEl) msgEl.remove();
|
||||
const timeEl = cell.querySelector('.last-tested-time');
|
||||
if (timeEl) {
|
||||
timeEl.textContent = '{{ t.last_tested }}: {{ t.just_now }}';
|
||||
}
|
||||
}
|
||||
|
||||
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' });
|
||||
if (!resp.ok) {
|
||||
btn.textContent = '{{ t.connection_failed }}';
|
||||
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
updateStatusBadge(siteId, 'error');
|
||||
return;
|
||||
}
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
if (!ct.includes('application/json')) {
|
||||
btn.textContent = '{{ t.connection_failed }}';
|
||||
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
updateStatusBadge(siteId, 'error');
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
btn.textContent = '{{ t.connection_ok }}';
|
||||
btn.className = 'text-sm text-green-600 dark:text-green-400';
|
||||
updateStatusBadge(siteId, data.status || 'active');
|
||||
} else {
|
||||
btn.textContent = data.message || '{{ t.connection_failed }}';
|
||||
btn.textContent = '{{ t.connection_failed }}';
|
||||
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
updateStatusBadge(siteId, data.status || 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = '{{ t.error }}';
|
||||
btn.textContent = '{{ t.connection_failed }}';
|
||||
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
updateStatusBadge(siteId, 'error');
|
||||
} finally {
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -110,8 +110,8 @@
|
||||
{% if lang == 'fa' %}Redirect URIs (هر URI در یک خط){% else %}Redirect URIs (one per line){% endif %}
|
||||
</label>
|
||||
<textarea id="redirect-uris" rows="3"
|
||||
placeholder="https://claude.ai/oauth/callback"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 font-mono text-sm"></textarea>
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 font-mono text-sm">https://claude.ai/api/mcp/auth_callback
|
||||
https://claude.com/api/mcp/auth_callback</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
|
||||
Reference in New Issue
Block a user