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