feat(F.7b): tool access UI + unified keys page (v3.9.0)
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 21:00:42 +02:00
parent 3fc02b5734
commit d8a0805412
30 changed files with 2965 additions and 72 deletions

View File

@@ -160,6 +160,7 @@ DASHBOARD_TRANSLATIONS = {
"admin_login": "Admin Login with API Key",
"profile": "Profile",
"admin_badge": "Admin",
"keys": "API Keys",
},
"fa": {
# Navigation
@@ -265,6 +266,7 @@ DASHBOARD_TRANSLATIONS = {
"admin_login": "ورود مدیر با کلید API",
"profile": "پروفایل",
"admin_badge": "مدیر",
"keys": "کلیدهای API",
},
}
@@ -2905,6 +2907,134 @@ async def dashboard_connect_page(request: Request) -> Response:
)
# ======================================================================
# Site View Route (F.7b session 2)
# ======================================================================
async def dashboard_sites_view(request: Request) -> Response:
"""GET /dashboard/sites/{id} — Show site connect page with config snippets."""
user_session, redirect = _require_user_session(request)
if redirect:
return redirect
site_id = request.path_params.get("id", "")
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
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)
public_url = os.environ.get("PUBLIC_URL", "http://localhost:8000").rstrip("/")
mcp_url = f"{public_url}/u/{user_session['user_id']}/{site['alias']}/mcp"
return templates.TemplateResponse(
request,
"dashboard/sites/view.html",
{
"lang": lang,
"t": t,
"session": user_session,
"site": site,
"plugin_names": SITE_PLUGIN_NAMES,
"mcp_url": mcp_url,
"clients": get_supported_clients(),
"current_page": "my_sites",
},
)
# ======================================================================
# Unified Keys Route (F.7b session 2)
# ======================================================================
async def dashboard_keys_unified(request: Request) -> Response:
"""GET /dashboard/keys — Unified API keys page (user or admin view)."""
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)
auth = get_dashboard_auth()
admin_session = auth.get_session_from_request(request)
user_session = auth.get_user_session_from_request(request)
if admin_session and is_admin_session(admin_session):
# Admin view — reuse existing admin keys logic
project_filter = request.query_params.get("project", "")
status_filter = request.query_params.get("status", "active")
search = request.query_params.get("search", "")
page = int(request.query_params.get("page", 1))
keys_data = await get_all_api_keys(
project_id=project_filter if project_filter else None,
status=status_filter,
search=search if search else None,
page=page,
)
from core.site_manager import get_site_manager
site_manager = get_site_manager()
available_projects = site_manager.list_all_sites()
return templates.TemplateResponse(
request,
"dashboard/keys/list.html",
{
"lang": lang,
"t": t,
"session": admin_session,
"is_admin": True,
"api_keys": keys_data["api_keys"],
"total_count": keys_data["total_count"],
"total_pages": keys_data["total_pages"],
"page_number": keys_data["current_page"],
"per_page": keys_data["per_page"],
"available_projects": available_projects,
"selected_project": project_filter,
"selected_status": status_filter,
"search_query": search,
"current_page": "keys",
},
)
if user_session:
# User view — personal keys
from core.user_keys import get_user_key_manager
user_keys = []
try:
key_mgr = get_user_key_manager()
user_keys = await key_mgr.list_keys(user_session["user_id"])
except RuntimeError:
pass
return templates.TemplateResponse(
request,
"dashboard/keys/list.html",
{
"lang": lang,
"t": t,
"session": user_session,
"is_admin": False,
"user_keys": user_keys,
"current_page": "keys",
},
)
return RedirectResponse(url="/auth/login", status_code=303)
# ======================================================================
# Site Management API Routes (E.3)
# ======================================================================
@@ -3162,6 +3292,181 @@ async def api_delete_key(request: Request) -> Response:
return JSONResponse({"error": str(e)}, status_code=503)
# ----------------------------------------------------------------------
# F.7b: per-site tool visibility management
# ----------------------------------------------------------------------
_VALID_TOOL_SCOPES = {"read", "read:sensitive", "deploy", "write", "admin", "custom"}
async def _require_owned_site(request: Request) -> tuple[dict | None, Response | None]:
"""Resolve ``{site_id}`` path param → site row owned by the current user.
Returns ``(site, None)`` on success, or ``(None, error_response)``.
"""
user_session, redirect = _require_user_session(request)
if redirect:
return None, JSONResponse({"error": "Unauthorized"}, status_code=401)
site_id = request.path_params.get("site_id", "")
if not site_id:
return None, JSONResponse({"error": "Missing site_id"}, status_code=400)
from core.database import get_database
try:
db = get_database()
except RuntimeError:
return None, JSONResponse({"error": "Database unavailable"}, status_code=503)
site = await db.get_site(site_id, user_session["user_id"])
if site is None:
return None, JSONResponse({"error": "Site not found"}, status_code=404)
return site, None
async def api_list_site_tools(request: Request) -> Response:
"""GET /api/sites/{site_id}/tools — list tools for a site with toggle state."""
site, err = await _require_owned_site(request)
if err:
return err
assert site is not None
from core.tool_access import get_tool_access_manager
access = get_tool_access_manager()
tools = await access.list_tools_for_site(site["id"], site["plugin_type"])
return JSONResponse(
{
"site_id": site["id"],
"plugin_type": site["plugin_type"],
"tool_scope": site.get("tool_scope", "admin"),
"tools": tools,
}
)
async def api_patch_site_tool(request: Request) -> Response:
"""PATCH /api/sites/{site_id}/tools/{tool_name} — toggle a single tool."""
site, err = await _require_owned_site(request)
if err:
return err
assert site is not None
tool_name = request.path_params.get("tool_name", "")
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
if "enabled" not in body or not isinstance(body["enabled"], bool):
return JSONResponse({"error": "Missing boolean 'enabled' field"}, status_code=400)
from core.tool_access import get_tool_access_manager
from core.tool_registry import get_tool_registry
tool_def = get_tool_registry().get_by_name(tool_name)
if tool_def is None:
return JSONResponse({"error": f"Unknown tool '{tool_name}'"}, status_code=404)
if tool_def.plugin_type != site["plugin_type"]:
return JSONResponse(
{"error": f"Tool '{tool_name}' does not belong to this site's plugin"},
status_code=400,
)
access = get_tool_access_manager()
try:
await access.toggle_tool(
site["id"],
tool_name,
bool(body["enabled"]),
body.get("reason"),
)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
return JSONResponse({"ok": True, "tool_name": tool_name, "enabled": body["enabled"]})
async def api_bulk_toggle_site_tools(request: Request) -> Response:
"""POST /api/sites/{site_id}/tools/bulk-toggle — toggle a category set."""
site, err = await _require_owned_site(request)
if err:
return err
assert site is not None
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
scope = body.get("scope")
enabled = body.get("enabled")
if not isinstance(scope, str) or not isinstance(enabled, bool):
return JSONResponse(
{"error": "Body must contain string 'scope' and bool 'enabled'"},
status_code=400,
)
from core.tool_access import get_tool_access_manager
access = get_tool_access_manager()
try:
affected = await access.bulk_toggle_by_scope(
site["id"], scope, enabled, plugin_type=site["plugin_type"]
)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
return JSONResponse({"ok": True, "affected": affected})
async def api_set_site_tool_scope(request: Request) -> Response:
"""PATCH /api/sites/{site_id}/tool-scope — update the site's scope preset."""
site, err = await _require_owned_site(request)
if err:
return err
assert site is not None
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
scope = body.get("scope")
if not isinstance(scope, str) or scope not in _VALID_TOOL_SCOPES:
return JSONResponse(
{
"error": (
"Body must contain 'scope' with one of: "
+ ", ".join(sorted(_VALID_TOOL_SCOPES))
)
},
status_code=400,
)
from core.database import get_database
db = get_database()
await db.set_site_tool_scope(site["id"], scope)
return JSONResponse({"ok": True, "site_id": site["id"], "tool_scope": scope})
async def api_scope_presets(request: Request) -> Response:
"""GET /api/scope-presets — static scope → categories mapping."""
user_session, redirect = _require_user_session(request)
if redirect:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
del user_session
from core.tool_access import SCOPE_TO_CATEGORIES
return JSONResponse({"presets": {k: sorted(v) for k, v in SCOPE_TO_CATEGORIES.items()}})
async def api_get_config(request: Request) -> Response:
"""GET /api/config/{alias} — Get config snippets for a site."""
user_session, redirect = _require_user_session(request)
@@ -3471,8 +3776,11 @@ def register_dashboard_routes(mcp):
dashboard_project_detail
)
# API Keys routes
mcp.custom_route("/dashboard/api-keys", methods=["GET"])(dashboard_api_keys_list)
# API Keys routes (unified — /dashboard/keys replaces /dashboard/api-keys and /dashboard/connect)
mcp.custom_route("/dashboard/keys", methods=["GET"])(dashboard_keys_unified)
mcp.custom_route("/dashboard/api-keys", methods=["GET"])(
lambda r: RedirectResponse("/dashboard/keys", status_code=301)
)
# OAuth Clients routes
mcp.custom_route("/dashboard/oauth-clients", methods=["GET"])(dashboard_oauth_clients_list)
@@ -3519,7 +3827,11 @@ def register_dashboard_routes(mcp):
mcp.custom_route("/dashboard/sites", methods=["GET"])(dashboard_sites_list)
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/connect", methods=["GET"])(dashboard_connect_page)
mcp.custom_route("/dashboard/sites/{id}", methods=["GET"])(dashboard_sites_view)
# /dashboard/connect → /dashboard/keys (301)
mcp.custom_route("/dashboard/connect", methods=["GET"])(
lambda r: RedirectResponse("/dashboard/keys", status_code=301)
)
# Service pages (F.3)
mcp.custom_route("/dashboard/services", methods=["GET"])(dashboard_services_list)

View File

@@ -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 = 5
SCHEMA_VERSION = 7
# Initial schema DDL
_SCHEMA_SQL = """\
@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS sites (
status_msg TEXT,
last_health TEXT,
last_tested_at TEXT,
tool_scope TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL,
UNIQUE(user_id, alias)
);
@@ -100,6 +101,18 @@ CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
-- F.7b: per-site tool toggles (scope-based visibility overrides)
CREATE TABLE IF NOT EXISTS site_tool_toggles (
id TEXT PRIMARY KEY,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
tool_name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
reason TEXT,
updated_at TEXT NOT NULL,
UNIQUE(site_id, tool_name)
);
CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site ON site_tool_toggles(site_id);
"""
# Migration registry: version -> SQL string
@@ -120,6 +133,38 @@ _MIGRATIONS: dict[int, str] = {
" updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n"
");\n"
),
6: (
# F.7: per-user tool toggles for scope-based visibility & per-tool disable
"CREATE TABLE IF NOT EXISTS user_tool_toggles (\n"
" id TEXT PRIMARY KEY,\n"
" user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n"
" tool_name TEXT NOT NULL,\n"
" enabled INTEGER NOT NULL DEFAULT 1,\n"
" reason TEXT,\n"
" updated_at TEXT NOT NULL,\n"
" UNIQUE(user_id, tool_name)\n"
");\n"
"CREATE INDEX IF NOT EXISTS idx_user_tool_toggles_user "
"ON user_tool_toggles(user_id);\n"
),
7: (
# F.7b: move tool toggles from per-user to per-site and add a
# per-site preset column. user_tool_toggles was merged on Phase-1
# but never populated with real data — safe to drop.
"DROP TABLE IF EXISTS user_tool_toggles;\n"
"CREATE TABLE IF NOT EXISTS site_tool_toggles (\n"
" id TEXT PRIMARY KEY,\n"
" site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,\n"
" tool_name TEXT NOT NULL,\n"
" enabled INTEGER NOT NULL DEFAULT 1,\n"
" reason TEXT,\n"
" updated_at TEXT NOT NULL,\n"
" UNIQUE(site_id, tool_name)\n"
");\n"
"CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site "
"ON site_tool_toggles(site_id);\n"
"ALTER TABLE sites ADD COLUMN tool_scope TEXT NOT NULL DEFAULT 'admin';\n"
),
}
@@ -726,6 +771,121 @@ class Database:
(_utc_now(), key_id),
)
# ------------------------------------------------------------------
# Site tool toggles & tool_scope (F.7b)
# ------------------------------------------------------------------
async def get_site_tool_toggles(self, site_id: str) -> dict[str, bool]:
"""Get explicit tool toggle overrides for a site.
Only rows where a tool has been explicitly toggled are stored.
Tools not in the result are implicitly enabled.
Args:
site_id: Site UUID.
Returns:
Dict mapping ``tool_name`` → ``enabled`` (bool).
"""
rows = await self.fetchall(
"SELECT tool_name, enabled FROM site_tool_toggles WHERE site_id = ?",
(site_id,),
)
return {row["tool_name"]: bool(row["enabled"]) for row in rows}
async def set_site_tool_toggle(
self,
site_id: str,
tool_name: str,
enabled: bool,
reason: str | None = None,
) -> None:
"""Upsert a single tool toggle for a site.
Args:
site_id: Site UUID.
tool_name: Fully-qualified tool name (e.g. ``coolify_list_applications``).
enabled: Whether the tool should be visible on this site.
reason: Optional note.
"""
toggle_id = str(uuid.uuid4())
now = _utc_now()
await self.execute(
"INSERT INTO site_tool_toggles (id, site_id, tool_name, enabled, reason, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(site_id, tool_name) DO UPDATE SET "
"enabled = excluded.enabled, reason = excluded.reason, updated_at = excluded.updated_at",
(toggle_id, site_id, tool_name, 1 if enabled else 0, reason, now),
)
async def delete_site_tool_toggle(self, site_id: str, tool_name: str) -> bool:
"""Delete a site's toggle for a tool (reverts to the default).
Args:
site_id: Site UUID.
tool_name: Fully-qualified tool name.
Returns:
True if a row was deleted.
"""
cursor = await self.execute(
"DELETE FROM site_tool_toggles WHERE site_id = ? AND tool_name = ?",
(site_id, tool_name),
)
return cursor.rowcount > 0
async def bulk_set_site_tool_toggles(
self,
site_id: str,
toggles: list[tuple[str, bool]],
reason: str | None = None,
) -> int:
"""Upsert multiple tool toggles for a site in one transaction.
Args:
site_id: Site UUID.
toggles: List of ``(tool_name, enabled)`` pairs.
reason: Optional shared reason applied to every row.
Returns:
Number of rows affected.
"""
if not toggles:
return 0
now = _utc_now()
rows = [
(str(uuid.uuid4()), site_id, tool_name, 1 if enabled else 0, reason, now)
for tool_name, enabled in toggles
]
await self.executemany(
"INSERT INTO site_tool_toggles (id, site_id, tool_name, enabled, reason, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(site_id, tool_name) DO UPDATE SET "
"enabled = excluded.enabled, reason = excluded.reason, updated_at = excluded.updated_at",
rows,
)
return len(rows)
async def get_site_tool_scope(self, site_id: str) -> str:
"""Return the site's ``tool_scope`` preset (defaults to ``'admin'``)."""
row = await self.fetchone("SELECT tool_scope FROM sites WHERE id = ?", (site_id,))
if row is None:
return "admin"
return row["tool_scope"] or "admin"
async def set_site_tool_scope(self, site_id: str, scope: str) -> None:
"""Update the ``tool_scope`` preset for a site.
Args:
site_id: Site UUID.
scope: One of ``read``, ``read:sensitive``, ``deploy``,
``write``, ``admin``, ``custom``.
"""
await self.execute(
"UPDATE sites SET tool_scope = ? WHERE id = ?",
(scope, site_id),
)
# ======================================================================
# Module-level helpers

View File

@@ -136,8 +136,8 @@
01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
'/dashboard/sites'),
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
('connect', t.get('connect', 'Connect'), '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', '/dashboard/connect'),
('keys', t.get('keys', 'API Keys'), 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/keys'),
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),

View File

@@ -0,0 +1,493 @@
{% extends "dashboard/base.html" %}
{% block title %}{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %} - MCP Hub{% endblock %}
{% block page_title %}{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}{% endblock %}
{% block content %}
<div class="space-y-6">
{% if is_admin %}
{# ── Admin view: full filters, all project keys ── #}
<div class="flex flex-wrap items-center justify-between gap-4">
<p class="text-gray-500 dark:text-gray-400 text-sm">
{% if lang == 'fa' %}مدیریت کلیدهای API (Admin){% else %}Manage project API keys (admin){% endif %}
</p>
<button onclick="openCreateModal()"
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm font-medium transition-colors flex items-center">
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
{% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
</button>
</div>
<!-- Admin Filters -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<form method="GET" class="flex flex-wrap items-center gap-4">
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
<select name="project" onchange="this.form.submit()"
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
<option value="">{% if lang == 'fa' %}همه پروژه‌ها{% else %}All Projects{% endif %}</option>
<option value="*" {% if selected_project == '*' %}selected{% endif %}>{% if lang == 'fa' %}کلیدهای عمومی (*){% else %}Global Keys (*){% endif %}</option>
{% for project in available_projects %}
<option value="{{ project.full_id }}" {% if selected_project == project.full_id %}selected{% endif %}>
{{ project.plugin_type }}: {{ project.alias or project.site_id }}
</option>
{% endfor %}
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
<select name="status" onchange="this.form.submit()"
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
<option value="all" {% if selected_status == 'all' %}selected{% endif %}>{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
<option value="revoked" {% if selected_status == 'revoked' %}selected{% endif %}>{% if lang == 'fa' %}لغو شده{% else %}Revoked{% endif %}</option>
</select>
</div>
<div class="flex-1 min-w-[200px]">
<input type="text" name="search" value="{{ search_query }}"
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search description...{% endif %}"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<button type="submit" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{{ t.search }}</button>
{% if search_query or selected_project or selected_status != 'active' %}
<a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="px-4 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">{{ t['clear'] }}</a>
{% endif %}
</form>
</div>
<!-- Admin Keys Table -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}شناسه{% else %}Key ID{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% if api_keys %}
{% for key in api_keys %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
<td class="px-6 py-4">
<div class="flex items-center">
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
<button onclick="copyToClipboard('{{ key.key_id }}')" class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-gray-400 hover:text-white transition-colors" title="Copy ID">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</td>
<td class="px-6 py-4">
{% if key.project_id == '*' %}
<span class="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg font-medium">{% if lang == 'fa' %}همه پروژه‌ها{% else %}All Projects{% endif %}</span>
{% else %}<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.project_id }}</span>{% endif %}
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
{% for scope in key.scope.split() %}
<span class="px-2 py-0.5 {% if scope == 'admin' %}bg-red-500/20 text-red-400{% elif scope == 'write' %}bg-orange-500/20 text-orange-400{% else %}bg-blue-500/20 text-blue-400{% endif %} text-xs rounded font-medium">{{ scope }}</span>
{% endfor %}
</div>
</td>
<td class="px-6 py-4"><span class="text-sm text-gray-500 dark:text-gray-400">{{ key.description or '-' }}</span></td>
<td class="px-6 py-4">
{% if key.revoked %}<div class="flex items-center"><span class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span><span class="text-sm text-red-400">{% if lang == 'fa' %}لغو شده{% else %}Revoked{% endif %}</span></div>
{% else %}<div class="flex items-center"><span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span><span class="text-sm text-green-400">{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</span></div>{% endif %}
</td>
<td class="px-6 py-4"><span class="text-sm text-gray-700 dark:text-gray-300">{{ key.usage_count }}</span></td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
{% if not key.revoked %}
<button onclick="openRevokeModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
class="p-2 bg-red-500/20 hover:bg-red-500/30 rounded-lg text-red-400 transition-colors" title="{% if lang == 'fa' %}لغو{% else %}Revoke{% endif %}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
</button>
{% endif %}
<button onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
class="p-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors" title="{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="7" class="px-6 py-12 text-center">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
<p class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}</p>
<button onclick="openCreateModal()" class="mt-4 px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}</button>
</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}نمایش {{ ((page_number-1)*per_page)+1 }} تا {{ [page_number*per_page, total_count]|min }} از {{ total_count }}{% else %}Showing {{ ((page_number-1)*per_page)+1 }}{{ [page_number*per_page, total_count]|min }} of {{ total_count }}{% endif %}
</p>
<div class="flex items-center gap-2">
{% if page_number > 1 %}<a href="?page={{ page_number-1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}</a>{% endif %}
{% for p in range(1, total_pages+1) %}{% if p == page_number %}<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ p }}</span>{% elif p == 1 or p == total_pages or (p >= page_number-2 and p <= page_number+2) %}<a href="?page={{ p }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{{ p }}</a>{% endif %}{% endfor %}
{% if page_number < total_pages %}<a href="?page={{ page_number+1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}</a>{% endif %}
</div>
</div>
{% endif %}
</div>
{% else %}
{# ── User view: personal keys with scope selector ── #}
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<p class="text-gray-500 dark:text-gray-400 text-sm">
{% if lang == 'fa' %}کلیدهای API شخصی برای دسترسی به MCP{% else %}Your personal API keys for MCP access{% endif %}
</p>
<p class="text-gray-400 dark:text-gray-500 text-xs mt-1">
{% if lang == 'fa' %}فیلتر ابزارهای هر سایت در <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">تنظیمات سایت</a> انجام می‌شود.{% else %}Per-site tool filters are configured in <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">Site Settings</a>.{% endif %}
</p>
</div>
<button onclick="openCreateModal()"
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm font-medium transition-colors flex items-center">
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
{% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
</button>
</div>
<!-- User Keys Table -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{% if lang == 'fa' %}نام{% else %}Name{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Prefix</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Uses</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
{% if user_keys %}
{% for key in user_keys %}
<tr id="ukey-{{ key.id }}">
<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>
</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">
<button onclick="deleteUserKey('{{ key.id }}')" class="text-sm text-red-600 dark:text-red-400 hover:text-red-500">{{ t.delete }}</button>
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="5" class="px-6 py-12 text-center">
<p class="text-gray-500 dark:text-gray-400 mb-4">{{ t.no_api_keys }}</p>
<button onclick="openCreateModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}</button>
</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
{# ── Modals ── #}
<!-- Create Key Modal -->
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
</h3>
</div>
{% if is_admin %}
{# Admin create form — posts to existing admin endpoint #}
<form id="createKeyForm" method="POST" action="/api/dashboard/api-keys/create" class="p-6 space-y-4">
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}</label>
<select name="project_id" 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 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
<option value="*">{% if lang == 'fa' %}همه پروژه‌ها (*){% else %}All Projects (*){% endif %}</option>
{% for project in available_projects %}
<option value="{{ project.full_id }}">{{ project.plugin_type }}: {{ project.alias or project.site_id }}</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 %}Scope{% endif %}</label>
<div class="space-y-2">
<label class="flex items-center"><input type="checkbox" name="scope_read" value="read" checked class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read</span></label>
<label class="flex items-center"><input type="checkbox" name="scope_write" value="write" class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write</span></label>
<label class="flex items-center"><input type="checkbox" name="scope_admin" value="admin" class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">admin</span></label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}توضیحات (اختیاری){% else %}Description (optional){% endif %}</label>
<input type="text" name="description" placeholder="{% if lang == 'fa' %}مثال: CI/CD{% else %}e.g., CI/CD key{% endif %}"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}انقضا{% else %}Expiration{% endif %}</label>
<select name="expires_in_days" class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
<option value="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
<option value="7">7 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
<option value="30">30 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
<option value="90">90 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
<option value="365">1 {% if lang == 'fa' %}سال{% else %}year{% endif %}</option>
</select>
</div>
</form>
{% else %}
{# User create form — uses /api/keys via JS #}
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}نام کلید{% else %}Key Name{% endif %}</label>
<input type="text" id="new-key-name" placeholder="{% if lang == 'fa' %}مثال: Claude Desktop{% else %}e.g., Claude Desktop{% endif %}"
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>
</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>
</div>
</div>
{% endif %}
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button onclick="closeCreateModal()" class="px-4 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">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
<button onclick="submitCreate()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}ایجاد کلید{% else %}Create Key{% endif %}</button>
</div>
</div>
</div>
<!-- New Key Display Modal -->
<div id="newKeyModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-6 h-6 text-green-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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% if lang == 'fa' %}کلید API ایجاد شد{% else %}API Key Created{% endif %}
</h3>
</div>
<div class="p-6 space-y-4">
<div class="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
<p class="text-sm text-yellow-400">{% if lang == 'fa' %}این کلید فقط یکبار نمایش داده می‌شود.{% else %}This key will only be shown once. Save it now!{% endif %}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">API Key</label>
<div class="flex items-center gap-2">
<input type="text" id="newKeyValue" 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-gray-900 dark:text-white font-mono text-sm">
<button onclick="copyNewKey()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
</div>
</div>
</div>
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button onclick="closeNewKeyModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}بستن{% else %}Done{% endif %}</button>
</div>
</div>
</div>
{% if is_admin %}
<!-- Revoke Modal -->
<div id="revokeModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700"><h3 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}لغو کلید API{% else %}Revoke API Key{% endif %}</h3></div>
<div class="p-6"><p class="text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}آیا مطمئنید که می‌خواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟{% else %}Are you sure you want to revoke <span id="revokeKeyName" class="font-mono text-white"></span>?{% endif %}</p></div>
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button onclick="closeRevokeModal()" class="px-4 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">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
<button id="confirmRevokeBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}لغو کلید{% else %}Revoke{% endif %}</button>
</div>
</div>
</div>
<!-- Delete Modal -->
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700"><h3 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}حذف کلید API{% else %}Delete API Key{% endif %}</h3></div>
<div class="p-6"><p class="text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}آیا مطمئنید که می‌خواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟{% else %}Are you sure you want to delete <span id="deleteKeyName" class="font-mono text-white"></span>?{% endif %}</p></div>
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button onclick="closeDeleteModal()" class="px-4 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">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
<button id="confirmDeleteBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}</button>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
(function() {
const isAdmin = {{ 'true' if is_admin else 'false' }};
const lang = '{{ lang }}';
let currentRevokeKeyId = null;
let currentDeleteKeyId = null;
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => showToast(lang === 'fa' ? 'کپی شد!' : 'Copied!'));
}
window.copyToClipboard = copyToClipboard;
function showToast(msg) {
const t = document.createElement('div');
t.className = 'fixed bottom-4 right-4 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white text-sm z-50';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2000);
}
function openCreateModal() {
document.getElementById('createModal').classList.remove('hidden');
document.getElementById('createModal').classList.add('flex');
}
window.openCreateModal = openCreateModal;
function closeCreateModal() {
document.getElementById('createModal').classList.add('hidden');
document.getElementById('createModal').classList.remove('flex');
}
window.closeCreateModal = closeCreateModal;
function closeNewKeyModal() {
document.getElementById('newKeyModal').classList.add('hidden');
document.getElementById('newKeyModal').classList.remove('flex');
location.reload();
}
window.closeNewKeyModal = closeNewKeyModal;
function copyNewKey() {
copyToClipboard(document.getElementById('newKeyValue').value);
}
window.copyNewKey = copyNewKey;
function showNewKey(key) {
document.getElementById('newKeyValue').value = key;
document.getElementById('newKeyModal').classList.remove('hidden');
document.getElementById('newKeyModal').classList.add('flex');
}
async function submitCreate() {
if (isAdmin) {
// Admin: collect scopes + submit form via JS
const form = document.getElementById('createKeyForm');
const formData = new FormData(form);
const scopes = [];
if (form.querySelector('[name="scope_read"]')?.checked) scopes.push('read');
if (form.querySelector('[name="scope_write"]')?.checked) scopes.push('write');
if (form.querySelector('[name="scope_admin"]')?.checked) scopes.push('admin');
if (scopes.length === 0) { alert(lang === 'fa' ? 'حداقل یک دسترسی انتخاب کنید' : 'Select at least one scope'); return; }
const data = {
project_id: formData.get('project_id'),
scope: scopes.join(' '),
description: formData.get('description') || null,
expires_in_days: formData.get('expires_in_days') ? parseInt(formData.get('expires_in_days')) : null,
};
const r = await fetch('/api/dashboard/api-keys/create', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
body: JSON.stringify(data),
});
const res = await r.json();
if (res.error) { alert(res.error); return; }
closeCreateModal();
showNewKey(res.key);
} 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 r = await fetch('/api/keys', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scopes: scope }),
});
const res = await r.json();
if (!r.ok) { alert(res.error || 'Failed'); return; }
closeCreateModal();
showNewKey(res.key.key);
}
}
window.submitCreate = submitCreate;
async function deleteUserKey(keyId) {
if (!confirm(lang === 'fa' ? 'این کلید حذف شود؟' : 'Delete this key?')) return;
const r = await fetch('/api/keys/' + keyId, { method: 'DELETE' });
if (r.ok) {
const row = document.getElementById('ukey-' + keyId);
if (row) row.remove();
}
}
window.deleteUserKey = deleteUserKey;
{% if is_admin %}
function openRevokeModal(keyId, keyName) {
currentRevokeKeyId = keyId;
document.getElementById('revokeKeyName').textContent = keyName;
document.getElementById('revokeModal').classList.remove('hidden');
document.getElementById('revokeModal').classList.add('flex');
}
window.openRevokeModal = openRevokeModal;
function closeRevokeModal() {
document.getElementById('revokeModal').classList.add('hidden');
document.getElementById('revokeModal').classList.remove('flex');
}
window.closeRevokeModal = closeRevokeModal;
document.getElementById('confirmRevokeBtn').onclick = async function() {
if (!currentRevokeKeyId) return;
const r = await fetch('/api/dashboard/api-keys/' + currentRevokeKeyId + '/revoke', { method: 'POST', credentials: 'same-origin' });
if (r.ok) location.reload();
else { const d = await r.json(); alert(d.error || 'Failed'); }
};
function openDeleteModal(keyId, keyName) {
currentDeleteKeyId = keyId;
document.getElementById('deleteKeyName').textContent = keyName;
document.getElementById('deleteModal').classList.remove('hidden');
document.getElementById('deleteModal').classList.add('flex');
}
window.openDeleteModal = openDeleteModal;
function closeDeleteModal() {
document.getElementById('deleteModal').classList.add('hidden');
document.getElementById('deleteModal').classList.remove('flex');
}
window.closeDeleteModal = closeDeleteModal;
document.getElementById('confirmDeleteBtn').onclick = async function() {
if (!currentDeleteKeyId) return;
const r = await fetch('/api/dashboard/api-keys/' + currentDeleteKeyId, { method: 'DELETE', credentials: 'same-origin' });
if (r.ok) location.reload();
else { const d = await r.json(); alert(d.error || 'Failed'); }
};
{% endif %}
})();
</script>
{% endblock %}

View File

@@ -110,6 +110,47 @@
</div>
</form>
</div>
<!-- Tool Access Card -->
<div id="tool-access-card" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-4">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}دسترسی به ابزارها{% else %}Tool Access{% endif %}
</h3>
<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 selector -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}سطح دسترسی{% else %}Access Scope{% endif %}
</label>
<select id="tool-scope-select"
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">
<option value="read">{% if lang == 'fa' %}فقط خواندن (Read){% else %}Read{% endif %}</option>
<option value="read:sensitive">{% if lang == 'fa' %}خواندن + داده حساس{% else %}Read + Sensitive{% endif %}</option>
<option value="deploy">{% if lang == 'fa' %}استقرار (start/stop/restart){% else %}Deploy{% endif %}</option>
<option value="write">{% if lang == 'fa' %}نوشتن + Lifecycle{% else %}Write{% endif %}</option>
<option value="admin">{% if lang == 'fa' %}دسترسی کامل{% else %}Admin (all tools){% endif %}</option>
<option value="custom">{% if lang == 'fa' %}سفارشی (per-tool){% else %}Custom{% endif %}</option>
</select>
<p id="scope-desc" class="text-xs text-gray-500 dark:text-gray-400 mt-1.5"></p>
</div>
<!-- Status message for scope save -->
<div id="scope-status" class="hidden text-xs"></div>
<!-- Advanced: per-tool overrides -->
<details id="tool-overrides-section">
<summary class="cursor-pointer text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none py-1">
{% if lang == 'fa' %}پیشرفته — انتخاب دستی ابزارها{% else %}Advanced — per-tool overrides{% endif %}
</summary>
<div id="tool-list" class="mt-3 space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4"></div>
</details>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
@@ -168,5 +209,162 @@
btn.disabled = false;
}
});
// ── Tool Access ──────────────────────────────────────────────
const SCOPE_DESCS = {
'read': '{% if lang == "fa" %}ابزارهای read-only (list/get) — بدون داده‌های حساس{% else %}Read-only list/get tools — no sensitive data{% endif %}',
'read:sensitive': '{% if lang == "fa" %}read + لاگ‌ها، بکاپ‌ها و متغیرهای محیطی{% else %}Read + logs, backups, and env vars{% endif %}',
'deploy': '{% if lang == "fa" %}read + start/stop/restart/deploy{% else %}Read + start/stop/restart/deploy{% endif %}',
'write': '{% if lang == "fa" %}read + lifecycle + ایجاد/بروزرسانی + env{% else %}Read + lifecycle + create/update + env{% endif %}',
'admin': '{% if lang == "fa" %}دسترسی کامل به همه ابزارها{% else %}Full access to all tools{% endif %}',
'custom': '{% if lang == "fa" %}بدون فیلتر سطح — فقط toggleهای دستی اعمال می‌شوند{% else %}No scope filter — only per-tool toggles apply{% endif %}',
};
const CAT_LABELS = {
'read': '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}',
'read_sensitive': '{% if lang == "fa" %}خواندن حساس{% else %}Sensitive Read{% endif %}',
'lifecycle': 'Lifecycle',
'crud': 'CRUD',
'env': '{% if lang == "fa" %}محیطی{% else %}Environment{% endif %}',
'backup': 'Backup',
'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');
if (!r.ok) { hideToolAccess(); return; }
const data = await r.json();
renderToolAccess(data);
} catch (_) { hideToolAccess(); }
}
function hideToolAccess() {
document.getElementById('tool-access-loading').textContent = '';
}
function renderToolAccess(data) {
const loading = document.getElementById('tool-access-loading');
const content = document.getElementById('tool-access-content');
loading.classList.add('hidden');
content.classList.remove('hidden');
// Set scope dropdown
const select = document.getElementById('tool-scope-select');
select.value = data.tool_scope || 'admin';
updateScopeDesc(data.tool_scope || 'admin');
// Scope change handler
select.onchange = async () => {
const scope = select.value;
updateScopeDesc(scope);
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', 'x-csrf-token': getCsrf() },
body: JSON.stringify({ scope }),
});
if (r.ok) {
statusEl.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %}';
statusEl.className = 'text-xs text-green-500';
} else {
statusEl.textContent = '{% if lang == "fa" %}خطا{% else %}Error saving{% 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);
};
// Render per-tool list grouped by category
const grouped = {};
for (const tool of data.tools) {
const cat = tool.category || 'read';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(tool);
}
const catOrder = ['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system'];
const container = document.getElementById('tool-list');
container.innerHTML = '';
for (const cat of catOrder) {
if (!grouped[cat]) continue;
const section = document.createElement('div');
section.className = 'space-y-1';
const header = document.createElement('p');
header.className = 'text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2';
header.textContent = CAT_LABELS[cat] || cat;
section.appendChild(header);
for (const tool of grouped[cat]) {
section.appendChild(renderToolRow(tool));
}
container.appendChild(section);
}
}
function updateScopeDesc(scope) {
const el = document.getElementById('scope-desc');
el.textContent = SCOPE_DESCS[scope] || '';
}
function renderToolRow(tool) {
const row = document.createElement('div');
row.id = 'tool-row-' + tool.name;
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30';
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 text-gray-800 dark:text-gray-200 truncate font-mono';
nameEl.textContent = tool.name;
nameEl.title = tool.description || '';
left.appendChild(nameEl);
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);
}
// Toggle switch
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', 'x-csrf-token': getCsrf() },
body: JSON.stringify({ enabled }),
});
if (!r.ok) { input.checked = !enabled; }
} 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;
}
document.addEventListener('DOMContentLoaded', loadToolAccess);
</script>
{% endblock %}

View File

@@ -80,6 +80,10 @@
id="test-btn-{{ site.id }}">
{{ t.test_connection }}
</button>
<a href="/dashboard/sites/{{ site.id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500">
{{ t.get('connect', 'Connect') }}
</a>
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
{{ t.edit }}

View File

@@ -0,0 +1,134 @@
{% 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">
<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>
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="ml-auto text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
{% if lang == 'fa' %}ویرایش{% else %}Edit{% endif %}
</a>
</div>
<!-- MCP Endpoint URL -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-3">
{% if lang == 'fa' %}آدرس MCP{% else %}MCP Endpoint{% endif %}
</h3>
<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="copyMcpUrl()" 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-2">
{% if lang == 'fa' %}
برای احراز هویت از کلید API (Bearer) یا OAuth استفاده کنید.
{% else %}
Authenticate with an API key (Bearer token) or OAuth.
{% endif %}
</p>
</div>
<!-- Config Snippets -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
{% if lang == 'fa' %}نمونه کدهای پیکربندی{% else %}Configuration Snippets{% endif %}
</h3>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}انتخاب کلاینت{% else %}Select 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 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-[100px]">{% 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-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 استفاده کنید.
{% else %}
<strong>Note:</strong> Use <code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code.
{% endif %}
</p>
</div>
<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> از صفحه <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">کلیدها</a> بسازید:
{% else %}
<strong>API Key:</strong> Create one on the <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">API Keys</a> page:
{% 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>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const siteAlias = "{{ site.alias }}";
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
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' : '';
document.getElementById('bearer-hint').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 loading config{% endif %}';
} catch (_) {
document.getElementById('config-output').textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
}
}
function copyMcpUrl() {
navigator.clipboard.writeText(document.getElementById('mcp-url').textContent.trim());
}
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);
}
document.addEventListener('DOMContentLoaded', updateConfig);
</script>
{% endblock %}

326
core/tool_access.py Normal file
View File

@@ -0,0 +1,326 @@
"""Tool access manager — site-scoped visibility and per-site toggles (F.7b).
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.
2. **Per-site tool toggles.** Site owners may explicitly disable specific
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.
Usage::
from core.tool_access import get_tool_access_manager
mgr = get_tool_access_manager()
visible = await mgr.get_visible_tools(
site_id=site["id"],
key_scopes=["read"],
plugin_type="coolify",
)
"""
from __future__ import annotations
import logging
from typing import Any
from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__)
# 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"},
"deploy": {"read", "lifecycle"},
"write": {"read", "lifecycle", "crud", "env"},
"admin": {
"read",
"read_sensitive",
"lifecycle",
"crud",
"env",
"backup",
"system",
},
}
# All known categories — any tool whose category is outside this set is
# treated as "always visible" for backward compatibility.
KNOWN_CATEGORIES: set[str] = {
"read",
"read_sensitive",
"lifecycle",
"crud",
"env",
"backup",
"system",
}
# Sentinel meaning "no site-level preset filter — use per-tool toggles only".
SCOPE_CUSTOM = "custom"
def scopes_to_categories(scopes: list[str]) -> set[str]:
"""Return the union of categories allowed by the given scope list.
Args:
scopes: List of scope strings as presented on the API key / token.
Returns:
Set of category names the scopes collectively allow.
"""
allowed: set[str] = set()
for scope in scopes:
allowed |= SCOPE_TO_CATEGORIES.get(scope.strip(), set())
return allowed
class ToolAccessManager:
"""Central manager for scope-based visibility and per-site tool toggles."""
def apply_scope_filter(
self,
tools: list[ToolDefinition],
scopes: list[str],
) -> list[ToolDefinition]:
"""Drop tools whose category is not allowed by the presented scopes.
Tools with an unknown category (e.g. legacy plugins not yet annotated)
are always kept — backward compatibility.
Args:
tools: Candidate tool list.
scopes: Scopes presented on the API key (or a single-element list
containing a site's ``tool_scope`` preset).
Returns:
Filtered tool list.
"""
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] = []
for tool in tools:
if tool.category not in KNOWN_CATEGORIES:
result.append(tool)
continue
if tool.category in allowed:
result.append(tool)
return result
async def apply_site_toggles(
self,
tools: list[ToolDefinition],
site_id: str,
) -> list[ToolDefinition]:
"""Drop tools the site owner has explicitly disabled.
Args:
tools: Candidate tool list.
site_id: Site UUID.
Returns:
Filtered tool list.
"""
from core.database import get_database
try:
db = get_database()
except RuntimeError:
return tools
toggles = await db.get_site_tool_toggles(site_id)
if not toggles:
return tools
return [t for t in tools if toggles.get(t.name, True)]
async def get_visible_tools(
self,
site_id: str,
key_scopes: list[str],
plugin_type: str,
) -> list[ToolDefinition]:
"""Return the visible tool list for a site on a given plugin.
Pipeline:
1. ``ToolRegistry.get_by_plugin_type``
2. Key-scope filter (API key's declared scopes)
3. Site-scope filter (site's stored ``tool_scope`` preset,
skipped when it is ``custom``)
4. Per-site toggle filter (``site_tool_toggles``)
Args:
site_id: Site UUID (the MCP endpoint alias resolves to this).
key_scopes: Scopes presented on the API key / token.
plugin_type: Plugin type (e.g. ``coolify``).
Returns:
List of visible ``ToolDefinition`` objects.
"""
from core.database import get_database
from core.tool_registry import get_tool_registry
registry = get_tool_registry()
tools = registry.get_by_plugin_type(plugin_type)
tools = self.apply_scope_filter(tools, key_scopes)
try:
db = get_database()
site_scope = await db.get_site_tool_scope(site_id)
except RuntimeError:
site_scope = "admin"
if site_scope and site_scope != SCOPE_CUSTOM:
tools = self.apply_scope_filter(tools, [site_scope])
tools = await self.apply_site_toggles(tools, site_id)
return tools
async def toggle_tool(
self,
site_id: str,
tool_name: str,
enabled: bool,
reason: str | None = None,
) -> None:
"""Enable or disable a single tool for a site.
Args:
site_id: Site UUID.
tool_name: Fully-qualified tool name.
enabled: True to enable, False to disable.
reason: Optional note.
"""
from core.database import get_database
db = get_database()
await db.set_site_tool_toggle(site_id, tool_name, enabled, reason)
logger.info(
"site %s toggled %s%s",
site_id,
tool_name,
"enabled" if enabled else "disabled",
)
async def bulk_toggle_by_scope(
self,
site_id: str,
scope_name: str,
enabled: bool,
plugin_type: str | None = None,
) -> int:
"""Toggle every tool whose category belongs to the given scope.
Only the *exclusive* category set of the scope is affected — i.e.
the categories explicitly listed under ``SCOPE_TO_CATEGORIES[scope_name]``.
Tools outside those categories are left unchanged.
Args:
site_id: Site UUID.
scope_name: Scope key (``"read"``, ``"deploy"``, ...).
enabled: True to enable, False to disable.
plugin_type: Optional filter — only affect tools from this plugin.
When ``None`` every plugin's tools in that category are touched.
Returns:
Number of tools affected.
"""
from core.database import get_database
from core.tool_registry import get_tool_registry
categories = SCOPE_TO_CATEGORIES.get(scope_name)
if categories is None:
raise ValueError(f"Unknown scope '{scope_name}'")
registry = get_tool_registry()
candidates = registry.get_all()
if plugin_type is not None:
candidates = [t for t in candidates if t.plugin_type == plugin_type]
affected = [t.name for t in candidates if t.category in categories]
if not affected:
return 0
db = get_database()
await db.bulk_set_site_tool_toggles(
site_id,
[(name, enabled) for name in affected],
reason=f"bulk:{scope_name}",
)
return len(affected)
async def list_tools_for_site(
self,
site_id: str,
plugin_type: str,
) -> list[dict[str, Any]]:
"""Return every tool for a plugin, annotated with per-site toggle state.
Used by the dashboard API to present the per-site management view.
Does not apply scope filters — the UI decides what to show.
Args:
site_id: Site UUID.
plugin_type: Plugin type.
Returns:
List of dicts with tool metadata + ``enabled`` flag.
"""
from core.database import get_database
from core.tool_registry import get_tool_registry
try:
db = get_database()
toggles = await db.get_site_tool_toggles(site_id)
except RuntimeError:
toggles = {}
registry = get_tool_registry()
tools = registry.get_by_plugin_type(plugin_type)
return [
{
"name": t.name,
"description": t.description,
"plugin_type": t.plugin_type,
"category": t.category,
"sensitivity": t.sensitivity,
"required_scope": t.required_scope,
"enabled": toggles.get(t.name, True),
}
for t in tools
]
# Singleton
_manager: ToolAccessManager | None = None
def get_tool_access_manager() -> ToolAccessManager:
"""Return the singleton :class:`ToolAccessManager`."""
global _manager
if _manager is None:
_manager = ToolAccessManager()
return _manager

View File

@@ -181,6 +181,9 @@ class ToolGenerator:
description = spec["description"]
schema = spec["schema"]
scope = spec.get("scope", "read")
# F.7: optional category + sensitivity for scope-based visibility
category = spec.get("category", "read")
sensitivity = spec.get("sensitivity", "normal")
# Create full tool name
tool_name = f"{plugin_type}_{action_name}"
@@ -202,6 +205,8 @@ class ToolGenerator:
handler=handler,
required_scope=scope,
plugin_type=plugin_type,
category=category,
sensitivity=sensitivity,
)
def _add_site_parameter(

View File

@@ -27,6 +27,10 @@ class ToolDefinition(BaseModel):
handler: Async function that executes the tool
required_scope: Required API key scope ("read", "write", "admin")
plugin_type: Plugin type this tool belongs to (e.g., "wordpress")
category: Tool category for scope-based visibility filtering (F.7)
One of: "read", "read_sensitive", "lifecycle", "crud", "env",
"backup", "system". Defaults to "read" for backward compatibility.
sensitivity: "normal" or "sensitive" (logs, envs, backups, connection strings).
"""
name: str = Field(..., description="Unique tool identifier")
@@ -40,6 +44,14 @@ class ToolDefinition(BaseModel):
default="read", description="Required API key scope (read/write/admin)"
)
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
category: str = Field(
default="read",
description="Tool category for scope-based visibility (F.7)",
)
sensitivity: str = Field(
default="normal",
description="Data sensitivity: normal or sensitive (F.7)",
)
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type

View File

@@ -27,6 +27,8 @@ from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__)
# Per-user rate limiting defaults
@@ -36,9 +38,6 @@ USER_RATE_LIMIT_PER_HR = int(os.getenv("USER_RATE_LIMIT_PER_HR", "500"))
# In-memory rate limit tracking: user_id -> list of timestamps
_rate_limits: dict[str, list[float]] = {}
# Cache for tool schemas per plugin type (computed once)
_tool_schema_cache: dict[str, list[dict[str, Any]]] = {}
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
"""Check per-user rate limits.
@@ -71,24 +70,15 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
return True, ""
def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
"""Get MCP tool definitions for a plugin type (cached).
def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
"""Convert ToolDefinition objects into MCP ``tools/list`` response shape.
Returns tool schemas with the ``site`` parameter removed
(auto-injected for user endpoints).
Strips the auto-injected ``site`` parameter, since user endpoints bind a
single site per alias.
"""
if plugin_type in _tool_schema_cache:
return _tool_schema_cache[plugin_type]
from core.tool_registry import get_tool_registry
registry = get_tool_registry()
tools = registry.get_by_plugin_type(plugin_type)
result = []
for tool_def in tools:
schema = deepcopy(tool_def.input_schema)
# Remove 'site' parameter (auto-injected)
if "properties" in schema:
schema["properties"].pop("site", None)
if "required" in schema and "site" in schema["required"]:
@@ -101,11 +91,24 @@ def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
"inputSchema": schema,
}
)
_tool_schema_cache[plugin_type] = result
return result
async def _get_visible_tools_for_site(
site_id: str,
key_scopes: list[str],
plugin_type: str,
) -> list[dict[str, Any]]:
"""Return tools/list payload filtered by key scope + site scope + toggles (F.7b)."""
from core.tool_access import get_tool_access_manager
access = get_tool_access_manager()
tools = await access.get_visible_tools(
site_id=site_id, key_scopes=key_scopes, plugin_type=plugin_type
)
return _tools_to_mcp_schema(tools)
async def _execute_tool(
tool_name: str,
arguments: dict[str, Any],
@@ -353,7 +356,7 @@ async def user_mcp_handler(request: Request) -> Response:
return Response(status_code=204)
elif method == "tools/list":
tools = _get_tools_for_plugin(site["plugin_type"])
tools = await _get_visible_tools_for_site(site["id"], key_scopes, site["plugin_type"])
return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
elif method == "tools/call":
@@ -378,19 +381,56 @@ async def user_mcp_handler(request: Request) -> Response:
required_scope = tool_def.required_scope
# key_scopes is set during authentication (both mhu_ and JWT paths)
# F.7b: enforce category-based scope allowlist in addition to the
# legacy read/write/admin hierarchy. A tool is allowed only if BOTH
# (a) the legacy hierarchy grants it, AND
# (b) the tool's category is in BOTH the key-scope set AND the
# site's stored tool_scope set (the narrower layer wins).
from core.tool_access import KNOWN_CATEGORIES, SCOPE_CUSTOM, scopes_to_categories
scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
required_level = scope_hierarchy.get(required_scope, 0)
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0])
legacy_ok = key_level >= required_level
if key_level < required_level:
key_cats = scopes_to_categories(key_scopes)
key_category_ok = tool_def.category not in KNOWN_CATEGORIES or tool_def.category in key_cats
# Site-level scope check (skipped for "custom" preset).
site_scope = site.get("tool_scope") or "admin"
if site_scope and site_scope != SCOPE_CUSTOM:
site_cats = scopes_to_categories([site_scope])
site_category_ok = (
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in site_cats
)
else:
site_category_ok = True
if not (legacy_ok and key_category_ok and site_category_ok):
return JSONResponse(
_jsonrpc_error(
req_id,
-32600,
f"Insufficient scope. Tool '{tool_name}' requires '{required_scope}' scope.",
f"Insufficient scope. Tool '{tool_name}' requires "
f"scope '{required_scope}' (category '{tool_def.category}').",
)
)
# F.7b: honour per-site tool toggles — a disabled tool cannot be called
# even if scopes would otherwise allow it.
try:
toggles = await db.get_site_tool_toggles(site["id"])
if not toggles.get(tool_name, True):
return JSONResponse(
_jsonrpc_error(
req_id,
-32600,
f"Tool '{tool_name}' is disabled for this site.",
)
)
except Exception as exc: # non-fatal — fall through on DB errors
logger.warning("Failed to check site tool toggles for %s: %s", site["id"], exc)
# Decrypt credentials
try:
from core.encryption import get_credential_encryption