Files
mcphub/core/templates/dashboard/sites/manage.html
airano-ir f203ca88de
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
feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:25:58 +02:00

1271 lines
81 KiB
HTML

{% 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) %}
{% set is_stored = cred_states.get(field.name, False) %}
<div data-cred-row="{{ field.name }}">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-2">
<span>{{ field.label }}{% if field.required %} *{% endif %}</span>
{% if is_stored %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300" data-cred-stored>
✓ {% if lang == 'fa' %}ذخیره‌شده{% else %}Stored{% endif %}
</span>
{% endif %}
</label>
<input type="{{ field.type }}" name="cred_{{ field.name }}"
placeholder="{% if is_stored %}{{ t.keep_existing }}{% elif field.required %}{{ t.keep_existing }}{% else %}{% if lang == 'fa' %}اختیاری — خالی بگذارید{% else %}Optional — leave empty{% endif %}{% endif %}"
data-required="{{ field.required | lower }}"
data-stored="{{ is_stored | 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">
{% if lang == 'fa' %}تنظیمات پیشرفته{% else %}Advanced Settings{% endif %}
</summary>
<div class="px-4 pb-4 pt-2 space-y-4">
{% for field in fields %}
{% if field.get('advanced', False) %}
{% set is_stored = cred_states.get(field.name, False) %}
<div data-cred-row="{{ field.name }}">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-2">
<span>{{ field.label }}</span>
{% if is_stored %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300" data-cred-stored>
✓ {% if lang == 'fa' %}ذخیره‌شده{% else %}Stored{% endif %}
</span>
<button type="button"
data-cred-clear="{{ field.name }}"
class="ml-auto inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border border-red-300 dark:border-red-500/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10">
{% if lang == 'fa' %}🗑 پاک کن{% else %}🗑 Clear{% endif %}
</button>
{% endif %}
</label>
<input type="{{ field.type }}" name="cred_{{ field.name }}"
placeholder="{% if is_stored %}{{ t.keep_existing }}{% else %}{% if lang == 'fa' %}اختیاری — خالی بگذارید{% else %}Optional — leave empty{% endif %}{% endif %}"
data-required="false"
data-stored="{{ is_stored | 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 %}
</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>
{% if provider_keys_supported %}
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 1b: AI Image Generation (per-site provider keys) -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('aikeys')" 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-pink-500/20 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}تولید تصویر با هوش مصنوعی{% else %}AI Image Generation{% endif %}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{% if lang == 'fa' %}
کلید API هر ارائه‌دهنده را تنها برای این سایت ذخیره کنید. بدون کلید، ابزار <code class="font-mono">wordpress_generate_and_upload_image</code> غیرفعال است.
{% else %}
Store an API key per provider, scoped only to this site. Without a key, <code class="font-mono">wordpress_generate_and_upload_image</code> is disabled for this site.
{% endif %}
</p>
</div>
</div>
<svg id="aikeys-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="aikeys-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-3">
<div id="aikeys-flash" class="hidden rounded-lg p-3 text-sm"></div>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for row in provider_key_rows %}
<div class="py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3" data-site-ai-row="{{ row.provider }}">
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-3 {% if lang == 'fa' %}space-x-reverse{% endif %}">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ row.label }}</h4>
{% if row.status == 'set' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
{% if lang == 'fa' %}تنظیم‌شده{% else %}Set{% endif %}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}تنظیم نشده{% else %}Unset{% endif %}
</span>
{% endif %}
</div>
</div>
<div class="flex items-center space-x-2 {% if lang == 'fa' %}space-x-reverse{% endif %}">
<form class="flex items-center gap-2"
data-site-ai-set="{{ row.provider }}"
onsubmit="return window.__siteAiKeys.setKey(event, '{{ row.provider }}')">
<input type="password"
name="api_key"
required
minlength="8"
autocomplete="new-password"
placeholder="{% if lang == 'fa' %}کلید API{% else %}New API key{% endif %}"
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-pink-500 focus:border-transparent w-48"/>
<button type="submit"
class="px-3 py-2 rounded-lg bg-pink-600 hover:bg-pink-500 text-white text-sm font-medium transition-colors">
{% if lang == 'fa' %}ذخیره{% else %}Save{% endif %}
</button>
</form>
<button type="button"
data-site-ai-remove="{{ row.provider }}"
onclick="return window.__siteAiKeys.deleteKey('{{ row.provider }}')"
class="px-3 py-2 rounded-lg border border-red-300 dark:border-red-500/50 text-red-600 dark:text-red-400 text-sm font-medium hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors {% if row.status != 'set' %}hidden{% endif %}">
{% if lang == 'fa' %}حذف{% else %}Remove{% endif %}
</button>
</div>
</div>
{% if row.provider == 'openrouter' %}
<details class="ml-0 sm:ml-0 mt-1 text-xs"
data-site-ai-models="openrouter"
data-current-default="{{ row.default_model or '' }}">
<summary class="cursor-pointer inline-block px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 select-none">
{% if lang == 'fa' %}🖼 مدل‌های تصویری در دسترس{% if row.default_model %} — پیش‌فرض: <code class="font-mono">{{ row.default_model }}</code>{% endif %}{% else %}🖼 Available image models{% if row.default_model %} — default: <code class="font-mono">{{ row.default_model }}</code>{% endif %}{% endif %}
</summary>
<div class="mt-2 rounded-lg border border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200">
<div class="text-gray-500 dark:text-gray-400" data-models-placeholder>
{% if lang == 'fa' %}در حال بارگذاری…{% else %}Loading…{% endif %}
</div>
<ul class="space-y-2 hidden" data-models-list></ul>
<p class="mt-2 text-[11px] text-gray-500 dark:text-gray-400" data-models-hint>
{% if lang == 'fa' %}
با کلیک روی «پیش‌فرض» مدل به‌عنوان مدل پیش‌فرض این سایت ذخیره می‌شود — اگر <code class="font-mono">model</code> در فراخوانی <code class="font-mono">wordpress_generate_and_upload_image</code> داده نشود، از این مقدار استفاده می‌شود.
{% else %}
Click "Set default" to make a model the site's default — used when <code class="font-mono">wordpress_generate_and_upload_image</code> is called without an explicit <code class="font-mono">model</code>.
{% endif %}
</p>
</div>
</details>
{% endif %}
{% endfor %}
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 pt-2">
{% if lang == 'fa' %}
کلیدها با AES-256-GCM رمزگذاری می‌شوند و فقط برای همین سایت قابل استفاده‌اند.
{% else %}
Keys are encrypted at rest (AES-256-GCM) and scoped to this site only.
{% endif %}
</p>
</div>
</div>
{% endif %}
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- 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">
{% if companion_download_url %}
<!-- F.20 prep: companion-plugin hint (compact, WP/WC only) -->
<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-lg p-3 text-xs text-indigo-800 dark:text-indigo-300">
{% if lang == 'fa' %}
برای قابلیت‌های کامل (آپلود بزرگ، cache purge، site-health، و…) افزونه‌ی
<a href="{{ companion_download_url }}" class="underline font-medium hover:no-underline">Airano MCP Bridge</a>
را در وردپرس نصب کنید (اختیاری).
{% else %}
For full capability (large uploads, cache purge, site-health, bulk meta, audit hooks, …), install the
<a href="{{ companion_download_url }}" class="underline font-medium hover:no-underline">Airano MCP Bridge</a>
plugin on your WordPress (optional).
{% endif %}
</div>
{% endif %}
{# F.7e: capability probe badge — tells the user whether the
saved credential can actually back the selected tier,
before they hit a 403 from a real tool call. #}
{# F.X.fix #9: badge is an HTMX-swappable partial. Re-check
button hits /api/sites/{id}/capabilities/badge and swaps
this element in place — no full-page reload. #}
{% if capability_probe %}
{% include "dashboard/sites/_capability_badge.html" %}
{% endif %}
<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 ─────────────────────────────────────────────
// F.X.fix-pass5 — Clear-button wiring. Marks the matching input
// with data-clear="1" + visually flashes the row so the user
// knows the next Save will wipe the stored value.
document.querySelectorAll('[data-cred-clear]').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
const fieldName = btn.dataset.credClear;
const input = document.querySelector(`[name="cred_${fieldName}"]`);
if (!input) return;
input.value = '';
input.dataset.clear = '1';
input.classList.add('border-red-400', 'dark:border-red-500');
const row = btn.closest('[data-cred-row]');
const stored = row?.querySelector('[data-cred-stored]');
if (stored) {
stored.textContent = '{% if lang == "fa" %}پس از Save پاک می‌شود{% else %}Will clear on Save{% endif %}';
stored.classList.remove('bg-green-100', 'dark:bg-green-900/40', 'text-green-700', 'dark:text-green-300');
stored.classList.add('bg-red-100', 'dark:bg-red-900/40', 'text-red-700', 'dark:text-red-300');
}
btn.disabled = true;
btn.classList.add('opacity-50');
});
});
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 = {};
// F.X.fix-pass5 — Empty input means "keep existing"; clearing
// a stored optional value requires an explicit Clear button
// click (which sets data-clear="1" on the input). Old
// behaviour silently wiped optional fields on every save.
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.clear === '1') {
// Explicit clear: send empty string to wipe.
creds[field.name] = '';
}
// else: leave out → server keeps existing value.
});
}
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 (_) {}
}
// F.X.fix-pass2 — exposed so the AI-keys set/delete handlers can
// trigger a live refresh of the gating UI after configuring a
// provider key (no full-page reload).
window.loadToolAccess = loadToolAccess;
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);
// F.X.fix-pass3 — central prerequisite gating. Backend has
// already evaluated `available` + `unavailable_reason` so the
// UI just renders accordingly. Reasons we know how to handle:
// provider_key — needs an AI provider key on this site
// companion_route — needs the Airano MCP Bridge companion plugin
// feature — needs an SEO plugin (Rank Math / Yoast)
const reason = tool.unavailable_reason || null;
const missingProviderKey = reason === 'provider_key';
const missingCompanion = reason === 'companion_route';
const missingSeoPlugin = reason === 'feature';
const missingWpCreds = reason === 'wp_credentials';
const unavailable = !tool.available;
const dimmed = !inScope || unavailable;
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' + (dimmed ? ' 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 ' + (dimmed ? 'text-gray-400 dark:text-gray-500 line-through' : 'text-gray-800 dark:text-gray-200');
nameEl.textContent = tool.name;
const titleParts = [tool.description || ''];
if (!inScope) titleParts.push('— {% if lang == "fa" %}خارج از سطح دسترسی{% else %}outside current scope{% endif %}');
if (missingProviderKey) titleParts.push('— {% if lang == "fa" %}نیاز به تنظیم کلید AI Provider{% else %}needs an AI provider key{% endif %}');
if (missingCompanion) titleParts.push('— {% if lang == "fa" %}نیاز به افزونه Airano MCP Bridge{% else %}needs the Airano MCP Bridge companion plugin{% endif %}');
if (missingSeoPlugin) titleParts.push('— {% if lang == "fa" %}نیاز به افزونه‌ی SEO (Rank Math یا Yoast){% else %}needs an SEO plugin (Rank Math or Yoast){% endif %}');
if (missingWpCreds) titleParts.push('— {% if lang == "fa" %}نیاز به Application Password وردپرس{% else %}needs a WordPress Application Password{% endif %}');
nameEl.title = titleParts.join(' ');
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);
}
if (missingProviderKey) {
const configurePill = document.createElement('button');
configurePill.type = 'button';
configurePill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-pink-100 dark:bg-pink-500/20 text-pink-700 dark:text-pink-300 hover:bg-pink-200 dark:hover:bg-pink-500/30 transition-colors';
configurePill.textContent = '{% if lang == "fa" %}⚙ تنظیم کلید{% else %}⚙ Configure key{% endif %}';
configurePill.addEventListener('click', (ev) => {
ev.stopPropagation();
const section = document.getElementById('aikeys-section');
if (section && section.classList.contains('hidden')) {
toggleSection('aikeys');
}
const target = document.getElementById('aikeys-section');
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
left.appendChild(configurePill);
} else if (missingCompanion) {
const pill = document.createElement('span');
pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300';
pill.textContent = '{% if lang == "fa" %}🧩 افزونه‌ی همراه نیاز است{% else %}🧩 needs companion plugin{% endif %}';
left.appendChild(pill);
} else if (missingSeoPlugin) {
const pill = document.createElement('span');
pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300';
pill.textContent = '{% if lang == "fa" %}🔍 SEO plugin نیاز است{% else %}🔍 needs SEO plugin{% endif %}';
left.appendChild(pill);
} else if (missingWpCreds) {
const pill = document.createElement('button');
pill.type = 'button';
pill.className = 'flex-shrink-0 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 hover:bg-orange-200 dark:hover:bg-orange-500/30 transition-colors';
pill.textContent = '{% if lang == "fa" %}🔑 WP App Password نیاز{% else %}🔑 needs WP App Password{% endif %}';
pill.addEventListener('click', (ev) => {
ev.stopPropagation();
const link = document.createElement('a');
link.href = '/dashboard/sites/' + encodeURIComponent(siteId) + '/edit';
link.click();
});
left.appendChild(pill);
}
const label = document.createElement('label');
label.className = 'relative inline-flex items-center flex-shrink-0 ' + (unavailable ? 'cursor-not-allowed' : 'cursor-pointer');
const input = document.createElement('input');
input.type = 'checkbox';
input.className = 'sr-only peer';
// When prerequisites unmet, force toggle visually OFF regardless
// of the user's saved preference — backend filter already drops
// the tool from the live endpoint.
input.checked = tool.available !== false && tool.enabled !== false;
input.disabled = unavailable;
if (!unavailable) {
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;
}
// ── Per-site AI provider keys (F.5a.9.x) ────────────────────────
{% if provider_keys_supported %}
(function () {
const flash = document.getElementById('aikeys-flash');
function show(kind, msg) {
if (!flash) return;
flash.textContent = msg;
flash.className = 'rounded-lg p-3 text-sm ' + (
kind === 'ok'
? 'bg-green-50 dark:bg-green-500/10 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-500/30'
: 'bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-500/30'
);
flash.classList.remove('hidden');
}
// F.X.fix-pass2 — in-place DOM update after set/delete (no
// window.location.reload()), so the AI Image Generation panel
// stays expanded and the scroll position is preserved. Also
// triggers a Tool Access reload + a capability-badge re-check
// so the gating UI reflects the new provider state live.
function _updateRowStatus(provider, isSet) {
const row = document.querySelector('[data-site-ai-row="' + provider + '"]');
if (!row) return;
const badge = row.querySelector('span.inline-flex');
if (badge) {
if (isSet) {
badge.className = 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300';
badge.textContent = '{% if lang == "fa" %}تنظیم‌شده{% else %}Set{% endif %}';
} else {
badge.className = 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300';
badge.textContent = '{% if lang == "fa" %}تنظیم نشده{% else %}Unset{% endif %}';
}
}
const removeBtn = document.querySelector('[data-site-ai-remove="' + provider + '"]');
if (removeBtn) {
removeBtn.classList.toggle('hidden', !isSet);
}
const form = document.querySelector('[data-site-ai-set="' + provider + '"]');
if (form) {
const input = form.querySelector('input[name="api_key"]');
if (input) input.value = '';
}
}
function _refreshLinkedUi() {
// Re-fetch tools so the AI tool's gating updates inline.
if (typeof window.loadToolAccess === 'function') {
window.loadToolAccess();
}
// Trigger the HTMX re-check button so the capability badge
// re-renders with the new ai_providers_configured state.
const recheck = document.querySelector('#capability-badge button[hx-get]');
if (recheck && window.htmx) {
window.htmx.trigger(recheck, 'click');
}
}
async function setKey(ev, provider) {
ev.preventDefault();
const form = ev.target;
const fd = new FormData(form);
const apiKey = (fd.get('api_key') || '').toString();
if (!apiKey || apiKey.length < 8) {
show('err', '{% if lang == "fa" %}کلید باید حداقل ۸ کاراکتر باشد{% else %}Key must be at least 8 characters{% endif %}');
return false;
}
try {
const resp = await fetch('/api/sites/' + encodeURIComponent(siteId) + '/provider-keys/' + encodeURIComponent(provider), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: apiKey }),
credentials: 'same-origin',
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
show('err', data.message || data.error || 'save failed');
return false;
}
_updateRowStatus(provider, true);
_refreshLinkedUi();
show('ok', '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %} — last4: ' + (data.secret_last4 || ''));
} catch (e) {
show('err', e.message || String(e));
}
return false;
}
async function deleteKey(provider) {
if (!window.confirm('{% if lang == "fa" %}آیا مطمئن هستید؟{% else %}Remove this provider key?{% endif %}')) {
return false;
}
try {
const resp = await fetch('/api/sites/' + encodeURIComponent(siteId) + '/provider-keys/' + encodeURIComponent(provider), {
method: 'DELETE',
credentials: 'same-origin',
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
show('err', data.message || data.error || 'delete failed');
return false;
}
_updateRowStatus(provider, false);
_refreshLinkedUi();
show('ok', '{% if lang == "fa" %}حذف شد{% else %}Removed{% endif %}');
} catch (e) {
show('err', e.message || String(e));
}
return false;
}
// F.X.fix-pass2 — OpenRouter model discovery UI. Lazy-loaded on
// first <details> expand so the panel open doesn't pay the
// network cost unless the user asks. Falls back gracefully
// when no key is set (catalog is public but availability
// flags reflect the operator's account when we pass a key).
async function _loadOpenRouterModels(details) {
if (details.dataset.loaded === '1') return;
details.dataset.loaded = '1';
const placeholder = details.querySelector('[data-models-placeholder]');
const list = details.querySelector('[data-models-list]');
try {
const resp = await fetch(
'/api/providers/openrouter/models?site_id=' + encodeURIComponent(siteId),
{ credentials: 'same-origin' }
);
const data = await resp.json();
if (!resp.ok || !data.ok) {
placeholder.textContent = data.message || data.error || '{% if lang == "fa" %}خطا در دریافت لیست{% else %}Failed to load models{% endif %}';
return;
}
const models = Array.isArray(data.models) ? data.models : [];
if (!models.length) {
placeholder.textContent = '{% if lang == "fa" %}هیچ مدلی یافت نشد{% else %}No image models found{% endif %}';
return;
}
placeholder.classList.add('hidden');
list.classList.remove('hidden');
list.innerHTML = '';
const provider = details.dataset.siteAiModels || 'openrouter';
let currentDefault = details.dataset.currentDefault || '';
const renderRow = (m) => {
const li = document.createElement('li');
li.className = 'flex items-start justify-between gap-2 pb-1 border-b border-gray-200 dark:border-gray-700 last:border-0';
const info = document.createElement('div');
info.className = 'flex-1 min-w-0';
const id = document.createElement('code');
id.className = 'font-mono text-gray-800 dark:text-gray-200 break-all';
id.textContent = m.id;
const nameLine = document.createElement('div');
nameLine.className = 'text-[11px] text-gray-500 dark:text-gray-400 mt-0.5';
const nameBits = [];
if (m.name && m.name !== m.id) nameBits.push(m.name);
if (m.price_per_image_usd != null) {
nameBits.push('$' + Number(m.price_per_image_usd).toFixed(4) + '/img');
}
nameLine.textContent = nameBits.join(' · ');
info.appendChild(id);
if (nameBits.length) info.appendChild(nameLine);
const actions = document.createElement('div');
actions.className = 'flex-shrink-0 flex items-center gap-1';
const isDefault = currentDefault === m.id;
const defaultBtn = document.createElement('button');
defaultBtn.type = 'button';
defaultBtn.className = 'px-2 py-0.5 rounded border text-[11px] transition-colors ' + (
isDefault
? 'border-emerald-300 dark:border-emerald-600 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300'
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
);
defaultBtn.textContent = isDefault
? '{% if lang == "fa" %}★ پیش‌فرض{% else %}★ Default{% endif %}'
: '{% if lang == "fa" %}پیش‌فرض{% else %}Set default{% endif %}';
defaultBtn.addEventListener('click', async () => {
const target = isDefault ? null : m.id;
defaultBtn.disabled = true;
const orig = defaultBtn.textContent;
defaultBtn.textContent = '…';
try {
const resp = await fetch(
'/api/sites/' + encodeURIComponent(siteId) +
'/provider-keys/' + encodeURIComponent(provider) +
'/default-model',
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: target }),
credentials: 'same-origin',
}
);
const data = await resp.json();
if (!resp.ok || !data.ok) {
defaultBtn.textContent = orig;
show('err', data.message || data.error || '{% if lang == "fa" %}خطا{% else %}Error{% endif %}');
return;
}
currentDefault = target || '';
details.dataset.currentDefault = currentDefault;
// Re-render the entire list so all rows reflect the new default.
list.innerHTML = '';
for (const x of models) list.appendChild(renderRow(x));
show('ok', '{% if lang == "fa" %}پیش‌فرض ذخیره شد{% else %}Default saved{% endif %}');
} catch (e) {
defaultBtn.textContent = orig;
show('err', e.message || String(e));
} finally {
defaultBtn.disabled = false;
}
});
const copy = document.createElement('button');
copy.type = 'button';
copy.className = 'px-2 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-[11px] text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700';
copy.textContent = '{% if lang == "fa" %}کپی{% else %}Copy{% endif %}';
copy.addEventListener('click', () => {
navigator.clipboard.writeText(m.id);
copy.textContent = '{% if lang == "fa" %}کپی شد{% else %}Copied{% endif %}';
setTimeout(() => { copy.textContent = '{% if lang == "fa" %}کپی{% else %}Copy{% endif %}'; }, 1500);
});
actions.appendChild(defaultBtn);
actions.appendChild(copy);
li.appendChild(info);
li.appendChild(actions);
return li;
};
for (const m of models) list.appendChild(renderRow(m));
} catch (e) {
placeholder.textContent = e.message || String(e);
}
}
document.querySelectorAll('details[data-site-ai-models]').forEach((d) => {
d.addEventListener('toggle', () => {
if (d.open) _loadOpenRouterModels(d);
});
});
window.__siteAiKeys = { setKey, deleteKey };
})();
{% endif %}
// F.X.fix #9: Re-check is now an HTMX partial swap — see the
// hx-get attribute on the button in _capability_badge.html. No JS
// shim needed here any more.
// ── Init ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadToolAccess();
updateConfig();
});
</script>
{% endblock %}