feat(F.7+F.17): v3.11.0 — Coolify plugin (67 tools) + tool access overhaul
Catch-up sync spanning v3.7.0 → v3.11.0 of the internal repo. Platform - Total tools: 565 → 633 (+68) across 10 plugins (Coolify added) - Tests: 481 → 828 passing New plugin: Coolify (67 tools, Track F.17) - Applications (17): CRUD, lifecycle, logs, env vars - Deployments (5): list/get/cancel/deploy, app history - Servers (8): CRUD, resources, domains, validation - Projects (8), Databases (16, 6 DB types + backups), Services (13) Tool access system (Track F.7 → F.7d) - Scope → category mapping with per-tool `category` + `sensitivity` - Schema v7: `site_tool_toggles(site_id)` + `sites.tool_scope` column - Schema v8: per-site API keys (`api_keys.site_id`) - Plugin-specific access-level presets (WP / WC / Gitea / OpenPanel / Coolify 5-tier) - Credential-requirement notice tailored per plugin and tier - Admin Tools count card on service page - Dropped redundant `write` tier on WP / WP Advanced / WooCommerce (admin-scope tool count = 0 → identical to admin tier) Dashboard - Unified site manage page (Connection / Tool Access / Connect) - /dashboard/keys unified (was /api-keys and /connect) - CSRF interceptor via meta-tag; removed conflicting cookie reader - Tailwind: pre-built CSS (scripts/build-css.sh) replaces CDN Docs - README / DOCKER_README / CLAUDE updated to 633 tools / 10 plugins - CHANGELOG entries for v3.7.0 → v3.11.0 - FastMCP compatibility note updated to 3.x (post-v3.5 upgrade) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2913,16 +2913,22 @@ async def dashboard_connect_page(request: Request) -> Response:
|
||||
|
||||
|
||||
async def dashboard_sites_view(request: Request) -> Response:
|
||||
"""GET /dashboard/sites/{id} — Show site connect page with config snippets."""
|
||||
"""GET /dashboard/sites/{id} — Unified site management page (F.7c).
|
||||
|
||||
Combines connection settings, tool access, and connect/config snippets
|
||||
into a single page with 3 sections.
|
||||
"""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
site_id = request.path_params.get("id", "")
|
||||
|
||||
import json
|
||||
|
||||
from core.config_snippets import get_supported_clients
|
||||
from core.site_api import PLUGIN_DISPLAY_NAMES as SITE_PLUGIN_NAMES
|
||||
from core.site_api import get_user_site
|
||||
from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site
|
||||
from core.tool_access import get_scope_presets_for_plugin
|
||||
|
||||
site = await get_user_site(site_id, user_session["user_id"])
|
||||
if site is None:
|
||||
@@ -2936,15 +2942,23 @@ async def dashboard_sites_view(request: Request) -> Response:
|
||||
public_url = os.environ.get("PUBLIC_URL", "http://localhost:8000").rstrip("/")
|
||||
mcp_url = f"{public_url}/u/{user_session['user_id']}/{site['alias']}/mcp"
|
||||
|
||||
plugin_fields = get_user_credential_fields()
|
||||
plugin_names = get_user_plugin_names()
|
||||
scope_presets = get_scope_presets_for_plugin(site["plugin_type"])
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard/sites/view.html",
|
||||
"dashboard/sites/manage.html",
|
||||
{
|
||||
"lang": lang,
|
||||
"t": t,
|
||||
"session": user_session,
|
||||
"site": site,
|
||||
"plugin_names": SITE_PLUGIN_NAMES,
|
||||
"plugin_names": plugin_names,
|
||||
"plugin_fields": plugin_fields,
|
||||
"plugin_fields_json": json.dumps(plugin_fields),
|
||||
"scope_presets": scope_presets,
|
||||
"scope_presets_json": json.dumps(scope_presets),
|
||||
"mcp_url": mcp_url,
|
||||
"clients": get_supported_clients(),
|
||||
"current_page": "my_sites",
|
||||
@@ -3009,7 +3023,9 @@ async def dashboard_keys_unified(request: Request) -> Response:
|
||||
)
|
||||
|
||||
if user_session:
|
||||
# User view — personal keys
|
||||
# User view — personal keys + config snippets
|
||||
from core.config_snippets import get_supported_clients
|
||||
from core.site_api import get_user_sites
|
||||
from core.user_keys import get_user_key_manager
|
||||
|
||||
user_keys = []
|
||||
@@ -3019,6 +3035,8 @@ async def dashboard_keys_unified(request: Request) -> Response:
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
sites = await get_user_sites(user_session["user_id"])
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard/keys/list.html",
|
||||
@@ -3028,6 +3046,8 @@ async def dashboard_keys_unified(request: Request) -> Response:
|
||||
"session": user_session,
|
||||
"is_admin": False,
|
||||
"user_keys": user_keys,
|
||||
"sites": sites,
|
||||
"clients": get_supported_clients(),
|
||||
"current_page": "keys",
|
||||
},
|
||||
)
|
||||
@@ -3153,43 +3173,13 @@ async def api_test_site(request: Request) -> Response:
|
||||
|
||||
|
||||
async def dashboard_sites_edit(request: Request) -> Response:
|
||||
"""GET /dashboard/sites/{id}/edit — Render the Edit Site form."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
"""GET /dashboard/sites/{id}/edit — Redirect to unified site page (F.7c)."""
|
||||
site_id = request.path_params.get("id", "")
|
||||
|
||||
from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site
|
||||
|
||||
site = await get_user_site(site_id, user_session["user_id"])
|
||||
if site is None:
|
||||
return RedirectResponse("/dashboard/sites?error=site_not_found", status_code=302)
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
query_lang = request.query_params.get("lang")
|
||||
lang = detect_language(accept_language, query_lang)
|
||||
t = get_translations(lang)
|
||||
|
||||
import json
|
||||
|
||||
plugin_fields = get_user_credential_fields()
|
||||
plugin_names = get_user_plugin_names()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard/sites/edit.html",
|
||||
{
|
||||
"lang": lang,
|
||||
"t": t,
|
||||
"session": user_session,
|
||||
"site": site,
|
||||
"plugin_fields": plugin_fields,
|
||||
"plugin_fields_json": json.dumps(plugin_fields),
|
||||
"plugin_names": plugin_names,
|
||||
"current_page": "my_sites",
|
||||
},
|
||||
)
|
||||
url = f"/dashboard/sites/{site_id}"
|
||||
if query_lang:
|
||||
url += f"?lang={query_lang}"
|
||||
return RedirectResponse(url, status_code=301)
|
||||
|
||||
|
||||
async def api_update_site(request: Request) -> Response:
|
||||
@@ -3243,11 +3233,25 @@ async def api_create_key(request: Request) -> Response:
|
||||
|
||||
try:
|
||||
key_mgr = get_user_key_manager()
|
||||
|
||||
# Validate site_id if provided (must belong to user)
|
||||
site_id = body.get("site_id")
|
||||
if site_id:
|
||||
from core.database import get_database
|
||||
|
||||
db = get_database()
|
||||
site = await db.get_site(site_id, user_session["user_id"])
|
||||
if site is None:
|
||||
return JSONResponse({"error": "Site not found"}, status_code=404)
|
||||
|
||||
# F.7c: All user keys get full access — tool visibility is controlled
|
||||
# per-site via tool_scope and per-tool toggles.
|
||||
result = await key_mgr.create_key(
|
||||
user_id=user_session["user_id"],
|
||||
name=body.get("name", "Default"),
|
||||
scopes=body.get("scopes", "read write admin"),
|
||||
scopes="read write admin",
|
||||
expires_in_days=body.get("expires_in_days"),
|
||||
site_id=site_id,
|
||||
)
|
||||
return JSONResponse({"key": result})
|
||||
except RuntimeError as e:
|
||||
@@ -3828,9 +3832,9 @@ def register_dashboard_routes(mcp):
|
||||
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add)
|
||||
mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit)
|
||||
mcp.custom_route("/dashboard/sites/{id}", methods=["GET"])(dashboard_sites_view)
|
||||
# /dashboard/connect → /dashboard/keys (301)
|
||||
# /dashboard/connect → /dashboard/sites (F.7c: sites page has connect info now)
|
||||
mcp.custom_route("/dashboard/connect", methods=["GET"])(
|
||||
lambda r: RedirectResponse("/dashboard/keys", status_code=301)
|
||||
lambda r: RedirectResponse("/dashboard/sites", status_code=301)
|
||||
)
|
||||
|
||||
# Service pages (F.3)
|
||||
|
||||
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
|
||||
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
|
||||
|
||||
# Schema version — increment when adding migrations
|
||||
SCHEMA_VERSION = 7
|
||||
SCHEMA_VERSION = 8
|
||||
|
||||
# Initial schema DDL
|
||||
_SCHEMA_SQL = """\
|
||||
@@ -83,7 +83,8 @@ CREATE TABLE IF NOT EXISTS user_api_keys (
|
||||
last_used TEXT,
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT
|
||||
expires_at TEXT,
|
||||
site_id TEXT
|
||||
);
|
||||
|
||||
-- WP plugin connection tokens (short-lived, for MCP Connect plugin)
|
||||
@@ -165,6 +166,10 @@ _MIGRATIONS: dict[int, str] = {
|
||||
"ON site_tool_toggles(site_id);\n"
|
||||
"ALTER TABLE sites ADD COLUMN tool_scope TEXT NOT NULL DEFAULT 'admin';\n"
|
||||
),
|
||||
8: (
|
||||
# F.7c: per-site API keys — allow keys scoped to a single site
|
||||
"ALTER TABLE user_api_keys ADD COLUMN site_id TEXT;\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -682,6 +687,7 @@ class Database:
|
||||
name: str,
|
||||
scopes: str = "read write",
|
||||
expires_at: str | None = None,
|
||||
site_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new user API key.
|
||||
|
||||
@@ -692,6 +698,7 @@ class Database:
|
||||
name: Human label (e.g. "Claude Desktop").
|
||||
scopes: Space-separated scopes.
|
||||
expires_at: Optional ISO 8601 expiry timestamp.
|
||||
site_id: Optional site UUID to scope key to a single site.
|
||||
|
||||
Returns:
|
||||
The created API key row as a dict.
|
||||
@@ -701,14 +708,14 @@ class Database:
|
||||
|
||||
await self.execute(
|
||||
"INSERT INTO user_api_keys "
|
||||
"(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at),
|
||||
"(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at, site_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at, site_id),
|
||||
)
|
||||
|
||||
result = await self.fetchone(
|
||||
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
|
||||
"created_at, expires_at FROM user_api_keys WHERE id = ?",
|
||||
"created_at, expires_at, site_id FROM user_api_keys WHERE id = ?",
|
||||
(key_id,),
|
||||
)
|
||||
if result is None:
|
||||
@@ -726,7 +733,7 @@ class Database:
|
||||
"""
|
||||
return await self.fetchall(
|
||||
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
|
||||
"created_at, expires_at FROM user_api_keys WHERE user_id = ? ORDER BY created_at",
|
||||
"created_at, expires_at, site_id FROM user_api_keys WHERE user_id = ? ORDER BY created_at",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
|
||||
"label": "Client ID",
|
||||
"type": "text",
|
||||
"required": True,
|
||||
"hint": "OpenPanel Dashboard → Settings → Clients → Create client with 'root' mode for full access",
|
||||
"hint": "Open your project on dashboard.openpanel.dev → Settings → Clients (URL: dashboard.openpanel.dev/{org}/{project-id}/settings/clients). Create a client in 'root' mode for full access.",
|
||||
},
|
||||
{
|
||||
"name": "client_secret",
|
||||
|
||||
@@ -111,9 +111,6 @@
|
||||
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
||||
<span class="{% if lang == 'fa' %}mr-3{% else %}ml-3{% endif %} font-bold text-lg text-gray-900 dark:text-white">MCP Hub</span>
|
||||
</div>
|
||||
<div x-show="!sidebarOpen" class="flex items-center justify-center">
|
||||
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
||||
</div>
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300">
|
||||
<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"
|
||||
|
||||
@@ -185,12 +185,13 @@
|
||||
<td class="px-6 py-4 text-gray-900 dark:text-white">{{ key.name }}</td>
|
||||
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 font-mono text-sm">mhu_{{ key.key_prefix }}...</td>
|
||||
<td class="px-6 py-4">
|
||||
{% set scopes = (key.scopes or 'read write admin').split() %}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for s in scopes %}
|
||||
<span class="px-2 py-0.5 {% if s == 'admin' %}bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400{% elif s == 'write' %}bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400{% elif s == 'deploy' %}bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400{% else %}bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400{% endif %} text-xs rounded font-medium">{{ s }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if key.site_id %}
|
||||
<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 text-xs rounded font-medium">
|
||||
{% for site in sites %}{% if site.id == key.site_id %}{{ site.alias }}{% endif %}{% endfor %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-0.5 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-xs rounded font-medium">{% if lang == 'fa' %}همه سرویسها{% else %}All Services{% endif %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm">{{ key.use_count }}</td>
|
||||
<td class="px-6 py-4">
|
||||
@@ -208,6 +209,100 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not is_admin %}
|
||||
<!-- Configuration Snippet -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}نمونه کد پیکربندی{% else %}Configuration Snippet{% endif %}
|
||||
</h3>
|
||||
|
||||
{% if sites %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}سایت{% else %}Site{% endif %}</label>
|
||||
<select id="config-site" onchange="updateConfig()"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||
{% for site in sites %}
|
||||
<option value="{{ site.alias }}" data-site-id="{{ site.id }}" data-plugin="{{ site.plugin_type }}">{{ site.alias }} ({{ site.plugin_type }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}کلاینت{% else %}Client{% 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">
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}">{{ client.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-[120px]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</pre>
|
||||
<button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else 'right-2' }} text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors" id="copy-config-btn">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
|
||||
</div>
|
||||
|
||||
<!-- Transport type info -->
|
||||
<div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>نکته:</strong> تنظیمات بالا شامل نوع اتصال (<code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code) میباشد. از <code>sse</code> استفاده نکنید — باعث خطای <code>400 Bad Request</code> میشود.
|
||||
{% else %}
|
||||
<strong>Note:</strong> Config above includes the correct transport type (<code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code). Do not use <code>sse</code> — it will cause <code>400 Bad Request</code> errors.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- WordPress SEO plugin note -->
|
||||
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
|
||||
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
|
||||
را روی سایت وردپرسی خود نصب کنید.
|
||||
{% else %}
|
||||
<strong>WordPress:</strong> For SEO tools, install the
|
||||
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
|
||||
plugin on your WordPress site.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Connection tip for web-based clients -->
|
||||
<div id="connection-tip" style="display:none" class="mt-3 bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4">
|
||||
<p class="text-sm text-green-700 dark:text-green-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال میتوانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
|
||||
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
|
||||
{% else %}
|
||||
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
|
||||
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- API Key hint for desktop/CLI clients -->
|
||||
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>API Key:</strong> از بخش API Keys بالا بسازید و مقدار آن را در تنظیمات وارد کنید:
|
||||
{% else %}
|
||||
<strong>API Key:</strong> Create one above and use it in your config:
|
||||
{% endif %}
|
||||
</p>
|
||||
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{% if lang == 'fa' %}ابتدا یک سایت اضافه کنید.{% else %}Add a site first to see configuration snippets.{% endif %}
|
||||
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -267,16 +362,26 @@
|
||||
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 focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}</label>
|
||||
<select id="new-key-scope" 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">
|
||||
<option value="read">Read — {% if lang == 'fa' %}فقط خواندن{% else %}view-only{% endif %}</option>
|
||||
<option value="read:sensitive">Read + Sensitive — {% if lang == 'fa' %}خواندن + داده حساس{% else %}logs, envs{% endif %}</option>
|
||||
<option value="deploy">Deploy — {% if lang == 'fa' %}start/stop/restart{% else %}start/stop/restart{% endif %}</option>
|
||||
<option value="write">Write — {% if lang == 'fa' %}نوشتن + lifecycle{% else %}create/update + lifecycle{% endif %}</option>
|
||||
<option value="read write admin" selected>Full Access — {% if lang == 'fa' %}دسترسی کامل{% else %}all tools{% endif %}</option>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}محدوده سرویس{% else %}Service Scope{% endif %}</label>
|
||||
<select id="new-key-site"
|
||||
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">
|
||||
<option value="">{% if lang == 'fa' %}همه سرویسها (عمومی){% else %}All Services (global){% endif %}</option>
|
||||
{% if sites %}
|
||||
{% for site in sites %}
|
||||
<option value="{{ site.id }}">{{ site.alias }} ({{ site.plugin_type }})</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1.5">
|
||||
{% if lang == 'fa' %}فیلتر ابزار هر سایت در تنظیمات سایت انجام میشود.{% else %}Per-site tool filters are set in Site Settings.{% endif %}
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{% if lang == 'fa' %}کلید اختصاصی فقط برای سرویس انتخابشده قابل استفاده است.{% else %}A site-scoped key only works for the selected service.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="px-3 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg">
|
||||
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
|
||||
</span>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{% if lang == 'fa' %}فیلتر ابزار هر سایت در <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline hover:text-gray-300">تنظیمات سایت</a> انجام میشود.{% else %}Tool access is controlled per-site in <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline hover:text-gray-300">Site Settings</a>.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,6 +472,16 @@
|
||||
function openCreateModal() {
|
||||
document.getElementById('createModal').classList.remove('hidden');
|
||||
document.getElementById('createModal').classList.add('flex');
|
||||
// F.7d: pre-select site from ?site= query param when present
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const siteParam = params.get('site');
|
||||
if (siteParam) {
|
||||
const siteSelect = document.getElementById('new-key-site');
|
||||
if (siteSelect) {
|
||||
const match = Array.from(siteSelect.options).find(o => o.value === siteParam);
|
||||
if (match) siteSelect.value = siteParam;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.openCreateModal = openCreateModal;
|
||||
|
||||
@@ -421,11 +536,13 @@
|
||||
} else {
|
||||
// User: POST to /api/keys
|
||||
const name = document.getElementById('new-key-name').value.trim();
|
||||
const scope = document.getElementById('new-key-scope').value;
|
||||
if (!name) { alert(lang === 'fa' ? 'نام کلید الزامی است' : 'Key name is required'); return; }
|
||||
const siteId = document.getElementById('new-key-site')?.value || null;
|
||||
const payload = { name, scopes: 'read write admin' };
|
||||
if (siteId) payload.site_id = siteId;
|
||||
const r = await fetch('/api/keys', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, scopes: scope }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const res = await r.json();
|
||||
if (!r.ok) { alert(res.error || 'Failed'); return; }
|
||||
@@ -488,6 +605,72 @@
|
||||
else { const d = await r.json(); alert(d.error || 'Failed'); }
|
||||
};
|
||||
{% endif %}
|
||||
|
||||
// ── Config Snippets (user view) ────────────────────────────────
|
||||
{% if not is_admin %}
|
||||
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
|
||||
const WP_TYPES = ['wordpress', 'woocommerce'];
|
||||
|
||||
async function updateConfig() {
|
||||
const siteSelect = document.getElementById('config-site');
|
||||
const alias = siteSelect?.value;
|
||||
const client = document.getElementById('config-client')?.value;
|
||||
if (!alias || !client) return;
|
||||
|
||||
const isWebClient = WEB_CLIENTS.includes(client);
|
||||
|
||||
const transportNote = document.getElementById('transport-note');
|
||||
if (transportNote) transportNote.style.display = isWebClient ? 'none' : '';
|
||||
|
||||
const connTip = document.getElementById('connection-tip');
|
||||
if (connTip) connTip.style.display = isWebClient ? '' : 'none';
|
||||
|
||||
const bearerHint = document.getElementById('bearer-hint');
|
||||
if (bearerHint) bearerHint.style.display = isWebClient ? 'none' : '';
|
||||
|
||||
const selectedOption = siteSelect.options[siteSelect.selectedIndex];
|
||||
const pluginType = selectedOption?.dataset?.plugin || '';
|
||||
const wpNote = document.getElementById('wp-seo-note');
|
||||
if (wpNote) wpNote.style.display = WP_TYPES.includes(pluginType) ? '' : 'none';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/config/' + alias + '?client=' + client);
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
document.getElementById('config-output').textContent = data.config;
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('config-output').textContent = 'Error loading config';
|
||||
}
|
||||
}
|
||||
window.updateConfig = updateConfig;
|
||||
|
||||
function copyConfig() {
|
||||
const text = document.getElementById('config-output').textContent;
|
||||
navigator.clipboard.writeText(text);
|
||||
const btn = document.getElementById('copy-config-btn');
|
||||
btn.textContent = lang === 'fa' ? 'کپی شد!' : 'Copied!';
|
||||
setTimeout(() => btn.textContent = lang === 'fa' ? 'کپی' : 'Copy', 2000);
|
||||
}
|
||||
window.copyConfig = copyConfig;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// F.7d: support ?site=<id-or-alias> from /dashboard/sites/{id} link
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const siteParam = params.get('site');
|
||||
const siteSelect = document.getElementById('config-site');
|
||||
if (siteParam && siteSelect) {
|
||||
// Match either the option value (alias) or its data-site-id attribute
|
||||
const match = Array.from(siteSelect.options).find(
|
||||
o => o.value === siteParam || o.dataset?.siteId === siteParam
|
||||
);
|
||||
if (match) siteSelect.value = match.value;
|
||||
}
|
||||
if (siteSelect) updateConfig();
|
||||
// F.7d: auto-open create modal when ?create=1 (e.g. from Connect section)
|
||||
if (params.get('create') === '1') openCreateModal();
|
||||
});
|
||||
{% endif %}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -220,24 +220,14 @@ https://claude.com/api/mcp/auth_callback</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="scopes" value="read" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">read</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="scopes" value="write" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">write</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="scopes" value="admin" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">admin</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="px-3 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg">
|
||||
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}دسترسی ابزار در تنظیمات هر سایت کنترل میشود.{% else %}Tool access is controlled per-site in Site Settings.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{% if lang == 'fa' %}admin: دسترسی کامل به همه عملیات{% else %}admin: Full access to all operations{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
@@ -426,10 +416,8 @@ https://claude.com/api/mcp/auth_callback</textarea>
|
||||
e.preventDefault();
|
||||
|
||||
var formData = new FormData(this);
|
||||
var scopes = [];
|
||||
document.querySelectorAll('input[name="scopes"]:checked').forEach(function(cb) {
|
||||
scopes.push(cb.value);
|
||||
});
|
||||
// F.7c: All clients get full scope — tool access controlled per-site
|
||||
var scopes = ['read', 'write', 'admin'];
|
||||
|
||||
// Parse redirect URIs (one per line)
|
||||
var redirectUrisText = formData.get('redirect_uris') || '';
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Tailwind CSS (pre-built) -->
|
||||
<link rel="stylesheet" href="/static/tailwind.min.css">
|
||||
|
||||
<!-- HTMX for dynamic updates -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
@@ -13,34 +13,6 @@
|
||||
<!-- Alpine.js for simple interactions -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Custom Tailwind Config -->
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'vazirmatn': ['Vazirmatn', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f5f3ff',
|
||||
100: '#ede9fe',
|
||||
200: '#ddd6fe',
|
||||
300: '#c4b5fd',
|
||||
400: '#a78bfa',
|
||||
500: '#8b5cf6',
|
||||
600: '#7c3aed',
|
||||
700: '#6d28d9',
|
||||
800: '#5b21b6',
|
||||
900: '#4c1d95',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Theme Initialization -->
|
||||
<script>
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{% set admin_tools = service.tools|selectattr('scope', 'equalto', 'admin')|list %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-{{ 4 if admin_tools|length > 0 else 3 }} gap-6">
|
||||
<!-- Tools Count -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -112,6 +113,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Tools (F.7d) -->
|
||||
{% if admin_tools|length > 0 %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}ابزار مدیر{% else %}Admin Tools{% endif %}
|
||||
</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ admin_tools|length }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Setup Requirements -->
|
||||
|
||||
@@ -42,13 +42,17 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{{ svc.tools_count }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
||||
<span>·</span>
|
||||
{% set read_count = svc.tools|selectattr('scope', 'equalto', 'read')|list|length %}
|
||||
{% set write_count = svc.tools|selectattr('scope', 'equalto', 'write')|list|length %}
|
||||
{% set admin_count = svc.tools|selectattr('scope', 'equalto', 'admin')|list|length %}
|
||||
<span class="text-green-600 dark:text-green-400">{{ read_count }} read</span>
|
||||
<span class="text-orange-600 dark:text-orange-400">{{ write_count }} write</span>
|
||||
{% if admin_count > 0 %}
|
||||
<span class="text-red-600 dark:text-red-400">{{ admin_count }} admin</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
{% block page_title %}{{ t.add_site }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<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">
|
||||
@@ -18,55 +19,119 @@
|
||||
<!-- 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="add-site-form" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
|
||||
<!-- Plugin Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||
<select id="plugin_type" name="plugin_type" required
|
||||
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 focus:border-transparent"
|
||||
onchange="updateFields()">
|
||||
<option value="">{{ t.select_plugin }}</option>
|
||||
{% for ptype, pname in plugin_names.items() %}
|
||||
<option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Section 1: Add Site / Connect Service -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="toggleSection('addsite')" 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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}اتصال سرویس{% else %}Connect Service{% endif %}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{% if lang == 'fa' %}نوع پلاگین و اطلاعات اتصال{% else %}Plugin type and connection details{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg id="addsite-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="addsite-section" class="border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||
<form id="add-site-form" class="space-y-4">
|
||||
<!-- Plugin Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||
<select id="plugin_type" name="plugin_type" required
|
||||
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 focus:border-transparent"
|
||||
onchange="updateFields()">
|
||||
<option value="">{{ t.select_plugin }}</option>
|
||||
{% for ptype, pname in plugin_names.items() %}
|
||||
<option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 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 placeholder="https://example.com"
|
||||
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">
|
||||
<p id="url-hint" class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden"></p>
|
||||
</div>
|
||||
<!-- 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 placeholder="https://example.com"
|
||||
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">
|
||||
<p id="url-hint" class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden"></p>
|
||||
</div>
|
||||
|
||||
<!-- Alias -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||
<input type="text" id="alias" name="alias" required placeholder="myblog" pattern="[a-zA-Z0-9_-]+"
|
||||
minlength="2" maxlength="50"
|
||||
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">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t.site_alias_hint }}</p>
|
||||
</div>
|
||||
<!-- Alias -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||
<input type="text" id="alias" name="alias" required placeholder="myblog" pattern="[a-zA-Z0-9_-]+"
|
||||
minlength="2" maxlength="50"
|
||||
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">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t.site_alias_hint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Credential Fields -->
|
||||
<div id="credential-fields" class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>
|
||||
</div>
|
||||
<!-- Dynamic Credential Fields -->
|
||||
<div id="credential-fields" class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex items-center gap-4 pt-4">
|
||||
<button type="submit" id="submit-btn"
|
||||
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
|
||||
{{ t.add_site }}
|
||||
</button>
|
||||
<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">
|
||||
{{ t.cancel }}
|
||||
</a>
|
||||
<!-- Submit -->
|
||||
<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.add_site }}
|
||||
</button>
|
||||
<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 text-sm">
|
||||
{{ t.cancel }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Section 2: What happens next (info card) -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="toggleSection('info')" 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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}مراحل بعدی{% else %}What happens next{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<svg id="info-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="info-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">1</span>
|
||||
<p>{% if lang == 'fa' %}سرویس خود را با انتخاب نوع پلاگین و وارد کردن URL و اعتبارنامه اضافه کنید.{% else %}Add your service by selecting the plugin type and entering the URL and credentials.{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">2</span>
|
||||
<p>{% if lang == 'fa' %}سطح دسترسی ابزارها و تنظیمات اتصال را از صفحه مدیریت سایت پیکربندی کنید.{% else %}Configure tool access levels and connection settings from the site management page.{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">3</span>
|
||||
<p>{% if lang == 'fa' %}یک کلید API بسازید و از قطعه کد پیکربندی برای اتصال MCP client خود استفاده کنید.{% else %}Create an API key and use the configuration snippet to connect your MCP client.{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -79,6 +144,14 @@
|
||||
'supabase': 'Your Supabase project URL (e.g., https://xxxxx.supabase.co)',
|
||||
};
|
||||
|
||||
// ── 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');
|
||||
}
|
||||
|
||||
function updateFields() {
|
||||
const ptype = document.getElementById('plugin_type').value;
|
||||
const container = document.getElementById('credential-fields');
|
||||
@@ -131,7 +204,7 @@
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Auto-fill credential fields if plugin_type is pre-selected (e.g., from service page)
|
||||
// Auto-fill credential fields if plugin_type is pre-selected
|
||||
if (document.getElementById('plugin_type').value) {
|
||||
updateFields();
|
||||
}
|
||||
@@ -171,7 +244,13 @@
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||
// Redirect to manage page for the new site
|
||||
const siteId = data.site?.id || data.site_id;
|
||||
if (siteId) {
|
||||
window.location.href = '/dashboard/sites/' + siteId + '{% if lang and lang != "en" %}?lang={{ lang }}{% endif %}';
|
||||
} else {
|
||||
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||
}
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Failed to add site';
|
||||
errorEl.classList.remove('hidden');
|
||||
|
||||
@@ -229,11 +229,6 @@
|
||||
'system': 'System',
|
||||
};
|
||||
|
||||
function getCsrf() {
|
||||
const m = document.cookie.match(/(?:^|;\s*)dashboard_csrf=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
|
||||
async function loadToolAccess() {
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tools');
|
||||
@@ -269,7 +264,7 @@
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tool-scope', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'x-csrf-token': getCsrf() },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scope }),
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -349,7 +344,7 @@
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'x-csrf-token': getCsrf() },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!r.ok) { input.checked = !enabled; }
|
||||
|
||||
768
core/templates/dashboard/sites/manage.html
Normal file
768
core/templates/dashboard/sites/manage.html
Normal file
@@ -0,0 +1,768 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ site.alias }} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{{ site.alias }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ site.alias }}
|
||||
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300">
|
||||
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Section 1: Connection Settings -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="toggleSection('connection')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}تنظیمات اتصال{% else %}Connection Settings{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<svg id="connection-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="connection-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||
<!-- Info notice -->
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-700 dark:text-blue-300 px-4 py-3 rounded-lg text-sm">
|
||||
{{ t.keep_existing }}
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
|
||||
|
||||
<form id="edit-site-form" class="space-y-4">
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
|
||||
<input type="url" id="url" name="url" required value="{{ site.url }}"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
|
||||
<!-- Credential Fields -->
|
||||
<div id="credential-fields" class="space-y-4">
|
||||
{% set fields = plugin_fields.get(site.plugin_type, []) %}
|
||||
{% for field in fields %}
|
||||
{% if not field.get('advanced', False) %}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{{ field.label }}{% if field.required %} *{% endif %}
|
||||
</label>
|
||||
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||
placeholder="{% if field.required %}{{ t.keep_existing }}{% else %}{{ t.leave_blank_to_clear }}{% endif %}"
|
||||
data-required="{{ field.required | lower }}"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
{% if field.hint %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% set has_advanced = fields | map(attribute='advanced') | select | list | length > 0 if fields else false %}
|
||||
{% if has_advanced %}
|
||||
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
|
||||
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
|
||||
Advanced Settings
|
||||
</summary>
|
||||
<div class="px-4 pb-4 pt-2 space-y-4">
|
||||
{% for field in fields %}
|
||||
{% if field.get('advanced', False) %}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{{ field.label }}</label>
|
||||
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||
placeholder="{{ t.leave_blank_to_clear }}" data-required="false"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
{% if field.hint %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Test + Save buttons -->
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button type="submit" id="submit-btn"
|
||||
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm font-medium disabled:opacity-50 transition-colors">
|
||||
{{ t.save }}
|
||||
</button>
|
||||
<button type="button" id="test-btn" onclick="testConnection()"
|
||||
class="px-5 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">
|
||||
{% if lang == 'fa' %}تست اتصال{% else %}Test Connection{% endif %}
|
||||
</button>
|
||||
<span id="test-status" class="text-sm"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Section 2: Tool Access -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="toggleSection('tools')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}دسترسی به ابزارها{% else %}Tool Access{% endif %}
|
||||
</h3>
|
||||
<p id="tool-summary" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"></p>
|
||||
</div>
|
||||
</div>
|
||||
<svg id="tools-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="tools-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||
<div id="tool-access-loading" class="text-gray-400 dark:text-gray-500 text-sm">
|
||||
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
||||
</div>
|
||||
<div id="tool-access-content" class="hidden space-y-4">
|
||||
<!-- Scope tier selector (F.7d: rendered from plugin-specific presets) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{% if lang == 'fa' %}سطح دسترسی{% else %}Access Level{% endif %}
|
||||
</label>
|
||||
<div id="scope-tiers" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-{{ scope_presets|length }} gap-2">
|
||||
{% for preset in scope_presets %}
|
||||
<button type="button" data-scope="{{ preset.value }}" onclick="selectScope('{{ preset.value }}')"
|
||||
class="scope-btn px-3 py-2.5 rounded-lg border-2 text-sm font-medium transition-all text-center">
|
||||
<div class="font-semibold">{% if lang == 'fa' %}{{ preset.label_fa }}{% else %}{{ preset.label }}{% endif %}</div>
|
||||
<div class="text-xs opacity-70 mt-0.5">{% if lang == 'fa' %}{{ preset.hint_fa }}{% else %}{{ preset.hint }}{% endif %}</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="scope-status" class="hidden text-xs mt-2"></div>
|
||||
<div id="scope-notice" class="hidden mt-3 rounded-lg p-3 text-xs"></div>
|
||||
</div>
|
||||
|
||||
<!-- Per-tool toggles (visible for all tiers, expanded for custom) -->
|
||||
<div id="tool-overrides-wrapper">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}
|
||||
<span id="tool-count" class="text-gray-400 font-normal"></span>
|
||||
</p>
|
||||
<input type="text" id="tool-search" placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search...{% endif %}"
|
||||
class="w-48 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div id="tool-list" class="space-y-1 max-h-80 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Section 3: Connect -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="toggleSection('connect')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}اتصال{% else %}Connect{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<svg id="connect-chevron" class="w-5 h-5 text-gray-400 transition-transform rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="connect-section" class="border-t border-gray-200 dark:border-gray-700 p-6 space-y-5">
|
||||
<!-- MCP Endpoint URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{% if lang == 'fa' %}آدرس MCP{% else %}MCP Endpoint{% endif %}
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="mcp-url" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto">{{ mcp_url }}</code>
|
||||
<button onclick="copyText('mcp-url')" class="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
|
||||
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
|
||||
{% if lang == 'fa' %}برای احراز هویت از کلید API یا OAuth استفاده کنید.{% else %}Authenticate with an API key (Bearer token) or OAuth.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Config Snippets -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{% if lang == 'fa' %}نمونه کد پیکربندی{% else %}Configuration Snippet{% endif %}
|
||||
</label>
|
||||
<select id="config-client" onchange="updateConfig()"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 mb-3">
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}">{{ client.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="relative">
|
||||
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[80px]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</pre>
|
||||
<button onclick="copyConfig()" id="copy-config-btn"
|
||||
class="absolute top-2 right-2 text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors">
|
||||
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
<div id="transport-note" class="mt-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
{% if lang == 'fa' %}<strong>نکته:</strong> از <code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code استفاده کنید.
|
||||
{% else %}<strong>Note:</strong> Use <code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick API Key Create -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}کلید API{% else %}API Key{% endif %}
|
||||
</label>
|
||||
<a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-xs text-blue-500 hover:text-blue-400">
|
||||
{% if lang == 'fa' %}مدیریت کلیدها{% else %}Manage Keys{% endif %} →
|
||||
</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 %}
|
||||
3
core/templates/static/src/input.css
Normal file
3
core/templates/static/src/input.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
1
core/templates/static/tailwind.min.css
vendored
Normal file
1
core/templates/static/tailwind.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,23 +1,17 @@
|
||||
"""Tool access manager — site-scoped visibility and per-site toggles (F.7b).
|
||||
"""Tool access manager — site-scoped visibility and per-site toggles (F.7c).
|
||||
|
||||
Provides a central pipeline that filters the set of MCP tools presented for
|
||||
a user endpoint based on:
|
||||
|
||||
1. **Scope → category mapping.** Every ``ToolDefinition`` carries a
|
||||
``category`` field (e.g. ``read``, ``lifecycle``, ``crud``, ``system``).
|
||||
An API key's declared scopes **and** the site's stored ``tool_scope``
|
||||
preset each map to a set of allowed categories via
|
||||
:data:`SCOPE_TO_CATEGORIES`. A tool is visible only if its category is in
|
||||
the intersection — the narrower of the two layers wins.
|
||||
1. **Universal scope tiers.** A 3-level system (``read`` / ``write`` /
|
||||
``admin``) that works across ALL plugins using the tool's
|
||||
``required_scope`` field. For Coolify (which has fine-grained
|
||||
``category`` annotations) the legacy category mapping is kept as an
|
||||
overlay for the ``custom`` preset.
|
||||
2. **Per-site tool toggles.** Site owners may explicitly disable specific
|
||||
tools via the ``site_tool_toggles`` table. Only overrides are stored —
|
||||
tools via the ``site_tool_toggles`` table. Only overrides are stored —
|
||||
tools without an entry are enabled by default.
|
||||
|
||||
Tools whose ``category`` is not in :data:`KNOWN_CATEGORIES` are **always
|
||||
visible** (backward compatibility — legacy plugins that have not been
|
||||
annotated yet default to ``category="read"``, which belongs to the ``read``
|
||||
scope set anyway, but an unknown value would be preserved).
|
||||
|
||||
The ``tool_scope`` value ``"custom"`` is a sentinel meaning "do not apply a
|
||||
site-level preset filter" — in that case only the per-tool toggles and the
|
||||
key scope are considered.
|
||||
@@ -44,9 +38,17 @@ from core.tool_registry import ToolDefinition
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Universal 3-tier scope system (F.7c) ─────────────────────────────
|
||||
# Maps a scope tier to the set of ``required_scope`` values it may access.
|
||||
# Works for ALL plugins because every tool has ``required_scope``.
|
||||
UNIVERSAL_SCOPE_TIERS: dict[str, set[str]] = {
|
||||
"read": {"read"},
|
||||
"write": {"read", "write"},
|
||||
"admin": {"read", "write", "admin"},
|
||||
}
|
||||
|
||||
# ── Legacy Coolify category mapping (kept for ``custom`` overlay) ─────
|
||||
# Mapping from scope → set of tool categories that scope may see.
|
||||
# Used for BOTH API-key scopes and per-site ``tool_scope`` presets.
|
||||
# Scopes are additive: presenting multiple scopes yields the union.
|
||||
SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
|
||||
"read": {"read"},
|
||||
"read:sensitive": {"read", "read_sensitive", "backup"},
|
||||
@@ -63,8 +65,7 @@ SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
|
||||
},
|
||||
}
|
||||
|
||||
# All known categories — any tool whose category is outside this set is
|
||||
# treated as "always visible" for backward compatibility.
|
||||
# All known Coolify categories.
|
||||
KNOWN_CATEGORIES: set[str] = {
|
||||
"read",
|
||||
"read_sensitive",
|
||||
@@ -78,6 +79,9 @@ KNOWN_CATEGORIES: set[str] = {
|
||||
# Sentinel meaning "no site-level preset filter — use per-tool toggles only".
|
||||
SCOPE_CUSTOM = "custom"
|
||||
|
||||
# Plugins that have fine-grained category annotations.
|
||||
_CATEGORY_PLUGINS: set[str] = {"coolify"}
|
||||
|
||||
|
||||
def scopes_to_categories(scopes: list[str]) -> set[str]:
|
||||
"""Return the union of categories allowed by the given scope list.
|
||||
@@ -94,6 +98,197 @@ def scopes_to_categories(scopes: list[str]) -> set[str]:
|
||||
return allowed
|
||||
|
||||
|
||||
def _scopes_to_required(scopes: list[str]) -> set[str]:
|
||||
"""Return the union of ``required_scope`` values allowed by universal tiers."""
|
||||
allowed: set[str] = set()
|
||||
for scope in scopes:
|
||||
allowed |= UNIVERSAL_SCOPE_TIERS.get(scope.strip(), set())
|
||||
return allowed
|
||||
|
||||
|
||||
def get_scope_presets_for_plugin(plugin_type: str) -> list[dict[str, str]]:
|
||||
"""Return the appropriate scope presets for a plugin type (F.7d).
|
||||
|
||||
Each preset is a dict with ``value`` (the canonical scope key persisted
|
||||
to ``sites.tool_scope``), ``label`` / ``label_fa`` (button title), and
|
||||
``hint`` / ``hint_fa`` (one-line description shown under the title).
|
||||
|
||||
The valid scope values are constrained by ``_VALID_TOOL_SCOPES`` in
|
||||
``core.dashboard.routes`` — currently:
|
||||
``{"read", "read:sensitive", "deploy", "write", "admin", "custom"}``.
|
||||
"""
|
||||
custom = {
|
||||
"value": "custom",
|
||||
"label": "Custom",
|
||||
"label_fa": "سفارشی",
|
||||
"hint": "Per-tool toggles",
|
||||
"hint_fa": "هر ابزار جداگانه",
|
||||
}
|
||||
|
||||
if plugin_type == "coolify":
|
||||
# 5 fine-grained Coolify tiers + custom (matches SCOPE_TO_CATEGORIES).
|
||||
return [
|
||||
{
|
||||
"value": "read",
|
||||
"label": "Read",
|
||||
"label_fa": "خواندن",
|
||||
"hint": "List/inspect resources",
|
||||
"hint_fa": "مشاهده و فهرست منابع",
|
||||
},
|
||||
{
|
||||
"value": "read:sensitive",
|
||||
"label": "Read + Secrets",
|
||||
"label_fa": "خواندن + اسرار",
|
||||
"hint": "Includes env vars and backups",
|
||||
"hint_fa": "شامل متغیرهای محیطی و بکاپ",
|
||||
},
|
||||
{
|
||||
"value": "deploy",
|
||||
"label": "Deploy",
|
||||
"label_fa": "استقرار",
|
||||
"hint": "Read + lifecycle (start/stop/restart)",
|
||||
"hint_fa": "مشاهده + راهاندازی/توقف/ریستارت",
|
||||
},
|
||||
{
|
||||
"value": "write",
|
||||
"label": "Write",
|
||||
"label_fa": "نوشتن",
|
||||
"hint": "Read + lifecycle + CRUD + env",
|
||||
"hint_fa": "مشاهده + لایفسایکل + CRUD + env",
|
||||
},
|
||||
{
|
||||
"value": "admin",
|
||||
"label": "Root",
|
||||
"label_fa": "روت",
|
||||
"hint": "Everything including system commands",
|
||||
"hint_fa": "همه چیز شامل دستورات سیستم",
|
||||
},
|
||||
custom,
|
||||
]
|
||||
|
||||
if plugin_type == "openpanel":
|
||||
return [
|
||||
{
|
||||
"value": "read",
|
||||
"label": "Read",
|
||||
"label_fa": "خواندن",
|
||||
"hint": "Export current project only",
|
||||
"hint_fa": "خروجی پروژه فعلی",
|
||||
},
|
||||
{
|
||||
"value": "write",
|
||||
"label": "Write",
|
||||
"label_fa": "نوشتن",
|
||||
"hint": "Default ingestion (track events)",
|
||||
"hint_fa": "ارسال رویداد (ingestion)",
|
||||
},
|
||||
{
|
||||
"value": "admin",
|
||||
"label": "Root",
|
||||
"label_fa": "روت",
|
||||
"hint": "Export any project",
|
||||
"hint_fa": "خروجی هر پروژه",
|
||||
},
|
||||
custom,
|
||||
]
|
||||
|
||||
if plugin_type == "woocommerce":
|
||||
# WooCommerce has no admin-scope tools (read=14, write=14, admin=0),
|
||||
# so Write and "Read + Write" tiers are identical. Present a single
|
||||
# full-access tier instead of two duplicates.
|
||||
return [
|
||||
{
|
||||
"value": "read",
|
||||
"label": "Read Only",
|
||||
"label_fa": "فقط خواندن",
|
||||
"hint": "Browse products, orders, customers",
|
||||
"hint_fa": "مشاهده محصولات، سفارشها و مشتریان",
|
||||
},
|
||||
{
|
||||
"value": "admin",
|
||||
"label": "Read + Write",
|
||||
"label_fa": "خواندن + نوشتن",
|
||||
"hint": "Full store management (all 28 tools)",
|
||||
"hint_fa": "مدیریت کامل فروشگاه (همه ۲۸ ابزار)",
|
||||
},
|
||||
custom,
|
||||
]
|
||||
|
||||
if plugin_type in {"wordpress", "wordpress_advanced"}:
|
||||
# WordPress has no admin-scope tools (read=27, write=40, admin=0).
|
||||
# SEO + plugin/theme tools require the Airano MCP SEO Bridge plugin
|
||||
# to be installed on the WP site itself. Present 2 tiers + custom.
|
||||
return [
|
||||
{
|
||||
"value": "read",
|
||||
"label": "Read Only",
|
||||
"label_fa": "فقط خواندن",
|
||||
"hint": "View posts, pages, media",
|
||||
"hint_fa": "مشاهده نوشتهها، صفحات و رسانه",
|
||||
},
|
||||
{
|
||||
"value": "admin",
|
||||
"label": "Full Access",
|
||||
"label_fa": "دسترسی کامل",
|
||||
"hint": "All tools (CRUD + SEO via add-on)",
|
||||
"hint_fa": "همه ابزارها (CRUD و SEO با افزونه)",
|
||||
},
|
||||
custom,
|
||||
]
|
||||
|
||||
if plugin_type == "gitea":
|
||||
return [
|
||||
{
|
||||
"value": "read",
|
||||
"label": "Read",
|
||||
"label_fa": "خواندن",
|
||||
"hint": "Browse repos, issues, users",
|
||||
"hint_fa": "مشاهده مخازن، ایشوها، کاربران",
|
||||
},
|
||||
{
|
||||
"value": "write",
|
||||
"label": "Read + Write",
|
||||
"label_fa": "خواندن + نوشتن",
|
||||
"hint": "Create issues, PRs, branches",
|
||||
"hint_fa": "ایجاد ایشو، PR و شاخه",
|
||||
},
|
||||
{
|
||||
"value": "admin",
|
||||
"label": "Admin",
|
||||
"label_fa": "مدیر",
|
||||
"hint": "Repo + org + user admin",
|
||||
"hint_fa": "مدیریت مخزن، سازمان و کاربر",
|
||||
},
|
||||
custom,
|
||||
]
|
||||
|
||||
# Universal default for all other plugins.
|
||||
return [
|
||||
{
|
||||
"value": "read",
|
||||
"label": "Read",
|
||||
"label_fa": "فقط خواندن",
|
||||
"hint": "View only",
|
||||
"hint_fa": "فقط مشاهده",
|
||||
},
|
||||
{
|
||||
"value": "write",
|
||||
"label": "Read + Write",
|
||||
"label_fa": "خواندن + نوشتن",
|
||||
"hint": "CRUD ops",
|
||||
"hint_fa": "عملیات CRUD",
|
||||
},
|
||||
{
|
||||
"value": "admin",
|
||||
"label": "Full Access",
|
||||
"label_fa": "دسترسی کامل",
|
||||
"hint": "All tools",
|
||||
"hint_fa": "همه ابزارها",
|
||||
},
|
||||
custom,
|
||||
]
|
||||
|
||||
|
||||
class ToolAccessManager:
|
||||
"""Central manager for scope-based visibility and per-site tool toggles."""
|
||||
|
||||
@@ -101,24 +296,35 @@ class ToolAccessManager:
|
||||
self,
|
||||
tools: list[ToolDefinition],
|
||||
scopes: list[str],
|
||||
plugin_type: str | None = None,
|
||||
) -> list[ToolDefinition]:
|
||||
"""Drop tools whose category is not allowed by the presented scopes.
|
||||
"""Drop tools not allowed by the presented scopes.
|
||||
|
||||
Tools with an unknown category (e.g. legacy plugins not yet annotated)
|
||||
are always kept — backward compatibility.
|
||||
For plugins with category annotations (Coolify) the legacy
|
||||
category-based filter is used. For all other plugins the universal
|
||||
3-tier filter based on ``required_scope`` is applied.
|
||||
|
||||
Args:
|
||||
tools: Candidate tool list.
|
||||
scopes: Scopes presented on the API key (or a single-element list
|
||||
containing a site's ``tool_scope`` preset).
|
||||
plugin_type: Plugin type hint. When provided and the plugin is
|
||||
NOT in ``_CATEGORY_PLUGINS``, the universal tier filter is
|
||||
used.
|
||||
|
||||
Returns:
|
||||
Filtered tool list.
|
||||
"""
|
||||
# Try universal tiers first (works for all plugins)
|
||||
allowed_scopes = _scopes_to_required(scopes)
|
||||
|
||||
if allowed_scopes and (plugin_type is None or plugin_type not in _CATEGORY_PLUGINS):
|
||||
# Universal filter: match tool.required_scope against allowed tiers
|
||||
return [t for t in tools if t.required_scope in allowed_scopes]
|
||||
|
||||
# Fallback: legacy category-based filter for Coolify / custom scopes
|
||||
allowed = scopes_to_categories(scopes)
|
||||
if not allowed:
|
||||
# No recognised scopes — preserve legacy behaviour and return
|
||||
# only tools with unknown categories.
|
||||
return [t for t in tools if t.category not in KNOWN_CATEGORIES]
|
||||
|
||||
result: list[ToolDefinition] = []
|
||||
@@ -185,7 +391,7 @@ class ToolAccessManager:
|
||||
registry = get_tool_registry()
|
||||
tools = registry.get_by_plugin_type(plugin_type)
|
||||
|
||||
tools = self.apply_scope_filter(tools, key_scopes)
|
||||
tools = self.apply_scope_filter(tools, key_scopes, plugin_type=plugin_type)
|
||||
|
||||
try:
|
||||
db = get_database()
|
||||
@@ -194,7 +400,7 @@ class ToolAccessManager:
|
||||
site_scope = "admin"
|
||||
|
||||
if site_scope and site_scope != SCOPE_CUSTOM:
|
||||
tools = self.apply_scope_filter(tools, [site_scope])
|
||||
tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type)
|
||||
|
||||
tools = await self.apply_site_toggles(tools, site_id)
|
||||
return tools
|
||||
|
||||
@@ -240,6 +240,28 @@ async def user_mcp_handler(request: Request) -> Response:
|
||||
_jsonrpc_error(None, -32600, "API key does not match user"),
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# Check site-scoped key: if key is scoped to a site, it can only
|
||||
# be used for that specific site's alias.
|
||||
key_site_id = key_info.get("site_id")
|
||||
if key_site_id:
|
||||
try:
|
||||
from core.database import get_database
|
||||
|
||||
db = get_database()
|
||||
site = await db.get_site(key_site_id, user_id)
|
||||
if site is None or site.get("alias") != alias:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(
|
||||
None,
|
||||
-32600,
|
||||
"API key is scoped to a different site",
|
||||
),
|
||||
status_code=403,
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # DB unavailable — allow through
|
||||
|
||||
key_scopes = key_info.get("scopes", "read").split()
|
||||
else:
|
||||
# Try OAuth JWT token (issued after consent flow via GitHub/Google login)
|
||||
|
||||
@@ -49,8 +49,8 @@ class UserKeyManager:
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Cache: raw_key -> (key_id, user_id, scopes, cached_at)
|
||||
self._cache: dict[str, tuple[str, str, str, float]] = {}
|
||||
# Cache: raw_key -> (key_id, user_id, scopes, site_id, cached_at)
|
||||
self._cache: dict[str, tuple[str, str, str, str | None, float]] = {}
|
||||
|
||||
async def create_key(
|
||||
self,
|
||||
@@ -58,6 +58,7 @@ class UserKeyManager:
|
||||
name: str,
|
||||
scopes: str = "read write admin",
|
||||
expires_in_days: int | None = None,
|
||||
site_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new API key for a user.
|
||||
|
||||
@@ -66,10 +67,11 @@ class UserKeyManager:
|
||||
name: Human label (e.g. "Claude Desktop").
|
||||
scopes: Access scopes (default: "read write admin" for full access).
|
||||
expires_in_days: Optional expiry in days from now. None = never.
|
||||
site_id: Optional site UUID to scope key to a single site.
|
||||
|
||||
Returns:
|
||||
Dict with ``key`` (plaintext, shown once), ``key_id``, ``name``,
|
||||
``scopes``, ``created_at``, ``expires_at``.
|
||||
``scopes``, ``created_at``, ``expires_at``, ``site_id``.
|
||||
"""
|
||||
from core.database import get_database
|
||||
|
||||
@@ -89,9 +91,10 @@ class UserKeyManager:
|
||||
name=name,
|
||||
scopes=scopes,
|
||||
expires_at=expires_at,
|
||||
site_id=site_id,
|
||||
)
|
||||
|
||||
logger.info("Created user API key %s for user %s", row["id"], user_id)
|
||||
logger.info("Created user API key %s for user %s (site=%s)", row["id"], user_id, site_id)
|
||||
return {
|
||||
"key": raw_key, # shown once
|
||||
"key_id": row["id"],
|
||||
@@ -99,6 +102,7 @@ class UserKeyManager:
|
||||
"scopes": row["scopes"],
|
||||
"created_at": row["created_at"],
|
||||
"expires_at": row["expires_at"],
|
||||
"site_id": row.get("site_id"),
|
||||
}
|
||||
|
||||
async def validate_key(self, api_key: str) -> dict[str, Any] | None:
|
||||
@@ -118,7 +122,7 @@ class UserKeyManager:
|
||||
# Check cache first
|
||||
cached = self._cache.get(api_key)
|
||||
if cached is not None:
|
||||
key_id, user_id, scopes, cached_at = cached
|
||||
key_id, user_id, scopes, site_id, cached_at = cached
|
||||
if time.time() - cached_at < _CACHE_TTL_SECONDS:
|
||||
# Update usage in background (fire-and-forget via DB)
|
||||
try:
|
||||
@@ -130,7 +134,7 @@ class UserKeyManager:
|
||||
asyncio.create_task(db.update_api_key_usage(key_id))
|
||||
except Exception:
|
||||
pass # Non-critical
|
||||
return {"key_id": key_id, "user_id": user_id, "scopes": scopes}
|
||||
return {"key_id": key_id, "user_id": user_id, "scopes": scopes, "site_id": site_id}
|
||||
else:
|
||||
del self._cache[api_key]
|
||||
|
||||
@@ -159,10 +163,12 @@ class UserKeyManager:
|
||||
await db.update_api_key_usage(row["id"])
|
||||
|
||||
# Cache the result
|
||||
site_id = row.get("site_id")
|
||||
self._cache[api_key] = (
|
||||
row["id"],
|
||||
row["user_id"],
|
||||
row["scopes"],
|
||||
site_id,
|
||||
time.time(),
|
||||
)
|
||||
|
||||
@@ -170,6 +176,7 @@ class UserKeyManager:
|
||||
"key_id": row["id"],
|
||||
"user_id": row["user_id"],
|
||||
"scopes": row["scopes"],
|
||||
"site_id": site_id,
|
||||
}
|
||||
|
||||
async def list_keys(self, user_id: str) -> list[dict[str, Any]]:
|
||||
|
||||
Reference in New Issue
Block a user