feat(F.7+F.17): v3.11.0 — Coolify plugin (67 tools) + tool access overhaul
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

Catch-up sync spanning v3.7.0 → v3.11.0 of the internal repo.

Platform
- Total tools: 565 → 633 (+68) across 10 plugins (Coolify added)
- Tests: 481 → 828 passing

New plugin: Coolify (67 tools, Track F.17)
- Applications (17): CRUD, lifecycle, logs, env vars
- Deployments (5): list/get/cancel/deploy, app history
- Servers (8): CRUD, resources, domains, validation
- Projects (8), Databases (16, 6 DB types + backups), Services (13)

Tool access system (Track F.7 → F.7d)
- Scope → category mapping with per-tool `category` + `sensitivity`
- Schema v7: `site_tool_toggles(site_id)` + `sites.tool_scope` column
- Schema v8: per-site API keys (`api_keys.site_id`)
- Plugin-specific access-level presets (WP / WC / Gitea / OpenPanel /
  Coolify 5-tier)
- Credential-requirement notice tailored per plugin and tier
- Admin Tools count card on service page
- Dropped redundant `write` tier on WP / WP Advanced / WooCommerce
  (admin-scope tool count = 0 → identical to admin tier)

Dashboard
- Unified site manage page (Connection / Tool Access / Connect)
- /dashboard/keys unified (was /api-keys and /connect)
- CSRF interceptor via meta-tag; removed conflicting cookie reader
- Tailwind: pre-built CSS (scripts/build-css.sh) replaces CDN

Docs
- README / DOCKER_README / CLAUDE updated to 633 tools / 10 plugins
- CHANGELOG entries for v3.7.0 → v3.11.0
- FastMCP compatibility note updated to 3.x (post-v3.5 upgrade)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 21:05:07 +02:00
parent d8a0805412
commit 788439e377
31 changed files with 1911 additions and 254 deletions

View File

@@ -0,0 +1,768 @@
{% extends "dashboard/base.html" %}
{% block title %}{{ site.alias }} - MCP Hub{% endblock %}
{% block page_title %}{{ site.alias }}{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto space-y-6">
<!-- Header -->
<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">
{{ site.alias }}
<span class="ml-2 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>
</h2>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 1: Connection Settings -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('connection')" 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="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"/>
</svg>
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}تنظیمات اتصال{% else %}Connection Settings{% endif %}
</h3>
</div>
<svg id="connection-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="connection-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
<!-- Info notice -->
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-700 dark:text-blue-300 px-4 py-3 rounded-lg text-sm">
{{ t.keep_existing }}
</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="edit-site-form" class="space-y-4">
<!-- 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 value="{{ site.url }}"
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>
<!-- Credential Fields -->
<div id="credential-fields" class="space-y-4">
{% set fields = plugin_fields.get(site.plugin_type, []) %}
{% for field in fields %}
{% if not field.get('advanced', False) %}
<div>
<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 }}"
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>
<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>
<!-- Test + Save buttons -->
<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.save }}
</button>
<button type="button" id="test-btn" onclick="testConnection()"
class="px-5 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">
{% if lang == 'fa' %}تست اتصال{% else %}Test Connection{% endif %}
</button>
<span id="test-status" class="text-sm"></span>
</div>
</form>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 2: Tool Access -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('tools')" 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-purple-500/20 rounded-lg flex items-center justify-center">
<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 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}دسترسی به ابزارها{% else %}Tool Access{% endif %}
</h3>
<p id="tool-summary" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"></p>
</div>
</div>
<svg id="tools-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="tools-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
<div id="tool-access-loading" class="text-gray-400 dark:text-gray-500 text-sm">
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
</div>
<div id="tool-access-content" class="hidden space-y-4">
<!-- Scope tier selector (F.7d: rendered from plugin-specific presets) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}سطح دسترسی{% else %}Access Level{% endif %}
</label>
<div id="scope-tiers" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-{{ scope_presets|length }} gap-2">
{% for preset in scope_presets %}
<button type="button" data-scope="{{ preset.value }}" onclick="selectScope('{{ preset.value }}')"
class="scope-btn px-3 py-2.5 rounded-lg border-2 text-sm font-medium transition-all text-center">
<div class="font-semibold">{% if lang == 'fa' %}{{ preset.label_fa }}{% else %}{{ preset.label }}{% endif %}</div>
<div class="text-xs opacity-70 mt-0.5">{% if lang == 'fa' %}{{ preset.hint_fa }}{% else %}{{ preset.hint }}{% endif %}</div>
</button>
{% endfor %}
</div>
<div id="scope-status" class="hidden text-xs mt-2"></div>
<div id="scope-notice" class="hidden mt-3 rounded-lg p-3 text-xs"></div>
</div>
<!-- Per-tool toggles (visible for all tiers, expanded for custom) -->
<div id="tool-overrides-wrapper">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}
<span id="tool-count" class="text-gray-400 font-normal"></span>
</p>
<input type="text" id="tool-search" placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search...{% endif %}"
class="w-48 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div id="tool-list" class="space-y-1 max-h-80 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-3"></div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 3: Connect -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('connect')" 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="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}اتصال{% else %}Connect{% endif %}
</h3>
</div>
<svg id="connect-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="connect-section" class="border-t border-gray-200 dark:border-gray-700 p-6 space-y-5">
<!-- MCP Endpoint URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}آدرس MCP{% else %}MCP Endpoint{% endif %}
</label>
<div class="flex items-center gap-2">
<code id="mcp-url" 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">{{ mcp_url }}</code>
<button onclick="copyText('mcp-url')" class="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
{% if lang == 'fa' %}برای احراز هویت از کلید API یا OAuth استفاده کنید.{% else %}Authenticate with an API key (Bearer token) or OAuth.{% endif %}
</p>
</div>
<!-- Config Snippets -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}نمونه کد پیکربندی{% else %}Configuration Snippet{% endif %}
</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 mb-3">
{% for client in clients %}
<option value="{{ client.id }}">{{ client.label }}</option>
{% endfor %}
</select>
<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-[80px]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</pre>
<button onclick="copyConfig()" id="copy-config-btn"
class="absolute top-2 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">
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
</button>
</div>
<div id="transport-note" class="mt-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
<p class="text-xs text-blue-700 dark:text-blue-400">
{% if lang == 'fa' %}<strong>نکته:</strong> از <code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code استفاده کنید.
{% else %}<strong>Note:</strong> Use <code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code.{% endif %}
</p>
</div>
</div>
<!-- Quick API Key Create -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex items-center justify-between mb-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}کلید API{% else %}API Key{% endif %}
</label>
<a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-xs text-blue-500 hover:text-blue-400">
{% if lang == 'fa' %}مدیریت کلیدها{% else %}Manage Keys{% endif %} &rarr;
</a>
</div>
<div id="quick-key-area">
<div class="flex gap-2">
<input type="text" id="quick-key-name" placeholder="{% if lang == 'fa' %}نام کلید (مثلا Claude Desktop){% else %}Key name (e.g. Claude Desktop){% endif %}"
class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
<button onclick="quickCreateKey()" id="quick-key-btn"
class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white text-sm font-medium transition-colors whitespace-nowrap">
{% if lang == 'fa' %}ایجاد کلید{% else %}Create Key{% endif %}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
{% if lang == 'fa' %}
این کلید به‌طور خودکار فقط برای این سرویس (<span class="font-mono">{{ site.alias }}</span>) فعال می‌شود.
{% else %}
This key will be auto-scoped to this service (<span class="font-mono">{{ site.alias }}</span>) only.
{% endif %}
</p>
<div id="quick-key-result" class="hidden mt-3">
<div class="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3 mb-2">
<p class="text-xs text-yellow-600 dark:text-yellow-400">{% if lang == 'fa' %}این کلید فقط یکبار نمایش داده می‌شود!{% else %}This key is only shown once!{% endif %}</p>
</div>
<div class="flex items-center gap-2">
<input type="text" id="quick-key-value" readonly
class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-sm font-mono text-gray-900 dark:text-white">
<button onclick="copyText('quick-key-value', true)" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const siteId = "{{ site.id }}";
const siteAlias = "{{ site.alias }}";
const pluginType = "{{ site.plugin_type }}";
const pluginFields = {{ plugin_fields_json | safe }};
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
// ── 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');
}
// ── Connection form ─────────────────────────────────────────────
document.getElementById('edit-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.disabled = true;
btn.textContent = '{{ t.updating_site }}';
const url = document.getElementById('url').value;
const creds = {};
if (pluginFields[pluginType]) {
pluginFields[pluginType].forEach(field => {
const input = document.querySelector(`[name="cred_${field.name}"]`);
if (!input) return;
const val = input.value.trim();
if (val) creds[field.name] = val;
else if (input.dataset.required === 'false') creds[field.name] = '';
});
}
try {
const resp = await fetch('/api/sites/' + siteId, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, credentials: creds }),
});
const data = await resp.json();
if (resp.ok) {
btn.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved!{% endif %}';
btn.className = btn.className.replace('bg-blue-600 hover:bg-blue-700', 'bg-green-600 hover:bg-green-700');
setTimeout(() => {
btn.textContent = '{{ t.save }}';
btn.className = btn.className.replace('bg-green-600 hover:bg-green-700', 'bg-blue-600 hover:bg-blue-700');
btn.disabled = false;
}, 2000);
} else {
errorEl.textContent = data.error || 'Failed to update site';
errorEl.classList.remove('hidden');
btn.textContent = '{{ t.save }}';
btn.disabled = false;
}
} catch (err) {
errorEl.textContent = 'Network error: ' + err.message;
errorEl.classList.remove('hidden');
btn.textContent = '{{ t.save }}';
btn.disabled = false;
}
});
async function testConnection() {
const btn = document.getElementById('test-btn');
const status = document.getElementById('test-status');
btn.disabled = true;
status.textContent = '{% if lang == "fa" %}در حال تست...{% else %}Testing...{% endif %}';
status.className = 'text-sm text-gray-400';
try {
const r = await fetch('/api/sites/' + siteId + '/test', { method: 'POST' });
const d = await r.json();
if (r.ok) {
status.textContent = '{% if lang == "fa" %}متصل{% else %}Connected{% endif %}';
status.className = 'text-sm text-green-500';
} else {
status.textContent = d.error || '{% if lang == "fa" %}خطا{% else %}Failed{% endif %}';
status.className = 'text-sm text-red-500';
}
} catch (_) {
status.textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
status.className = 'text-sm text-red-500';
}
btn.disabled = false;
setTimeout(() => { status.textContent = ''; }, 4000);
}
// ── Tool Access ─────────────────────────────────────────────────
let allTools = [];
let currentScope = 'admin';
async function loadToolAccess() {
try {
const r = await fetch('/api/sites/' + siteId + '/tools');
if (!r.ok) return;
const data = await r.json();
allTools = data.tools || [];
currentScope = data.tool_scope || 'admin';
renderToolAccess();
} catch (_) {}
}
function renderToolAccess() {
document.getElementById('tool-access-loading').classList.add('hidden');
document.getElementById('tool-access-content').classList.remove('hidden');
// Update scope buttons
document.querySelectorAll('.scope-btn').forEach(btn => {
const s = btn.dataset.scope;
if (s === currentScope) {
btn.className = 'scope-btn px-3 py-2.5 rounded-lg border-2 text-sm font-medium transition-all text-center border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400';
} else {
btn.className = 'scope-btn px-3 py-2.5 rounded-lg border-2 text-sm font-medium transition-all text-center border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-500';
}
});
// Summary — show in-scope tools count
const inScopeTools = allTools.filter(t => isToolInScope(t));
const enabled = inScopeTools.filter(t => t.enabled !== false).length;
const scopeLabel = currentScope === 'custom' ? '' : ' [' + currentScope + ']';
document.getElementById('tool-summary').textContent =
'{% if lang == "fa" %}' + enabled + ' از ' + inScopeTools.length + ' ابزار فعال' + scopeLabel + '{% else %}' + enabled + ' of ' + inScopeTools.length + ' tools enabled' + scopeLabel + '{% endif %}';
document.getElementById('tool-count').textContent = '(' + allTools.length + ')';
renderScopeNotice();
renderToolList();
}
// F.7d: Credential requirement — per plugin + scope, describes what
// the Credential already saved in Connection Settings MUST grant on
// the source service so the chosen Access Level is actually usable.
// Phrased as a requirement on the existing credential, not an imperative
// to create a new one.
const CREDENTIAL_GUIDES = {
wordpress: {
read: {
en: 'The Application Password saved in Connection Settings must belong to a WordPress user with at least <strong>Editor</strong> role (or lower). No CRUD capabilities required.',
fa: 'Application Password ثبت‌شده در Connection Settings باید متعلق به کاربری با حداقل نقش <strong>Editor</strong> (یا پایین‌تر) باشد. نیاز به قابلیت CRUD نیست.'
},
admin: {
en: 'The Application Password saved in Connection Settings must belong to a WordPress <strong>Administrator</strong> — required for full CRUD. SEO + plugin/theme tools additionally require the <code class="font-mono">Airano MCP SEO Bridge</code> plugin to be installed on the site.',
fa: 'Application Password ثبت‌شده در Connection Settings باید متعلق به کاربر <strong>Administrator</strong> باشد تا CRUD کامل کار کند. ابزارهای SEO و مدیریت افزونه/قالب علاوه بر آن نیازمند نصب افزونه <code class="font-mono">Airano MCP SEO Bridge</code> روی سایت هستند.'
}
},
wordpress_advanced: {
read: {
en: 'The Application Password in Connection Settings must belong to an <strong>Editor</strong>-or-lower user. Read-only WP-CLI operations are enforced by role caps.',
fa: 'Application Password ثبت‌شده در Connection Settings باید برای کاربری با نقش <strong>Editor</strong> یا پایین‌تر باشد.'
},
admin: {
en: 'The Application Password in Connection Settings must belong to an <strong>Administrator</strong> and the Docker container name must be correct for shell-level plugin/theme operations. Install <code class="font-mono">Airano MCP SEO Bridge</code> on the site to unlock SEO + plugin/theme tools.',
fa: 'Application Password ثبت‌شده باید متعلق به کاربر <strong>Administrator</strong> باشد و نام Docker Container درست وارد شده باشد. برای ابزارهای SEO افزونه <code class="font-mono">Airano MCP SEO Bridge</code> روی سایت نصب باشد.'
}
},
woocommerce: {
read: {
en: 'The REST API key (Consumer Key / Secret) saved in Connection Settings must have <strong>Read</strong> permission, and the creating WP user at least <em>Shop Manager</em> to see orders/customers.',
fa: 'کلید REST API (Consumer Key / Secret) ثبت‌شده در Connection Settings باید سطح <strong>Read</strong> داشته باشد و کاربر سازنده حداقل نقش <em>Shop Manager</em> را داشته باشد.'
},
admin: {
en: 'The REST API key in Connection Settings must have <strong>Read/Write</strong> permission and belong to an <em>Administrator</em> or <em>Shop Manager</em> — enables all 28 store-management tools. SEO tools additionally require the <code class="font-mono">Airano MCP SEO Bridge</code> plugin.',
fa: 'کلید REST API ثبت‌شده باید سطح <strong>Read/Write</strong> داشته و متعلق به <em>Administrator</em> یا <em>Shop Manager</em> باشد تا همه ۲۸ ابزار فعال شوند. ابزارهای SEO نیازمند افزونه <code class="font-mono">Airano MCP SEO Bridge</code> هستند.'
}
},
gitea: {
read: {
en: 'The Access Token in Connection Settings must have scopes <code class="font-mono">read:repository</code>, <code class="font-mono">read:issue</code>, <code class="font-mono">read:user</code>, <code class="font-mono">read:organization</code> (Gitea 1.19+ scoped tokens).',
fa: 'توکن ثبت‌شده در Connection Settings باید اسکوپ‌های <code class="font-mono">read:repository</code>، <code class="font-mono">read:issue</code>، <code class="font-mono">read:user</code> و <code class="font-mono">read:organization</code> را داشته باشد.'
},
write: {
en: 'The Access Token in Connection Settings must include the <code class="font-mono">read:*</code> scopes above <strong>plus</strong> <code class="font-mono">write:repository</code>, <code class="font-mono">write:issue</code>, <code class="font-mono">write:user</code>.',
fa: 'توکن ثبت‌شده علاوه بر اسکوپ‌های <code class="font-mono">read:*</code> باید <code class="font-mono">write:repository</code>، <code class="font-mono">write:issue</code> و <code class="font-mono">write:user</code> را هم داشته باشد.'
},
admin: {
en: 'The Access Token in Connection Settings must belong to a Gitea <strong>site admin</strong> and include <code class="font-mono">admin:repo_hook</code>, <code class="font-mono">admin:org_hook</code>, <code class="font-mono">admin:application</code> scopes.',
fa: 'توکن ثبت‌شده باید متعلق به یک <strong>site admin</strong> در Gitea باشد و اسکوپ‌های <code class="font-mono">admin:repo_hook</code>، <code class="font-mono">admin:org_hook</code> و <code class="font-mono">admin:application</code> را داشته باشد.'
}
},
openpanel: {
read: {
en: 'The Client ID / Secret in Connection Settings must belong to a client created in <strong>read</strong> mode — limited to Export + Insights on the current project.',
fa: 'جفت Client ID / Secret ثبت‌شده در Connection Settings باید متعلق به Clientی با حالت <strong>read</strong> باشد (فقط Export و Insights در پروژه جاری).'
},
write: {
en: 'The Client ID / Secret in Connection Settings must belong to a <strong>write</strong>-mode client (event tracking / ingestion only, no export).',
fa: 'جفت Client ID / Secret ثبت‌شده باید متعلق به Clientی با حالت <strong>write</strong> باشد (فقط ارسال رویداد، بدون Export).'
},
admin: {
en: 'The Client ID / Secret in Connection Settings must belong to a <strong>root</strong>-mode client — required for exporting any project, managing other projects, and full organisation access.',
fa: 'جفت Client ID / Secret ثبت‌شده باید متعلق به Clientی در حالت <strong>root</strong> باشد (خروجی از همه پروژه‌ها و مدیریت سازمان).'
}
},
coolify: {
read: {
en: 'The API token saved in Connection Settings must have permission <code class="font-mono">read</code> — view resources only, no secrets.',
fa: 'توکن API ثبت‌شده در Connection Settings باید مجوز <code class="font-mono">read</code> داشته باشد — فقط مشاهده‌ی منابع بدون اسرار.'
},
'read:sensitive': {
en: 'The API token saved in Connection Settings must have permission <code class="font-mono">read:sensitive</code> — view resources <em>plus</em> env vars and backups.',
fa: 'توکن API ثبت‌شده باید مجوز <code class="font-mono">read:sensitive</code> داشته باشد — شامل مشاهده متغیرهای محیطی و بکاپ.'
},
deploy: {
en: 'The API token saved in Connection Settings must have <code class="font-mono">read</code> + <code class="font-mono">write</code> permissions (Coolify has no separate deploy permission — write enables start/stop/restart/deploy).',
fa: 'توکن API ثبت‌شده باید مجوزهای <code class="font-mono">read</code> و <code class="font-mono">write</code> را داشته باشد (Coolify مجوز deploy جداگانه ندارد؛ write اجازه start/stop/restart/deploy می‌دهد).'
},
write: {
en: 'The API token saved in Connection Settings must have <code class="font-mono">read</code> + <code class="font-mono">write</code> + <code class="font-mono">read:sensitive</code> for full CRUD + env management.',
fa: 'توکن API ثبت‌شده باید <code class="font-mono">read</code> + <code class="font-mono">write</code> + <code class="font-mono">read:sensitive</code> داشته باشد تا CRUD کامل و مدیریت env کار کند.'
},
admin: {
en: 'The API token saved in Connection Settings must have permission <code class="font-mono">root</code> — unrestricted, including system commands and team administration.',
fa: 'توکن API ثبت‌شده باید مجوز <code class="font-mono">root</code> داشته باشد — دسترسی نامحدود شامل دستورات سیستمی و مدیریت تیم.'
}
}
};
function renderScopeNotice() {
const el = document.getElementById('scope-notice');
if (!el) return;
const isFa = {% if lang == 'fa' %}true{% else %}false{% endif %};
const scopeLabels = { read: 'Read', 'read:sensitive': 'Read+Secrets', deploy: 'Deploy', write: 'Write', admin: 'Full Access' };
if (currentScope === 'custom') {
el.classList.add('hidden');
el.innerHTML = '';
return;
}
const guide = (CREDENTIAL_GUIDES[pluginType] || {})[currentScope];
if (!guide) {
el.classList.add('hidden');
el.innerHTML = '';
return;
}
const title = isFa
? 'پیش‌نیاز Credential برای سطح <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:'
: 'Credential requirement for <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:';
const body = isFa ? guide.fa : guide.en;
el.innerHTML =
'<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-lg p-3 text-indigo-700 dark:text-indigo-300">' +
'<p class="mb-1">' + title + '</p>' +
'<p>' + body + '</p>' +
'</div>';
el.classList.remove('hidden');
}
function renderToolList() {
const container = document.getElementById('tool-list');
const search = (document.getElementById('tool-search').value || '').toLowerCase();
container.innerHTML = '';
// Group by required_scope
const groups = { read: [], write: [], admin: [] };
for (const tool of allTools) {
const s = tool.required_scope || 'read';
if (!groups[s]) groups[s] = [];
groups[s].push(tool);
}
const scopeLabels = {
read: '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}',
write: '{% if lang == "fa" %}نوشتن{% else %}Write{% endif %}',
admin: 'Admin',
};
let visibleCount = 0;
for (const scope of ['read', 'write', 'admin']) {
const tools = (groups[scope] || []).filter(t => !search || t.name.toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search));
if (!tools.length) continue;
const header = document.createElement('p');
header.className = 'text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-1 mt-3 first:mt-0';
header.textContent = scopeLabels[scope] || scope;
container.appendChild(header);
for (const tool of tools) {
container.appendChild(renderToolRow(tool));
visibleCount++;
}
}
if (visibleCount === 0) {
container.innerHTML = '<p class="text-sm text-gray-400 py-4 text-center">{% if lang == "fa" %}ابزاری یافت نشد{% else %}No tools found{% endif %}</p>';
}
}
// Universal scope tiers: maps scope → set of allowed required_scope values
const SCOPE_TIERS = {
read: new Set(['read']),
write: new Set(['read', 'write']),
admin: new Set(['read', 'write', 'admin']),
custom: null, // no filter
};
// F.7d: Coolify-style category overlay for fine-grained tiers.
// Mirrors core.tool_access.SCOPE_TO_CATEGORIES.
const SCOPE_CATEGORIES = {
'read': new Set(['read']),
'read:sensitive': new Set(['read', 'read_sensitive', 'backup']),
'deploy': new Set(['read', 'lifecycle']),
'write': new Set(['read', 'lifecycle', 'crud', 'env']),
'admin': new Set(['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system']),
};
const KNOWN_CATEGORIES = new Set(['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system']);
const CATEGORY_PLUGINS = new Set(['coolify']);
function isToolInScope(tool) {
if (currentScope === 'custom') return true;
// Coolify-style category match (only for plugins with category annotations).
if (CATEGORY_PLUGINS.has(pluginType)) {
const allowed = SCOPE_CATEGORIES[currentScope];
if (!allowed) return true;
// Tools without a known category are always visible.
if (!KNOWN_CATEGORIES.has(tool.category)) return true;
return allowed.has(tool.category);
}
// Universal 3-tier filter for everything else.
const tier = SCOPE_TIERS[currentScope];
if (!tier) return true;
return tier.has(tool.required_scope || 'read');
}
async function selectScope(scope) {
currentScope = scope;
renderToolAccess();
const statusEl = document.getElementById('scope-status');
statusEl.textContent = '{% if lang == "fa" %}ذخیره...{% else %}Saving...{% endif %}';
statusEl.className = 'text-xs text-gray-400';
statusEl.classList.remove('hidden');
try {
const r = await fetch('/api/sites/' + siteId + '/tool-scope', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scope }),
});
if (r.ok) {
statusEl.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %}';
statusEl.className = 'text-xs text-green-500';
} else {
const data = await r.json().catch(() => ({}));
statusEl.textContent = data.error || '{% if lang == "fa" %}خطا{% else %}Error{% endif %}';
statusEl.className = 'text-xs text-red-500';
}
} catch (_) {
statusEl.textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
statusEl.className = 'text-xs text-red-500';
}
setTimeout(() => statusEl.classList.add('hidden'), 2000);
}
function renderToolRow(tool) {
const inScope = isToolInScope(tool);
const row = document.createElement('div');
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30' + (inScope ? '' : ' opacity-40');
const left = document.createElement('div');
left.className = 'flex items-center gap-2 flex-1 min-w-0';
const nameEl = document.createElement('span');
nameEl.className = 'text-sm truncate font-mono ' + (inScope ? 'text-gray-800 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500 line-through');
nameEl.textContent = tool.name;
nameEl.title = (tool.description || '') + (inScope ? '' : ' — {% if lang == "fa" %}خارج از سطح دسترسی{% else %}outside current scope{% endif %}');
left.appendChild(nameEl);
if (!inScope) {
const scopeBadge = document.createElement('span');
scopeBadge.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500';
scopeBadge.textContent = tool.required_scope || 'read';
left.appendChild(scopeBadge);
}
if (tool.sensitivity === 'sensitive') {
const badge = document.createElement('span');
badge.className = 'flex-shrink-0 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';
badge.textContent = '{% if lang == "fa" %}حساس{% else %}sensitive{% endif %}';
left.appendChild(badge);
}
const label = document.createElement('label');
label.className = 'relative inline-flex items-center cursor-pointer flex-shrink-0';
const input = document.createElement('input');
input.type = 'checkbox';
input.className = 'sr-only peer';
input.checked = tool.enabled !== false;
input.onchange = async () => {
const enabled = input.checked;
try {
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
if (!r.ok) input.checked = !enabled;
else {
tool.enabled = enabled;
const en = allTools.filter(t => t.enabled !== false).length;
document.getElementById('tool-summary').textContent =
'{% if lang == "fa" %}' + en + ' از ' + allTools.length + ' ابزار فعال{% else %}' + en + ' of ' + allTools.length + ' tools enabled{% endif %}';
}
} catch (_) { input.checked = !enabled; }
};
const slider = document.createElement('div');
slider.className = 'w-9 h-5 bg-gray-300 dark:bg-gray-600 peer-checked:bg-blue-600 rounded-full peer peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 transition-colors after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4';
label.appendChild(input);
label.appendChild(slider);
row.appendChild(left);
row.appendChild(label);
return row;
}
// Tool search
document.getElementById('tool-search').addEventListener('input', renderToolList);
// ── Config Snippets ─────────────────────────────────────────────
async function updateConfig() {
const client = document.getElementById('config-client')?.value;
if (!client) return;
const isWeb = WEB_CLIENTS.includes(client);
document.getElementById('transport-note').style.display = isWeb ? 'none' : '';
try {
const r = await fetch('/api/config/' + siteAlias + '?client=' + client);
const data = await r.json();
document.getElementById('config-output').textContent = r.ok ? data.config : '{% if lang == "fa" %}خطا{% else %}Error{% endif %}';
} catch (_) {
document.getElementById('config-output').textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
}
}
function copyConfig() {
const text = document.getElementById('config-output').textContent;
navigator.clipboard.writeText(text);
const btn = document.getElementById('copy-config-btn');
const orig = btn.textContent;
btn.textContent = '{% if lang == "fa" %}کپی شد!{% else %}Copied!{% endif %}';
setTimeout(() => btn.textContent = orig, 2000);
}
function copyText(elementId, isInput) {
const el = document.getElementById(elementId);
const text = isInput ? el.value : el.textContent.trim();
navigator.clipboard.writeText(text);
}
// ── Quick Key Create ────────────────────────────────────────────
async function quickCreateKey() {
const name = document.getElementById('quick-key-name').value.trim();
if (!name) { document.getElementById('quick-key-name').focus(); return; }
const btn = document.getElementById('quick-key-btn');
btn.disabled = true;
try {
// F.7d: Connect-section keys are auto-scoped to this site
const r = await fetch('/api/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scopes: 'read write admin', site_id: siteId }),
});
const res = await r.json();
if (r.ok) {
document.getElementById('quick-key-value').value = res.key.key;
document.getElementById('quick-key-result').classList.remove('hidden');
btn.textContent = '{% if lang == "fa" %}ایجاد شد{% else %}Created!{% endif %}';
} else {
alert(res.error || 'Failed');
}
} catch (e) { alert('Network error'); }
btn.disabled = false;
}
// ── Init ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadToolAccess();
updateConfig();
});
</script>
{% endblock %}