Settings fixes: - MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings table (DB > ENV > default), so dashboard/settings changes apply without restart. Sync cache refreshed on every save or delete. - /api/me reports the live DB value for max_sites_per_user. Admin improvements: - Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS). - Admin Overview now shows platform stats: registered users, new users (7d), total user sites, available tools. Plugin cleanup: - Appwrite and Directus plugins removed from the active registry (8 plugins now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Plugin code is retained for future re-enabling. - Settings page plugin visibility list updated to match. Mobile onboarding: - Stepper steps on narrow viewports stack vertically with correct full border and rounded corners on each step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1341 lines
88 KiB
HTML
1341 lines
88 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 -->
|
|
{% set _qs = request.query_params if request else None %}
|
|
{% set _from_v2 = _qs and _qs.get('from') == 'dashboard-v2' %}
|
|
<div class="flex items-center gap-4 mb-6">
|
|
<a href="{% if _from_v2 %}/dashboard-v2/sites{% else %}/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}{% endif %}"
|
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</a>
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
{{ 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 %} →
|
|
</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_specialist: {
|
|
read: {
|
|
en: 'The Application Password in Connection Settings must belong to a WordPress user with <strong>manage_options</strong> (Administrator). Companion plugin <code class="font-mono">Airano MCP Bridge v2.11.0+</code> must be installed and active on the site.',
|
|
fa: 'Application Password ثبتشده باید متعلق به کاربری با مجوز <strong>manage_options</strong> (Administrator) باشد. افزونه همراه <code class="font-mono">Airano MCP Bridge v2.11.0+</code> باید روی سایت نصب و فعال باشد.'
|
|
},
|
|
editor: {
|
|
en: 'Same prerequisites as Read, plus companion <code class="font-mono">Airano MCP Bridge v2.13.0+</code> for page editing (Gutenberg blocks, Elementor, Classic) and <code class="font-mono">v2.14.0+</code> for theme file CRUD. Per-tool capability checks: <code>edit_post</code>, <code>edit_themes</code>.',
|
|
fa: 'پیشنیاز Read بهعلاوهٔ نسخهٔ <code class="font-mono">Airano MCP Bridge v2.13.0+</code> برای ویرایش صفحه (Gutenberg، Elementor، Classic) و <code class="font-mono">v2.14.0+</code> برای ویرایش فایل قالب. Capability موردنیاز: <code>edit_post</code> و <code>edit_themes</code>.'
|
|
},
|
|
install: {
|
|
en: 'Same prerequisites as Editor, plus companion <code class="font-mono">Airano MCP Bridge v2.14.0+</code> for theme install/activate/delete and <code class="font-mono">v2.15.0+</code> for plugin install/activate/deactivate/update. Per-tool capability checks: <code>install_plugins</code>, <code>activate_plugins</code>, <code>install_themes</code>, <code>switch_themes</code>, <code>update_plugins</code>.',
|
|
fa: 'پیشنیاز Editor بهعلاوهٔ <code class="font-mono">Airano MCP Bridge v2.14.0+</code> برای نصب/فعالسازی/حذف قالب و <code class="font-mono">v2.15.0+</code> برای نصب/فعالسازی/غیرفعالسازی/بهروزرسانی پلاگین. Capability موردنیاز: <code>install_plugins</code>, <code>activate_plugins</code>, <code>install_themes</code>, <code>switch_themes</code>, <code>update_plugins</code>.'
|
|
},
|
|
admin: {
|
|
en: 'Same prerequisites as Installer, plus URL/zip install routes and plugin/theme delete. Per-tool capability checks: <code>delete_plugins</code>, <code>delete_themes</code>. PHP file edits additionally require <code class="font-mono">DISALLOW_FILE_EDIT</code> to be unset (or false) in <code class="font-mono">wp-config.php</code>.',
|
|
fa: 'پیشنیاز Installer بهعلاوهٔ مسیرهای نصب از URL/zip و حذف پلاگین/قالب. Capability موردنیاز: <code>delete_plugins</code>, <code>delete_themes</code>. ویرایش فایل PHP علاوه بر آن نیازمند تنظیم نشدن (یا false بودن) ثابت <code class="font-mono">DISALLOW_FILE_EDIT</code> در <code class="font-mono">wp-config.php</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> داشته باشد — دسترسی نامحدود شامل دستورات سیستمی و مدیریت تیم.'
|
|
}
|
|
}
|
|
};
|
|
|
|
// F.19.2.2 — destructive-tier warnings. Rendered above the credential
|
|
// requirement notice when the user picks ``install`` or ``admin``,
|
|
// so they understand the blast radius before the click sticks. Only
|
|
// these two tiers warrant a strong warning — everything below is
|
|
// recoverable (reads, content edits, theme file writes which
|
|
// optimistic-concurrency on sha256, settings which can be reset).
|
|
// ``install`` is amber: installs reshape the site but the source
|
|
// (wp.org) is curated. ``admin`` is red: arbitrary zip + delete +
|
|
// user CRUD have no undo.
|
|
const TIER_WARNINGS = {
|
|
install: {
|
|
severity: 'amber',
|
|
en: 'Selecting <strong>Installer</strong> grants the AI agent permission to install + activate plugins and themes from wp.org slugs. Source is curated by wp.org, but installations affect the live site immediately. Recommended: test on a staging site first; review installed plugins regularly via <code>wp_plugin_list</code>.',
|
|
fa: 'انتخاب <strong>Installer</strong> به agent اجازه میدهد پلاگین و قالب را با slug از wp.org نصب و فعال کند. منبع wp.org curated است اما نصبها بلافاصله روی سایت زنده اعمال میشوند. توصیه: ابتدا روی سایت staging تست کنید و افزونههای نصبشده را با <code>wp_plugin_list</code> بهطور منظم مرور کنید.'
|
|
},
|
|
admin: {
|
|
severity: 'red',
|
|
en: 'Selecting <strong>Admin</strong> grants the AI agent the full destructive surface: install plugins/themes from arbitrary URLs or base64 zips, delete plugins (drops their data with no undo), delete themes, and user CRUD. Use only on sites you fully control where an AI mistake is recoverable from backups. <strong>Not recommended for production sites with real users.</strong>',
|
|
fa: 'انتخاب <strong>Admin</strong> به agent سطح کامل تخریبی میدهد: نصب پلاگین/قالب از URL یا zip دلخواه، حذف پلاگین (دادهاش بدون undo پاک میشود)، حذف قالب، و CRUD کاربر. فقط روی سایتهایی که کامل کنترل دارید و خطای agent از بکاپ قابل بازیابی است استفاده کنید. <strong>برای سایتهای production با کاربر واقعی توصیه نمیشود.</strong>'
|
|
}
|
|
};
|
|
|
|
function renderScopeNotice() {
|
|
const el = document.getElementById('scope-notice');
|
|
if (!el) return;
|
|
const isFa = {% if lang == 'fa' %}true{% else %}false{% endif %};
|
|
const scopeLabels = {
|
|
read: 'Read', editor: 'Editor', settings: 'Settings', install: 'Installer',
|
|
'read:sensitive': 'Read+Secrets', deploy: 'Deploy', write: 'Write', admin: 'Admin'
|
|
};
|
|
|
|
if (currentScope === 'custom') {
|
|
el.classList.add('hidden');
|
|
el.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const guide = (CREDENTIAL_GUIDES[pluginType] || {})[currentScope];
|
|
const warning = TIER_WARNINGS[currentScope];
|
|
|
|
if (!guide && !warning) {
|
|
el.classList.add('hidden');
|
|
el.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const blocks = [];
|
|
|
|
// Destructive-tier warning first (so eyes hit it before requirements).
|
|
if (warning) {
|
|
const palette = warning.severity === 'red'
|
|
? 'bg-red-50 dark:bg-red-500/10 border-red-300 dark:border-red-500/40 text-red-800 dark:text-red-200'
|
|
: 'bg-amber-50 dark:bg-amber-500/10 border-amber-300 dark:border-amber-500/40 text-amber-800 dark:text-amber-200';
|
|
const icon = warning.severity === 'red' ? '⚠️' : '⚡';
|
|
const heading = isFa ? 'هشدار:' : 'Warning:';
|
|
blocks.push(
|
|
'<div class="' + palette + ' border rounded-lg p-3 mb-2">' +
|
|
'<p class="font-semibold mb-1">' + icon + ' ' + heading + '</p>' +
|
|
'<p>' + (isFa ? warning.fa : warning.en) + '</p>' +
|
|
'</div>'
|
|
);
|
|
}
|
|
|
|
// Credential requirement block.
|
|
if (guide) {
|
|
const title = isFa
|
|
? 'پیشنیاز Credential برای سطح <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:'
|
|
: 'Credential requirement for <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:';
|
|
const body = isFa ? guide.fa : guide.en;
|
|
blocks.push(
|
|
'<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.innerHTML = blocks.join('');
|
|
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. F.19.2.0 — six tiers now in play
|
|
// (read / editor / settings / install / write / admin).
|
|
const groups = {
|
|
read: [], editor: [], settings: [], install: [], 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 %}',
|
|
editor: '{% if lang == "fa" %}ویرایشگر{% else %}Editor{% endif %}',
|
|
settings: '{% if lang == "fa" %}تنظیمات{% else %}Settings{% endif %}',
|
|
install: '{% if lang == "fa" %}نصبکننده{% else %}Installer{% endif %}',
|
|
write: '{% if lang == "fa" %}نوشتن{% else %}Write{% endif %}',
|
|
admin: 'Admin',
|
|
};
|
|
|
|
let visibleCount = 0;
|
|
for (const scope of ['read', 'editor', 'settings', 'install', '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.
|
|
// F.19.2.0 — kept in lockstep with core.tool_access.UNIVERSAL_SCOPE_TIERS.
|
|
const SCOPE_TIERS = {
|
|
read: new Set(['read']),
|
|
editor: new Set(['read', 'editor']),
|
|
settings: new Set(['read', 'editor', 'settings']),
|
|
install: new Set(['read', 'editor', 'settings', 'install']),
|
|
write: new Set(['read', 'editor', 'settings', 'install', 'write']),
|
|
admin: new Set(['read', 'editor', 'settings', 'install', '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');
|
|
const fromV2 = new URLSearchParams(window.location.search).get('from') === 'dashboard-v2';
|
|
link.href = '/dashboard/sites/' + encodeURIComponent(siteId) + '/edit' + (fromV2 ? '?from=dashboard-v2' : '');
|
|
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 %}
|