feat(F.7b): tool access UI + unified keys page (v3.9.0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify) with 633 tools total. The tool count stays constant regardless of how many sites are configured.
|
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify) with 597 tools total. The tool count stays constant regardless of how many sites are configured.
|
||||||
|
|
||||||
## Quick Setup
|
## Quick Setup
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
|
|||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
[](https://pypi.org/project/mcphub-server/)
|
[](https://pypi.org/project/mcphub-server/)
|
||||||
[](https://hub.docker.com/r/airano/mcphub)
|
[](https://hub.docker.com/r/airano/mcphub)
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -265,7 +265,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
/mcp → Admin endpoint (all 633 tools)
|
/mcp → Admin endpoint (all 565 tools)
|
||||||
/system/mcp → System tools only (24 tools)
|
/system/mcp → System tools only (24 tools)
|
||||||
/wordpress/mcp → WordPress tools (67 tools)
|
/wordpress/mcp → WordPress tools (67 tools)
|
||||||
/woocommerce/mcp → WooCommerce tools (28 tools)
|
/woocommerce/mcp → WooCommerce tools (28 tools)
|
||||||
@@ -275,13 +275,12 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
/supabase/mcp → Supabase tools (70 tools)
|
/supabase/mcp → Supabase tools (70 tools)
|
||||||
/openpanel/mcp → OpenPanel tools (42 tools)
|
/openpanel/mcp → OpenPanel tools (42 tools)
|
||||||
/appwrite/mcp → Appwrite tools (100 tools)
|
/appwrite/mcp → Appwrite tools (100 tools)
|
||||||
/coolify/mcp → Coolify tools (67 tools)
|
|
||||||
/directus/mcp → Directus tools (100 tools)
|
/directus/mcp → Directus tools (100 tools)
|
||||||
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
||||||
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
|
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (633 tools) to minimize token usage.
|
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (565 tools) to minimize token usage.
|
||||||
|
|
||||||
| Endpoint | Use Case | Tools |
|
| Endpoint | Use Case | Tools |
|
||||||
|----------|----------|------:|
|
|----------|----------|------:|
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ DASHBOARD_TRANSLATIONS = {
|
|||||||
"admin_login": "Admin Login with API Key",
|
"admin_login": "Admin Login with API Key",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"admin_badge": "Admin",
|
"admin_badge": "Admin",
|
||||||
|
"keys": "API Keys",
|
||||||
},
|
},
|
||||||
"fa": {
|
"fa": {
|
||||||
# Navigation
|
# Navigation
|
||||||
@@ -265,6 +266,7 @@ DASHBOARD_TRANSLATIONS = {
|
|||||||
"admin_login": "ورود مدیر با کلید API",
|
"admin_login": "ورود مدیر با کلید API",
|
||||||
"profile": "پروفایل",
|
"profile": "پروفایل",
|
||||||
"admin_badge": "مدیر",
|
"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)
|
# 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)
|
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:
|
async def api_get_config(request: Request) -> Response:
|
||||||
"""GET /api/config/{alias} — Get config snippets for a site."""
|
"""GET /api/config/{alias} — Get config snippets for a site."""
|
||||||
user_session, redirect = _require_user_session(request)
|
user_session, redirect = _require_user_session(request)
|
||||||
@@ -3471,8 +3776,11 @@ def register_dashboard_routes(mcp):
|
|||||||
dashboard_project_detail
|
dashboard_project_detail
|
||||||
)
|
)
|
||||||
|
|
||||||
# API Keys routes
|
# API Keys routes (unified — /dashboard/keys replaces /dashboard/api-keys and /dashboard/connect)
|
||||||
mcp.custom_route("/dashboard/api-keys", methods=["GET"])(dashboard_api_keys_list)
|
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
|
# OAuth Clients routes
|
||||||
mcp.custom_route("/dashboard/oauth-clients", methods=["GET"])(dashboard_oauth_clients_list)
|
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", methods=["GET"])(dashboard_sites_list)
|
||||||
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add)
|
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}/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)
|
# Service pages (F.3)
|
||||||
mcp.custom_route("/dashboard/services", methods=["GET"])(dashboard_services_list)
|
mcp.custom_route("/dashboard/services", methods=["GET"])(dashboard_services_list)
|
||||||
|
|||||||
162
core/database.py
162
core/database.py
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
|
|||||||
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
|
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
|
||||||
|
|
||||||
# Schema version — increment when adding migrations
|
# Schema version — increment when adding migrations
|
||||||
SCHEMA_VERSION = 5
|
SCHEMA_VERSION = 7
|
||||||
|
|
||||||
# Initial schema DDL
|
# Initial schema DDL
|
||||||
_SCHEMA_SQL = """\
|
_SCHEMA_SQL = """\
|
||||||
@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS sites (
|
|||||||
status_msg TEXT,
|
status_msg TEXT,
|
||||||
last_health TEXT,
|
last_health TEXT,
|
||||||
last_tested_at TEXT,
|
last_tested_at TEXT,
|
||||||
|
tool_scope TEXT NOT NULL DEFAULT 'admin',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
UNIQUE(user_id, alias)
|
UNIQUE(user_id, alias)
|
||||||
);
|
);
|
||||||
@@ -100,6 +101,18 @@ CREATE TABLE IF NOT EXISTS schema_version (
|
|||||||
version INTEGER PRIMARY KEY,
|
version INTEGER PRIMARY KEY,
|
||||||
applied_at TEXT NOT NULL
|
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
|
# Migration registry: version -> SQL string
|
||||||
@@ -120,6 +133,38 @@ _MIGRATIONS: dict[int, str] = {
|
|||||||
" updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n"
|
" updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n"
|
||||||
");\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),
|
(_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
|
# Module-level helpers
|
||||||
|
|||||||
@@ -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',
|
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'),
|
'/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'),
|
('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
|
('keys', t.get('keys', 'API Keys'), 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
|
||||||
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'),
|
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
|
('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
|
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'),
|
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
|
||||||
|
|||||||
493
core/templates/dashboard/keys/list.html
Normal file
493
core/templates/dashboard/keys/list.html
Normal 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 %}
|
||||||
@@ -110,6 +110,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
@@ -168,5 +209,162 @@
|
|||||||
btn.disabled = false;
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -80,6 +80,10 @@
|
|||||||
id="test-btn-{{ site.id }}">
|
id="test-btn-{{ site.id }}">
|
||||||
{{ t.test_connection }}
|
{{ t.test_connection }}
|
||||||
</button>
|
</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 %}"
|
<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">
|
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
{{ t.edit }}
|
{{ t.edit }}
|
||||||
|
|||||||
134
core/templates/dashboard/sites/view.html
Normal file
134
core/templates/dashboard/sites/view.html
Normal 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
326
core/tool_access.py
Normal 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
|
||||||
@@ -181,6 +181,9 @@ class ToolGenerator:
|
|||||||
description = spec["description"]
|
description = spec["description"]
|
||||||
schema = spec["schema"]
|
schema = spec["schema"]
|
||||||
scope = spec.get("scope", "read")
|
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
|
# Create full tool name
|
||||||
tool_name = f"{plugin_type}_{action_name}"
|
tool_name = f"{plugin_type}_{action_name}"
|
||||||
@@ -202,6 +205,8 @@ class ToolGenerator:
|
|||||||
handler=handler,
|
handler=handler,
|
||||||
required_scope=scope,
|
required_scope=scope,
|
||||||
plugin_type=plugin_type,
|
plugin_type=plugin_type,
|
||||||
|
category=category,
|
||||||
|
sensitivity=sensitivity,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _add_site_parameter(
|
def _add_site_parameter(
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class ToolDefinition(BaseModel):
|
|||||||
handler: Async function that executes the tool
|
handler: Async function that executes the tool
|
||||||
required_scope: Required API key scope ("read", "write", "admin")
|
required_scope: Required API key scope ("read", "write", "admin")
|
||||||
plugin_type: Plugin type this tool belongs to (e.g., "wordpress")
|
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")
|
name: str = Field(..., description="Unique tool identifier")
|
||||||
@@ -40,6 +44,14 @@ class ToolDefinition(BaseModel):
|
|||||||
default="read", description="Required API key scope (read/write/admin)"
|
default="read", description="Required API key scope (read/write/admin)"
|
||||||
)
|
)
|
||||||
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
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
|
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ from typing import Any
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse, Response
|
from starlette.responses import JSONResponse, Response
|
||||||
|
|
||||||
|
from core.tool_registry import ToolDefinition
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Per-user rate limiting defaults
|
# 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
|
# In-memory rate limit tracking: user_id -> list of timestamps
|
||||||
_rate_limits: dict[str, list[float]] = {}
|
_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]:
|
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
|
||||||
"""Check per-user rate limits.
|
"""Check per-user rate limits.
|
||||||
@@ -71,24 +70,15 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
|
|||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
|
def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
|
||||||
"""Get MCP tool definitions for a plugin type (cached).
|
"""Convert ToolDefinition objects into MCP ``tools/list`` response shape.
|
||||||
|
|
||||||
Returns tool schemas with the ``site`` parameter removed
|
Strips the auto-injected ``site`` parameter, since user endpoints bind a
|
||||||
(auto-injected for user endpoints).
|
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 = []
|
result = []
|
||||||
for tool_def in tools:
|
for tool_def in tools:
|
||||||
schema = deepcopy(tool_def.input_schema)
|
schema = deepcopy(tool_def.input_schema)
|
||||||
# Remove 'site' parameter (auto-injected)
|
|
||||||
if "properties" in schema:
|
if "properties" in schema:
|
||||||
schema["properties"].pop("site", None)
|
schema["properties"].pop("site", None)
|
||||||
if "required" in schema and "site" in schema["required"]:
|
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,
|
"inputSchema": schema,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
_tool_schema_cache[plugin_type] = result
|
|
||||||
return 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(
|
async def _execute_tool(
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
@@ -353,7 +356,7 @@ async def user_mcp_handler(request: Request) -> Response:
|
|||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
elif method == "tools/list":
|
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}))
|
return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
|
||||||
|
|
||||||
elif method == "tools/call":
|
elif method == "tools/call":
|
||||||
@@ -378,19 +381,56 @@ async def user_mcp_handler(request: Request) -> Response:
|
|||||||
required_scope = tool_def.required_scope
|
required_scope = tool_def.required_scope
|
||||||
# key_scopes is set during authentication (both mhu_ and JWT paths)
|
# 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}
|
scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
|
||||||
required_level = scope_hierarchy.get(required_scope, 0)
|
required_level = scope_hierarchy.get(required_scope, 0)
|
||||||
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [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(
|
return JSONResponse(
|
||||||
_jsonrpc_error(
|
_jsonrpc_error(
|
||||||
req_id,
|
req_id,
|
||||||
-32600,
|
-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
|
# Decrypt credentials
|
||||||
try:
|
try:
|
||||||
from core.encryption import get_credential_encryption
|
from core.encryption import get_credential_encryption
|
||||||
|
|||||||
136
docs/prompts/next-session-f7-tool-access.md
Normal file
136
docs/prompts/next-session-f7-tool-access.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
|
||||||
|
|
||||||
|
## فایلهای مرجع
|
||||||
|
- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal) ← Phase F.7 طراحی کامل
|
||||||
|
- core/tool_generator.py (mcphub-internal) ← تولید ابزار فعلی
|
||||||
|
- core/tool_registry.py (mcphub-internal) ← رجیستری ابزار
|
||||||
|
- core/user_endpoints.py (mcphub-internal) ← فیلتر ابزار در endpoint کاربر
|
||||||
|
- core/user_keys.py (mcphub-internal) ← سیستم API key کاربر
|
||||||
|
- core/database.py (mcphub-internal) ← دیتابیس و مایگریشن
|
||||||
|
- core/plugin_visibility.py (mcphub-internal) ← فیلتر پلاگین فعلی
|
||||||
|
- core/dashboard/routes.py (mcphub-internal) ← روتهای داشبورد
|
||||||
|
- server.py (mcphub-internal) ← middleware و scope enforcement
|
||||||
|
- CLAUDE.md (mcphub-internal)
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
- MCPHub: v3.8.0 — 633 ابزار، 10 پلاگین، 67 ابزار Coolify
|
||||||
|
- تستها: 766 (internal), 734 (public)
|
||||||
|
- CI سبز
|
||||||
|
- Scope فعلی: read/write/admin (سه سطح ساده)
|
||||||
|
- فیلتر فعلی: فقط plugin-level (ENABLED_PLUGINS) — بدون per-tool toggle
|
||||||
|
|
||||||
|
## ریپازیتوری
|
||||||
|
- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
|
||||||
|
|
||||||
|
## هدف session: F.7 — Smart Tool Visibility & Scope-Based Access Control
|
||||||
|
|
||||||
|
### مشکلاتی که حل میشوند
|
||||||
|
1. همه ابزارهای یک پلاگین فعال به همه کاربران نشان داده میشوند — کنترل per-tool نداریم
|
||||||
|
2. وقتی کاربر API key با scope خاص (مثلا read) میسازد، باز هم همه ابزارها در tools/list نمایش داده میشوند
|
||||||
|
3. ابزارهای وردپرس که نیاز به افزونههای کمکی دارند (SEO Bridge, WP-CLI) بدون بررسی prerequisite نشان داده میشوند
|
||||||
|
4. کاربران نمیتوانند ابزارهایی که نیاز ندارند را غیرفعال کنند
|
||||||
|
|
||||||
|
### مدل scope پیشنهادی (گسترشیافته)
|
||||||
|
فعلی: `read`, `write`, `admin`
|
||||||
|
جدید:
|
||||||
|
- `deploy` — عملیات lifecycle (start/stop/restart/deploy) + read
|
||||||
|
- `read:sensitive` — read + لاگ، env var، بکاپ، connection string
|
||||||
|
|
||||||
|
**نگاشت scope → دستهبندی ابزار:**
|
||||||
|
| Scope | ابزارها |
|
||||||
|
|-------|---------|
|
||||||
|
| `read` | list_*, get_* (بدون sensitive) |
|
||||||
|
| `read:sensitive` | read + *_logs, *_envs, *_backups |
|
||||||
|
| `deploy` | read + start_*, stop_*, restart_*, deploy |
|
||||||
|
| `write` | deploy + create_*, update_*, delete_*_env |
|
||||||
|
| `admin` | write + delete_* (منابع)، create_server |
|
||||||
|
|
||||||
|
### بخش اول: Core — ساختار داده و مدیریت دسترسی (بدون UI)
|
||||||
|
|
||||||
|
#### مرحله ۱: دیتابیس
|
||||||
|
- [ ] جدول `user_tool_toggles` در `core/database.py`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_tool_toggles (
|
||||||
|
id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tool_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
reason TEXT, updated_at TEXT NOT NULL, UNIQUE(user_id, tool_name)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- [ ] جدول `scope_presets` — پریستهای scope سیستمی + سفارشی
|
||||||
|
- [ ] Migration اجرا شود
|
||||||
|
|
||||||
|
#### مرحله ۲: ماژول tool_access.py
|
||||||
|
- [ ] `core/tool_access.py` — کلاس `ToolAccessManager`
|
||||||
|
- [ ] `get_visible_tools(user_id, scopes, plugin_type)` → لیست فیلتر شده
|
||||||
|
- [ ] `apply_scope_filter(tools, scopes)` → فقط ابزارهای مجاز بر اساس scope
|
||||||
|
- [ ] `apply_user_toggles(tools, user_id)` → اعمال toggleهای کاربر
|
||||||
|
- [ ] `toggle_tool(user_id, tool_name, enabled)` → ذخیره تنظیم
|
||||||
|
- [ ] `bulk_toggle_by_scope(user_id, scope_name)` → فعال/غیرفعال دستهجمعی
|
||||||
|
|
||||||
|
#### مرحله ۳: Tool metadata enhancement
|
||||||
|
- [ ] اضافه کردن `sensitivity` و `category` به tool specs در handlerها:
|
||||||
|
- `sensitivity`: "normal" | "sensitive" (لاگ، env، بکاپ)
|
||||||
|
- `category`: "read" | "lifecycle" | "crud" | "env" | "backup" | "system"
|
||||||
|
- [ ] شروع از Coolify (آخرین و تمیزترین) سپس سایر پلاگینها
|
||||||
|
- [ ] ToolDefinition در tool_registry.py آپدیت شود
|
||||||
|
|
||||||
|
#### مرحله ۴: فیلتر در user_endpoints.py
|
||||||
|
- [ ] `_get_tools_for_plugin()` از ToolAccessManager استفاده کند
|
||||||
|
- [ ] Pipeline فیلتر:
|
||||||
|
1. plugin_visibility (موجود)
|
||||||
|
2. scope-to-tool mapping (جدید)
|
||||||
|
3. user toggles (جدید)
|
||||||
|
- [ ] Scope enforcement در middleware آپدیت شود (server.py)
|
||||||
|
|
||||||
|
#### مرحله ۵: تست
|
||||||
|
- [ ] `tests/test_tool_access.py` — unit tests
|
||||||
|
- [ ] تستهای scope mapping: key با scope "read" → فقط ابزارهای read
|
||||||
|
- [ ] تستهای toggle: کاربر disable کرده → ابزار در tools/list نیست
|
||||||
|
- [ ] تستهای integration: API key scope → فیلتر واقعی
|
||||||
|
|
||||||
|
### بخش دوم: API — روتهای مدیریت toggle
|
||||||
|
|
||||||
|
- [ ] `GET /api/user/tools` — لیست ابزارها با وضعیت toggle
|
||||||
|
- [ ] `PATCH /api/user/tools/{tool_name}` — تغییر toggle
|
||||||
|
- [ ] `POST /api/user/tools/bulk-toggle` — toggle دستهجمعی بر اساس scope
|
||||||
|
- [ ] `GET /api/user/scope-presets` — لیست presetها
|
||||||
|
- [ ] تست: روتها کار کنند
|
||||||
|
|
||||||
|
### بخش سوم: Prerequisites (وردپرس/ووکامرس)
|
||||||
|
|
||||||
|
- [ ] `check_prerequisites(tools, site_config)` در tool_access.py
|
||||||
|
- [ ] تشخیص SEO Bridge: `wp-json/airano-mcp-seo-bridge/v1/status`
|
||||||
|
- [ ] تشخیص WP-CLI: بررسی `container` field در credentials
|
||||||
|
- [ ] تشخیص WooCommerce: `wp-json/wc/v3/system_status`
|
||||||
|
- [ ] ابزارهای وابسته علامتگذاری شوند (نه حذف — فقط annotation)
|
||||||
|
|
||||||
|
### بخش چهارم: UI — صفحه مدیریت ابزار
|
||||||
|
|
||||||
|
- [ ] `core/templates/dashboard/tool-preferences.html`
|
||||||
|
- [ ] لیست ابزارها گروهبندی شده بر اساس category
|
||||||
|
- [ ] Toggle switch برای هر ابزار
|
||||||
|
- [ ] Badge برای prerequisite (نصب نشده / نیاز به Docker)
|
||||||
|
- [ ] Dropdown برای اعمال scope preset
|
||||||
|
- [ ] در صفحه Connect: پیشنمایش ابزارها هنگام ساخت API key
|
||||||
|
|
||||||
|
### بخش پنجم: ثبت و تست نهایی
|
||||||
|
|
||||||
|
- [ ] `uvx --python 3.12 black .`
|
||||||
|
- [ ] `uvx ruff check --fix .`
|
||||||
|
- [ ] `pytest` — همه تستها سبز
|
||||||
|
- [ ] Commit: `feat(F.7): add smart tool visibility and scope-based access control`
|
||||||
|
- [ ] Push و درخواست redeploy
|
||||||
|
- [ ] تست live: ساخت API key با scope "read" → بررسی tools/list
|
||||||
|
- [ ] Sync به نسخه عمومی
|
||||||
|
- [ ] آپدیت ورژن به v3.9.0 (اگر تأیید شد)
|
||||||
|
- [ ] حافظه آپدیت شود
|
||||||
|
- [ ] پلن آپدیت شود (F.7 complete)
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- فیلتر scope باید backward-compatible باشد — keyهای موجود بدون تغییر کار کنند
|
||||||
|
- Default: همه ابزارها فعال — فقط explicit disable ذخیره شود
|
||||||
|
- `user_tool_toggles` فقط overrideها رو ذخیره میکنه، نه همه ابزارها
|
||||||
|
- Prerequisite check باید non-blocking باشه — ابزار حذف نشه، فقط annotate بشه
|
||||||
|
- server.py نیازی به تغییر زیاد ندارد — فقط middleware scope check آپدیت شود
|
||||||
|
- ایمیل git داخلی: mcphub.dev@gmail.com
|
||||||
|
- بخش اول و دوم اولویت اصلی هستند — بخش سوم و چهارم اگر وقت شد
|
||||||
98
docs/prompts/next-session-f7b-ui.md
Normal file
98
docs/prompts/next-session-f7b-ui.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
|
||||||
|
|
||||||
|
## فایلهای مرجع
|
||||||
|
- docs/plans/2026-04-04-f7b-site-scoped-tool-access.md ← پلن کامل F.7b
|
||||||
|
- core/tool_access.py ← ToolAccessManager (سایتمحور — session 1)
|
||||||
|
- core/dashboard/routes.py ← روتهای API site tools (بخش F.7b)
|
||||||
|
- core/templates/dashboard/sites/edit.html ← صفحه edit سایت (بدون بخش tools)
|
||||||
|
- core/templates/dashboard/connect.html ← صفحه connect فعلی (config snippets + keys)
|
||||||
|
- core/templates/dashboard/api-keys/list.html ← صفحه admin کلیدها (UI بهتر)
|
||||||
|
- core/dashboard/routes.py::dashboard_connect_page / dashboard_api_keys_list
|
||||||
|
- CLAUDE.md (mcphub-internal)
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
- MCPHub: v3.8.0 + F.7b session 1 (commit روی Phase-1)
|
||||||
|
- Tests: 813 passed، CI سبز
|
||||||
|
- Backend F.7b کامل است: per-site tool_scope + site_tool_toggles + 4 روت جدید تحت `/api/sites/{site_id}/...` + `/api/scope-presets`
|
||||||
|
- فقط UI باقی مانده — هدف این session
|
||||||
|
|
||||||
|
## ریپازیتوری
|
||||||
|
- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
|
||||||
|
|
||||||
|
## هدف session: F.7b — UI + page merge
|
||||||
|
|
||||||
|
### ۱. بخش "Tool Access" در صفحه edit سایت
|
||||||
|
فایل: `core/templates/dashboard/sites/edit.html`
|
||||||
|
|
||||||
|
- [ ] اضافه کردن یک کارت جدید "Tool Access" بعد از فرم credentials
|
||||||
|
- [ ] Dropdown برای `tool_scope` (values: read / read:sensitive / deploy / write / admin / custom)
|
||||||
|
- PATCH روی `/api/sites/{site_id}/tool-scope` با body `{scope: "..."}`
|
||||||
|
- توضیح کوتاه کنار هر گزینه: "Read (X tools)" — شمارش زنده از `/api/sites/{site_id}/tools`
|
||||||
|
- [ ] Collapsible "Advanced — per-tool overrides":
|
||||||
|
- گرید/لیست گروهبندی شده بر اساس `category` (read / read_sensitive / lifecycle / crud / env / backup / system)
|
||||||
|
- Toggle switch برای هر ابزار → PATCH `/api/sites/{site_id}/tools/{tool_name}` با `{enabled: bool}`
|
||||||
|
- Badge قرمز برای `sensitivity=sensitive`
|
||||||
|
- نام کوتاه از `name`، tooltip با `description`
|
||||||
|
- [ ] استفاده از HTMX (در پروژه موجود است) برای updates بدون full reload
|
||||||
|
- [ ] CSRF token از cookie `dashboard_csrf` به header `X-CSRF-Token`
|
||||||
|
|
||||||
|
### ۲. انتقال config snippets از connect به صفحه سایت
|
||||||
|
فایل: `core/templates/dashboard/sites/view.html` (یا ایجاد اگر وجود ندارد)
|
||||||
|
|
||||||
|
- [ ] هر سایت در `/dashboard/sites/{id}` نمایش دهد:
|
||||||
|
- URL MCP مخصوص آن سایت: `{PUBLIC_URL}/u/{user_id}/{alias}/mcp`
|
||||||
|
- Tabs یا accordion با snippets برای Claude Desktop / Cursor / Zed / کلاینتهای دیگر
|
||||||
|
- استفاده از `core/config_snippets.py::get_supported_clients` (موجود)
|
||||||
|
- [ ] از صفحه `/dashboard/sites` (list) دکمه "Connect" به این صفحه لینک بزند
|
||||||
|
|
||||||
|
### ۳. ادغام `/dashboard/connect` و `/dashboard/api-keys` → `/dashboard/keys` (گزینه A)
|
||||||
|
UI مبنا: `core/templates/dashboard/api-keys/list.html` (قشنگتر و کاملتر است طبق تأیید کاربر)
|
||||||
|
|
||||||
|
- [ ] ساخت handler `dashboard_keys_unified(request)` که بر اساس session type branch میزند:
|
||||||
|
- OAuth user → نمایش `user_api_keys` برای آن کاربر
|
||||||
|
- Admin/master → نمایش کامل `api_keys` (همان view فعلی)
|
||||||
|
- [ ] template جدید `core/templates/dashboard/keys/list.html` با ادغام design از `api-keys/list.html`
|
||||||
|
- User view: سادهتر، scope selector در create dialog، لیست کلیدهای خود کاربر
|
||||||
|
- Admin view: فیلترهای کامل (project, status, search, pagination) — بدون تغییر
|
||||||
|
- [ ] **Scope selector در create-key dialog** — این بخش حیاتی است:
|
||||||
|
- Radio/select: read / read:sensitive / deploy / write / admin
|
||||||
|
- Helper text: "Per-site tool filters are set in Site Settings"
|
||||||
|
- POST به `/api/keys` (همان endpoint فعلی) با `scopes: "<selected>"`
|
||||||
|
- [ ] Redirect های قدیمی:
|
||||||
|
- `/dashboard/connect` → `/dashboard/keys` (301)
|
||||||
|
- `/dashboard/api-keys` → `/dashboard/keys` (301)
|
||||||
|
- [ ] حذف handler های قدیمی `dashboard_connect_page` و `dashboard_api_keys_list` و یا تبدیل به thin wrapper redirect
|
||||||
|
- [ ] منوی navigation sidebar را update کن — فقط یک entry "API Keys"
|
||||||
|
|
||||||
|
### ۴. گزینه B (ادغام عمیق DB) — deferred
|
||||||
|
در پلن session 1 ذکر شده اما اجرا نمیکنیم مگر کاربر صراحتاً درخواست کند. کامنت در code اضافه کنید به `api_create_key` که "dual-table model is intentional — see F.7b plan".
|
||||||
|
|
||||||
|
### ۵. تست
|
||||||
|
- [ ] `tests/test_dashboard_keys_unified.py` — تست منوی unified، scope selector، 301 redirect از URL های قدیمی
|
||||||
|
- [ ] `tests/test_sites_tool_access_ui.py` — smoke test که edit page با tool_scope=read درست render شود (میتوان با TestClient چک کرد که template بدون 500 میآید)
|
||||||
|
- [ ] بهروزرسانی `tests/test_dashboard.py::test_dashboard_connect_page` → به `/dashboard/keys` منتقل شود یا به پذیرش redirect
|
||||||
|
- [ ] pytest کامل سبز
|
||||||
|
|
||||||
|
### ۶. ورژن، sync، commit
|
||||||
|
- [ ] `uvx --python 3.12 black . && uvx --python 3.12 ruff check --fix .`
|
||||||
|
- [ ] bump version به `v3.9.0` در pyproject.toml + `__version__` در server.py (اگر وجود دارد)
|
||||||
|
- [ ] Commit: `feat(F.7b): tool access UI + unified keys page (v3.9.0)`
|
||||||
|
- [ ] Push به Phase-1
|
||||||
|
- [ ] `python3.11 scripts/community-build/sync.py --output ../mcphub/` سپس در repo عمومی `black` + `ruff`
|
||||||
|
- [ ] Commit عمومی با ایمیل `hi.airano@gmail.com` و push
|
||||||
|
- [ ] درخواست deploy از کاربر
|
||||||
|
|
||||||
|
### ۷. تست live پس از deploy
|
||||||
|
- [ ] ورود به `/dashboard/sites/{id}/edit` → بخش Tool Access → تغییر scope به `read` → save
|
||||||
|
- [ ] بدون ساخت کلید جدید، MCP client (همان کلید admin موجود) روی آن alias → `tools/list` باید فقط ابزارهای read را نشان دهد
|
||||||
|
- [ ] تغییر به `custom` → Advanced → disable یک ابزار خاص (مثلاً `coolify_delete_server`) → تست
|
||||||
|
- [ ] ساخت کلید جدید از صفحه unified با scope=`read` → بررسی در لیست
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- **Backward compatibility:** سایتهای موجود `tool_scope='admin'` دارند (default migration v7) → هیچ تغییر رفتاری روی سایتهای قدیمی
|
||||||
|
- **فیلترها:** key scope و site scope **intersect** میشوند. admin key + site=read → فقط read. write key + site=deploy → فقط read + lifecycle.
|
||||||
|
- **CSRF:** middleware روی `/api/sites/*` فعال است. UI باید header `X-CSRF-Token` از cookie `dashboard_csrf` بفرستد. HTMX این را با `hx-headers` هندل میکند.
|
||||||
|
- **CSS:** پروژه Tailwind دارد. از همان کلاسهای موجود در `api-keys/list.html` استفاده کن برای consistency.
|
||||||
|
- **i18n:** پروژه EN/FA است. متنهای جدید را به `core/i18n.py` اضافه کن.
|
||||||
|
- **ایمیل git داخلی:** mcphub.dev@gmail.com | ایمیل عمومی: hi.airano@gmail.com
|
||||||
|
- **نباید:** توابع F.7 v1 (با `user_` prefix) را بازگردانی کنی. همه سایتمحور است.
|
||||||
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "list_applications",
|
"name": "list_applications",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_applications",
|
"method_name": "list_applications",
|
||||||
"description": "List all Coolify applications. Optionally filter by tag name.",
|
"description": "List all Coolify applications. Optionally filter by tag name.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -26,6 +27,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_application",
|
"name": "get_application",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_application",
|
"method_name": "get_application",
|
||||||
"description": "Get details of a specific Coolify application by UUID.",
|
"description": "Get details of a specific Coolify application by UUID.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -43,6 +45,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_application_public",
|
"name": "create_application_public",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_application_public",
|
"method_name": "create_application_public",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a Coolify application from a public Git repository. "
|
"Create a Coolify application from a public Git repository. "
|
||||||
@@ -113,6 +116,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_application_dockerfile",
|
"name": "create_application_dockerfile",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_application_dockerfile",
|
"method_name": "create_application_dockerfile",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a Coolify application from a Dockerfile (without git). "
|
"Create a Coolify application from a Dockerfile (without git). "
|
||||||
@@ -170,6 +174,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_application_docker_image",
|
"name": "create_application_docker_image",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_application_docker_image",
|
"method_name": "create_application_docker_image",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a Coolify application from a Docker image. "
|
"Create a Coolify application from a Docker image. "
|
||||||
@@ -233,6 +238,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_application_compose",
|
"name": "create_application_compose",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_application_compose",
|
"method_name": "create_application_compose",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a Coolify application from Docker Compose. "
|
"Create a Coolify application from Docker Compose. "
|
||||||
@@ -278,6 +284,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_application",
|
"name": "update_application",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "update_application",
|
"method_name": "update_application",
|
||||||
"description": (
|
"description": (
|
||||||
"Update a Coolify application settings. Supports name, description, "
|
"Update a Coolify application settings. Supports name, description, "
|
||||||
@@ -357,6 +364,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_application",
|
"name": "delete_application",
|
||||||
|
"category": "system",
|
||||||
"method_name": "delete_application",
|
"method_name": "delete_application",
|
||||||
"description": (
|
"description": (
|
||||||
"Delete a Coolify application permanently. "
|
"Delete a Coolify application permanently. "
|
||||||
@@ -397,6 +405,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "start_application",
|
"name": "start_application",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "start_application",
|
"method_name": "start_application",
|
||||||
"description": "Deploy/start a Coolify application. Triggers a new deployment.",
|
"description": "Deploy/start a Coolify application. Triggers a new deployment.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -424,6 +433,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "stop_application",
|
"name": "stop_application",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "stop_application",
|
"method_name": "stop_application",
|
||||||
"description": "Stop a running Coolify application.",
|
"description": "Stop a running Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -446,6 +456,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "restart_application",
|
"name": "restart_application",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "restart_application",
|
"method_name": "restart_application",
|
||||||
"description": "Restart a Coolify application.",
|
"description": "Restart a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -463,6 +474,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_application_logs",
|
"name": "get_application_logs",
|
||||||
|
"category": "read_sensitive",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "get_application_logs",
|
"method_name": "get_application_logs",
|
||||||
"description": "Get logs for a Coolify application.",
|
"description": "Get logs for a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -487,6 +500,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "list_application_envs",
|
"name": "list_application_envs",
|
||||||
|
"category": "read_sensitive",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "list_application_envs",
|
"method_name": "list_application_envs",
|
||||||
"description": "List environment variables for a Coolify application.",
|
"description": "List environment variables for a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -504,6 +519,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_application_env",
|
"name": "create_application_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "create_application_env",
|
"method_name": "create_application_env",
|
||||||
"description": "Create an environment variable for a Coolify application.",
|
"description": "Create an environment variable for a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -550,6 +567,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_application_env",
|
"name": "update_application_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "update_application_env",
|
"method_name": "update_application_env",
|
||||||
"description": "Update an environment variable for a Coolify application.",
|
"description": "Update an environment variable for a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -592,6 +611,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_application_envs_bulk",
|
"name": "update_application_envs_bulk",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "update_application_envs_bulk",
|
"method_name": "update_application_envs_bulk",
|
||||||
"description": "Bulk update environment variables for a Coolify application.",
|
"description": "Bulk update environment variables for a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -625,6 +646,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_application_env",
|
"name": "delete_application_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "delete_application_env",
|
"method_name": "delete_application_env",
|
||||||
"description": "Delete an environment variable from a Coolify application.",
|
"description": "Delete an environment variable from a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ def _create_db_spec(db_type: str, description: str) -> dict[str, Any]:
|
|||||||
"""Generate a create_* tool spec for a database type."""
|
"""Generate a create_* tool spec for a database type."""
|
||||||
return {
|
return {
|
||||||
"name": f"create_{db_type}",
|
"name": f"create_{db_type}",
|
||||||
|
"category": "crud",
|
||||||
"method_name": f"create_{db_type}",
|
"method_name": f"create_{db_type}",
|
||||||
"description": description,
|
"description": description,
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -55,6 +56,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
specs = [
|
specs = [
|
||||||
{
|
{
|
||||||
"name": "list_databases",
|
"name": "list_databases",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_databases",
|
"method_name": "list_databases",
|
||||||
"description": "List all Coolify databases.",
|
"description": "List all Coolify databases.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -65,6 +67,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_database",
|
"name": "get_database",
|
||||||
|
"category": "read_sensitive",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "get_database",
|
"method_name": "get_database",
|
||||||
"description": "Get details of a specific Coolify database by UUID.",
|
"description": "Get details of a specific Coolify database by UUID.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -82,6 +86,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_database",
|
"name": "update_database",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "update_database",
|
"method_name": "update_database",
|
||||||
"description": "Update a Coolify database settings.",
|
"description": "Update a Coolify database settings.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -119,6 +124,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_database",
|
"name": "delete_database",
|
||||||
|
"category": "system",
|
||||||
"method_name": "delete_database",
|
"method_name": "delete_database",
|
||||||
"description": (
|
"description": (
|
||||||
"Delete a Coolify database permanently. "
|
"Delete a Coolify database permanently. "
|
||||||
@@ -154,6 +160,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "start_database",
|
"name": "start_database",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "start_database",
|
"method_name": "start_database",
|
||||||
"description": "Start a Coolify database.",
|
"description": "Start a Coolify database.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -171,6 +178,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "stop_database",
|
"name": "stop_database",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "stop_database",
|
"method_name": "stop_database",
|
||||||
"description": "Stop a running Coolify database.",
|
"description": "Stop a running Coolify database.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -188,6 +196,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "restart_database",
|
"name": "restart_database",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "restart_database",
|
"method_name": "restart_database",
|
||||||
"description": "Restart a Coolify database.",
|
"description": "Restart a Coolify database.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -222,6 +231,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "get_database_backups",
|
"name": "get_database_backups",
|
||||||
|
"category": "backup",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "get_database_backups",
|
"method_name": "get_database_backups",
|
||||||
"description": "Get backup configuration and history for a Coolify database.",
|
"description": "Get backup configuration and history for a Coolify database.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -239,6 +250,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_database_backup",
|
"name": "create_database_backup",
|
||||||
|
"category": "backup",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "create_database_backup",
|
"method_name": "create_database_backup",
|
||||||
"description": "Create a manual backup of a Coolify database.",
|
"description": "Create a manual backup of a Coolify database.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -256,6 +269,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "list_backup_executions",
|
"name": "list_backup_executions",
|
||||||
|
"category": "backup",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "list_backup_executions",
|
"method_name": "list_backup_executions",
|
||||||
"description": "List all backup executions across all databases.",
|
"description": "List all backup executions across all databases.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "list_deployments",
|
"name": "list_deployments",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_deployments",
|
"method_name": "list_deployments",
|
||||||
"description": "List all running deployments on the Coolify instance.",
|
"description": "List all running deployments on the Coolify instance.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_deployment",
|
"name": "get_deployment",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_deployment",
|
"method_name": "get_deployment",
|
||||||
"description": "Get details of a specific deployment by UUID.",
|
"description": "Get details of a specific deployment by UUID.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "cancel_deployment",
|
"name": "cancel_deployment",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "cancel_deployment",
|
"method_name": "cancel_deployment",
|
||||||
"description": "Cancel a running deployment.",
|
"description": "Cancel a running deployment.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -55,6 +58,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deploy",
|
"name": "deploy",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "deploy",
|
"method_name": "deploy",
|
||||||
"description": (
|
"description": (
|
||||||
"Trigger deployment by tag name or resource UUID. "
|
"Trigger deployment by tag name or resource UUID. "
|
||||||
@@ -82,6 +86,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "list_app_deployments",
|
"name": "list_app_deployments",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_app_deployments",
|
"method_name": "list_app_deployments",
|
||||||
"description": "List deployment history for a specific application.",
|
"description": "List deployment history for a specific application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "list_projects",
|
"name": "list_projects",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_projects",
|
"method_name": "list_projects",
|
||||||
"description": "List all Coolify projects.",
|
"description": "List all Coolify projects.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_project",
|
"name": "get_project",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_project",
|
"method_name": "get_project",
|
||||||
"description": "Get details of a specific Coolify project by UUID.",
|
"description": "Get details of a specific Coolify project by UUID.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_project",
|
"name": "create_project",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_project",
|
"method_name": "create_project",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a new Coolify project. "
|
"Create a new Coolify project. "
|
||||||
@@ -62,6 +65,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_project",
|
"name": "update_project",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "update_project",
|
"method_name": "update_project",
|
||||||
"description": "Update a Coolify project name or description.",
|
"description": "Update a Coolify project name or description.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -87,6 +91,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_project",
|
"name": "delete_project",
|
||||||
|
"category": "system",
|
||||||
"method_name": "delete_project",
|
"method_name": "delete_project",
|
||||||
"description": (
|
"description": (
|
||||||
"Delete a Coolify project permanently. "
|
"Delete a Coolify project permanently. "
|
||||||
@@ -107,6 +112,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "list_environments",
|
"name": "list_environments",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_environments",
|
"method_name": "list_environments",
|
||||||
"description": "List all environments in a Coolify project.",
|
"description": "List all environments in a Coolify project.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -124,6 +130,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_environment",
|
"name": "get_environment",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_environment",
|
"method_name": "get_environment",
|
||||||
"description": ("Get details of a specific environment in a Coolify project by name."),
|
"description": ("Get details of a specific environment in a Coolify project by name."),
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -146,6 +153,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_environment",
|
"name": "create_environment",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_environment",
|
"method_name": "create_environment",
|
||||||
"description": "Create a new environment in a Coolify project.",
|
"description": "Create a new environment in a Coolify project.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "list_servers",
|
"name": "list_servers",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_servers",
|
"method_name": "list_servers",
|
||||||
"description": "List all servers registered in the Coolify instance.",
|
"description": "List all servers registered in the Coolify instance.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_server",
|
"name": "get_server",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_server",
|
"method_name": "get_server",
|
||||||
"description": "Get details of a specific server by UUID, including settings.",
|
"description": "Get details of a specific server by UUID, including settings.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_server",
|
"name": "create_server",
|
||||||
|
"category": "system",
|
||||||
"method_name": "create_server",
|
"method_name": "create_server",
|
||||||
"description": (
|
"description": (
|
||||||
"Register a new server in Coolify. "
|
"Register a new server in Coolify. "
|
||||||
@@ -98,6 +101,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_server",
|
"name": "update_server",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "update_server",
|
"method_name": "update_server",
|
||||||
"description": "Update server configuration.",
|
"description": "Update server configuration.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -142,6 +146,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_server",
|
"name": "delete_server",
|
||||||
|
"category": "system",
|
||||||
"method_name": "delete_server",
|
"method_name": "delete_server",
|
||||||
"description": "Delete a server from Coolify. This cannot be undone!",
|
"description": "Delete a server from Coolify. This cannot be undone!",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -159,6 +164,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_server_resources",
|
"name": "get_server_resources",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_server_resources",
|
"method_name": "get_server_resources",
|
||||||
"description": (
|
"description": (
|
||||||
"Get all resources (applications, databases, services) "
|
"Get all resources (applications, databases, services) "
|
||||||
@@ -179,6 +185,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_server_domains",
|
"name": "get_server_domains",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_server_domains",
|
"method_name": "get_server_domains",
|
||||||
"description": "Get all domains configured on a specific server.",
|
"description": "Get all domains configured on a specific server.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -196,6 +203,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "validate_server",
|
"name": "validate_server",
|
||||||
|
"category": "read",
|
||||||
"method_name": "validate_server",
|
"method_name": "validate_server",
|
||||||
"description": "Validate server connectivity and configuration.",
|
"description": "Validate server connectivity and configuration.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "list_services",
|
"name": "list_services",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_services",
|
"method_name": "list_services",
|
||||||
"description": "List all Coolify services.",
|
"description": "List all Coolify services.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_service",
|
"name": "get_service",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_service",
|
"method_name": "get_service",
|
||||||
"description": "Get details of a specific Coolify service by UUID.",
|
"description": "Get details of a specific Coolify service by UUID.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_service",
|
"name": "create_service",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_service",
|
"method_name": "create_service",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a Coolify service from a predefined template. "
|
"Create a Coolify service from a predefined template. "
|
||||||
@@ -94,6 +97,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_service",
|
"name": "update_service",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "update_service",
|
"method_name": "update_service",
|
||||||
"description": "Update a Coolify service settings.",
|
"description": "Update a Coolify service settings.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -123,6 +127,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_service",
|
"name": "delete_service",
|
||||||
|
"category": "system",
|
||||||
"method_name": "delete_service",
|
"method_name": "delete_service",
|
||||||
"description": (
|
"description": (
|
||||||
"Delete a Coolify service permanently. "
|
"Delete a Coolify service permanently. "
|
||||||
@@ -158,6 +163,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "start_service",
|
"name": "start_service",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "start_service",
|
"method_name": "start_service",
|
||||||
"description": "Start a Coolify service.",
|
"description": "Start a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -175,6 +181,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "stop_service",
|
"name": "stop_service",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "stop_service",
|
"method_name": "stop_service",
|
||||||
"description": "Stop a running Coolify service.",
|
"description": "Stop a running Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -192,6 +199,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "restart_service",
|
"name": "restart_service",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "restart_service",
|
"method_name": "restart_service",
|
||||||
"description": "Restart a Coolify service.",
|
"description": "Restart a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -209,6 +217,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "list_service_envs",
|
"name": "list_service_envs",
|
||||||
|
"category": "read_sensitive",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "list_service_envs",
|
"method_name": "list_service_envs",
|
||||||
"description": "List environment variables for a Coolify service.",
|
"description": "List environment variables for a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -226,6 +236,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_service_env",
|
"name": "create_service_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "create_service_env",
|
"method_name": "create_service_env",
|
||||||
"description": "Create an environment variable for a Coolify service.",
|
"description": "Create an environment variable for a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -257,6 +269,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_service_env",
|
"name": "update_service_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "update_service_env",
|
"method_name": "update_service_env",
|
||||||
"description": "Update an environment variable for a Coolify service.",
|
"description": "Update an environment variable for a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -287,6 +301,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_service_envs_bulk",
|
"name": "update_service_envs_bulk",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "update_service_envs_bulk",
|
"method_name": "update_service_envs_bulk",
|
||||||
"description": "Bulk update environment variables for a Coolify service.",
|
"description": "Bulk update environment variables for a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -317,6 +333,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_service_env",
|
"name": "delete_service_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "delete_service_env",
|
"method_name": "delete_service_env",
|
||||||
"description": "Delete an environment variable from a Coolify service.",
|
"description": "Delete an environment variable from a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcphub-server"
|
name = "mcphub-server"
|
||||||
version = "3.8.0"
|
version = "3.9.0"
|
||||||
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
|
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
||||||
|
|||||||
50
server.py
50
server.py
@@ -58,16 +58,22 @@ from core import (
|
|||||||
set_api_key_context,
|
set_api_key_context,
|
||||||
)
|
)
|
||||||
from core.dashboard.routes import (
|
from core.dashboard.routes import (
|
||||||
# E.3: Site Management routes
|
# F.7b: Per-site tool visibility
|
||||||
|
api_bulk_toggle_site_tools,
|
||||||
api_create_key,
|
api_create_key,
|
||||||
|
# E.3: Site Management routes
|
||||||
api_create_site,
|
api_create_site,
|
||||||
api_delete_key,
|
api_delete_key,
|
||||||
api_delete_site,
|
api_delete_site,
|
||||||
api_get_config,
|
api_get_config,
|
||||||
api_list_keys,
|
api_list_keys,
|
||||||
|
api_list_site_tools,
|
||||||
api_list_sites,
|
api_list_sites,
|
||||||
|
api_patch_site_tool,
|
||||||
# K.5: Settings routes
|
# K.5: Settings routes
|
||||||
api_save_setting,
|
api_save_setting,
|
||||||
|
api_scope_presets,
|
||||||
|
api_set_site_tool_scope,
|
||||||
api_test_site,
|
api_test_site,
|
||||||
api_update_site,
|
api_update_site,
|
||||||
# E.2: OAuth Social Login routes
|
# E.2: OAuth Social Login routes
|
||||||
@@ -80,7 +86,6 @@ from core.dashboard.routes import (
|
|||||||
dashboard_api_keys_create,
|
dashboard_api_keys_create,
|
||||||
dashboard_api_keys_delete,
|
dashboard_api_keys_delete,
|
||||||
# K.3: API Keys routes
|
# K.3: API Keys routes
|
||||||
dashboard_api_keys_list,
|
|
||||||
dashboard_api_keys_revoke,
|
dashboard_api_keys_revoke,
|
||||||
dashboard_api_project_detail,
|
dashboard_api_project_detail,
|
||||||
dashboard_api_projects,
|
dashboard_api_projects,
|
||||||
@@ -88,11 +93,11 @@ from core.dashboard.routes import (
|
|||||||
# K.4: Audit Logs routes
|
# K.4: Audit Logs routes
|
||||||
dashboard_audit_logs_list,
|
dashboard_audit_logs_list,
|
||||||
# E.3: Dashboard pages
|
# E.3: Dashboard pages
|
||||||
dashboard_connect_page,
|
|
||||||
# K.5: Health Monitoring routes
|
|
||||||
dashboard_health_page,
|
dashboard_health_page,
|
||||||
dashboard_health_projects_partial,
|
dashboard_health_projects_partial,
|
||||||
dashboard_home,
|
dashboard_home,
|
||||||
|
# F.7b session 2: Unified keys page
|
||||||
|
dashboard_keys_unified,
|
||||||
dashboard_login_page,
|
dashboard_login_page,
|
||||||
dashboard_login_submit,
|
dashboard_login_submit,
|
||||||
dashboard_logout,
|
dashboard_logout,
|
||||||
@@ -114,6 +119,7 @@ from core.dashboard.routes import (
|
|||||||
dashboard_sites_add,
|
dashboard_sites_add,
|
||||||
dashboard_sites_edit,
|
dashboard_sites_edit,
|
||||||
dashboard_sites_list,
|
dashboard_sites_list,
|
||||||
|
dashboard_sites_view,
|
||||||
# Bug C: User OAuth client routes
|
# Bug C: User OAuth client routes
|
||||||
dashboard_user_oauth_clients_create,
|
dashboard_user_oauth_clients_create,
|
||||||
dashboard_user_oauth_clients_delete,
|
dashboard_user_oauth_clients_delete,
|
||||||
@@ -4783,6 +4789,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
|
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
|
||||||
Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]),
|
Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]),
|
||||||
Route("/dashboard/sites/{id}/edit", dashboard_sites_edit, methods=["GET"]),
|
Route("/dashboard/sites/{id}/edit", dashboard_sites_edit, methods=["GET"]),
|
||||||
|
Route("/dashboard/sites/{id}", dashboard_sites_view, methods=["GET"]),
|
||||||
Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]),
|
Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]),
|
||||||
# Bug C: User OAuth client routes (must be before /dashboard/connect)
|
# Bug C: User OAuth client routes (must be before /dashboard/connect)
|
||||||
Route(
|
Route(
|
||||||
@@ -4790,7 +4797,12 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
dashboard_user_oauth_clients_list,
|
dashboard_user_oauth_clients_list,
|
||||||
methods=["GET"],
|
methods=["GET"],
|
||||||
),
|
),
|
||||||
Route("/dashboard/connect", dashboard_connect_page, methods=["GET"]),
|
# F.7b session 2: /dashboard/connect → /dashboard/keys (301)
|
||||||
|
Route(
|
||||||
|
"/dashboard/connect",
|
||||||
|
lambda r: RedirectResponse("/dashboard/keys", status_code=301),
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
# F.3: Service pages (must be before /dashboard catch-all)
|
# F.3: Service pages (must be before /dashboard catch-all)
|
||||||
Route("/dashboard/services", dashboard_services_list, methods=["GET"]),
|
Route("/dashboard/services", dashboard_services_list, methods=["GET"]),
|
||||||
Route("/dashboard/services/{plugin_type}", dashboard_service_page, methods=["GET"]),
|
Route("/dashboard/services/{plugin_type}", dashboard_service_page, methods=["GET"]),
|
||||||
@@ -4812,8 +4824,14 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
dashboard_api_project_detail,
|
dashboard_api_project_detail,
|
||||||
methods=["GET"],
|
methods=["GET"],
|
||||||
),
|
),
|
||||||
# Dashboard API Keys routes (Phase K.3)
|
# Dashboard API Keys routes (Phase K.3 + F.7b session 2: unified /dashboard/keys)
|
||||||
Route("/dashboard/api-keys", dashboard_api_keys_list, methods=["GET"]),
|
Route("/dashboard/keys", dashboard_keys_unified, methods=["GET"]),
|
||||||
|
# /dashboard/api-keys → /dashboard/keys (301)
|
||||||
|
Route(
|
||||||
|
"/dashboard/api-keys",
|
||||||
|
lambda r: RedirectResponse("/dashboard/keys", status_code=301),
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
Route("/api/dashboard/api-keys/create", dashboard_api_keys_create, methods=["POST"]),
|
Route("/api/dashboard/api-keys/create", dashboard_api_keys_create, methods=["POST"]),
|
||||||
Route(
|
Route(
|
||||||
"/api/dashboard/api-keys/{key_id}/revoke", dashboard_api_keys_revoke, methods=["POST"]
|
"/api/dashboard/api-keys/{key_id}/revoke", dashboard_api_keys_revoke, methods=["POST"]
|
||||||
@@ -4862,6 +4880,24 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
Route("/api/keys/{id}", api_delete_key, methods=["DELETE"]),
|
Route("/api/keys/{id}", api_delete_key, methods=["DELETE"]),
|
||||||
# Config snippet API (E.3)
|
# Config snippet API (E.3)
|
||||||
Route("/api/config/{alias}", api_get_config, methods=["GET"]),
|
Route("/api/config/{alias}", api_get_config, methods=["GET"]),
|
||||||
|
# F.7b: Per-site tool visibility management
|
||||||
|
Route("/api/sites/{site_id}/tools", api_list_site_tools, methods=["GET"]),
|
||||||
|
Route(
|
||||||
|
"/api/sites/{site_id}/tools/bulk-toggle",
|
||||||
|
api_bulk_toggle_site_tools,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/api/sites/{site_id}/tools/{tool_name}",
|
||||||
|
api_patch_site_tool,
|
||||||
|
methods=["PATCH"],
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/api/sites/{site_id}/tool-scope",
|
||||||
|
api_set_site_tool_scope,
|
||||||
|
methods=["PATCH"],
|
||||||
|
),
|
||||||
|
Route("/api/scope-presets", api_scope_presets, methods=["GET"]),
|
||||||
# OAuth endpoints
|
# OAuth endpoints
|
||||||
Route("/.well-known/oauth-authorization-server", oauth_metadata, methods=["GET"]),
|
Route("/.well-known/oauth-authorization-server", oauth_metadata, methods=["GET"]),
|
||||||
# Path-specific OAuth protected resource metadata (must come before root)
|
# Path-specific OAuth protected resource metadata (must come before root)
|
||||||
|
|||||||
@@ -374,37 +374,15 @@ class TestDashboardCookieManagement:
|
|||||||
|
|
||||||
|
|
||||||
def test_dashboard_connect_page(monkeypatch):
|
def test_dashboard_connect_page(monkeypatch):
|
||||||
"""Test that the /dashboard/connect page renders successfully without 500 errors."""
|
"""Test that /dashboard/connect redirects to /dashboard/keys (F.7b session 2)."""
|
||||||
from server import create_multi_endpoint_app
|
from server import create_multi_endpoint_app
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
import core.dashboard.routes
|
|
||||||
import core.site_api
|
|
||||||
import core.user_keys
|
|
||||||
|
|
||||||
app = create_multi_endpoint_app()
|
app = create_multi_endpoint_app()
|
||||||
client = TestClient(app)
|
client = TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
def mock_req(*args):
|
|
||||||
return {"user_id": "abc", "type": "user"}, None
|
|
||||||
|
|
||||||
monkeypatch.setattr(core.dashboard.routes, "_require_user_session", mock_req)
|
|
||||||
|
|
||||||
async def mock_sites(*args):
|
|
||||||
return [{"alias": "Test", "plugin_type": "dummy"}]
|
|
||||||
|
|
||||||
monkeypatch.setattr(core.site_api, "get_user_sites", mock_sites)
|
|
||||||
|
|
||||||
class MockKeyMgr:
|
|
||||||
async def list_keys(self, *a):
|
|
||||||
return [
|
|
||||||
{"id": "1", "name": "Key", "key_prefix": "prefix", "scopes": "all", "use_count": 0}
|
|
||||||
]
|
|
||||||
|
|
||||||
monkeypatch.setattr(core.user_keys, "get_user_key_manager", lambda: MockKeyMgr())
|
|
||||||
|
|
||||||
resp = client.get("/dashboard/connect")
|
resp = client.get("/dashboard/connect")
|
||||||
|
|
||||||
assert resp.status_code == 200
|
# /dashboard/connect now redirects 301 to /dashboard/keys
|
||||||
assert "Test" in resp.text
|
assert resp.status_code == 301
|
||||||
assert "Key" in resp.text
|
assert "/dashboard/keys" in resp.headers["location"]
|
||||||
|
|||||||
81
tests/test_dashboard_keys_unified.py
Normal file
81
tests/test_dashboard_keys_unified.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Tests for the unified /dashboard/keys page (F.7b session 2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import core.dashboard.routes as routes_module
|
||||||
|
import core.database as db_module
|
||||||
|
from core.database import Database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def patched_db(tmp_path, monkeypatch):
|
||||||
|
database = Database(str(tmp_path / "keys.db"))
|
||||||
|
await database.initialize()
|
||||||
|
monkeypatch.setattr(db_module, "_database", database)
|
||||||
|
yield database
|
||||||
|
await database.close()
|
||||||
|
monkeypatch.setattr(db_module, "_database", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_row(patched_db):
|
||||||
|
return await patched_db.create_user(
|
||||||
|
email="keys@example.com",
|
||||||
|
name="keysuser",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-keys-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_client(monkeypatch, user_row, patched_db):
|
||||||
|
from server import create_multi_endpoint_app
|
||||||
|
|
||||||
|
def fake_user_session(_request):
|
||||||
|
return {"user_id": user_row["id"], "type": "user"}, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes_module, "_require_user_session", fake_user_session)
|
||||||
|
|
||||||
|
# Also patch auth so dashboard_keys_unified finds the user session
|
||||||
|
class FakeAuth:
|
||||||
|
def get_session_from_request(self, _r):
|
||||||
|
return None # not admin
|
||||||
|
|
||||||
|
def get_user_session_from_request(self, _r):
|
||||||
|
return {"user_id": user_row["id"], "type": "user"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes_module, "get_dashboard_auth", lambda: FakeAuth())
|
||||||
|
|
||||||
|
app = create_multi_endpoint_app()
|
||||||
|
return TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnifiedKeysUserView:
|
||||||
|
def test_get_keys_page_returns_200(self, user_client):
|
||||||
|
r = user_client.get("/dashboard/keys")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "API Key" in r.text or "کلید" in r.text
|
||||||
|
|
||||||
|
def test_old_connect_redirects_301(self, user_client):
|
||||||
|
r = user_client.get("/dashboard/connect")
|
||||||
|
assert r.status_code == 301
|
||||||
|
assert "/dashboard/keys" in r.headers["location"]
|
||||||
|
|
||||||
|
def test_old_api_keys_redirects_301(self, user_client):
|
||||||
|
r = user_client.get("/dashboard/api-keys")
|
||||||
|
assert r.status_code == 301
|
||||||
|
assert "/dashboard/keys" in r.headers["location"]
|
||||||
|
|
||||||
|
def test_user_view_has_scope_selector(self, user_client):
|
||||||
|
r = user_client.get("/dashboard/keys")
|
||||||
|
assert r.status_code == 200
|
||||||
|
# Scope dropdown options should be present
|
||||||
|
assert "read:sensitive" in r.text or "Read" in r.text
|
||||||
|
|
||||||
|
def test_user_view_shows_create_button(self, user_client):
|
||||||
|
r = user_client.get("/dashboard/keys")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Create" in r.text or "ایجاد" in r.text
|
||||||
@@ -538,6 +538,72 @@ class TestEmptyResults:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteToolToggles:
|
||||||
|
"""F.7b: per-site tool toggle and tool_scope helpers."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_empty_toggles_by_default(self, db, site_row):
|
||||||
|
assert await db.get_site_tool_toggles(site_row["id"]) == {}
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_set_and_get_toggle(self, db, site_row):
|
||||||
|
await db.set_site_tool_toggle(
|
||||||
|
site_row["id"], "coolify_list_applications", False, reason="not needed"
|
||||||
|
)
|
||||||
|
toggles = await db.get_site_tool_toggles(site_row["id"])
|
||||||
|
assert toggles == {"coolify_list_applications": False}
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_toggle_is_upsert(self, db, site_row):
|
||||||
|
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
|
||||||
|
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", True)
|
||||||
|
toggles = await db.get_site_tool_toggles(site_row["id"])
|
||||||
|
assert toggles == {"coolify_get_server": True}
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_toggle(self, db, site_row):
|
||||||
|
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
|
||||||
|
removed = await db.delete_site_tool_toggle(site_row["id"], "coolify_get_server")
|
||||||
|
assert removed is True
|
||||||
|
assert await db.get_site_tool_toggles(site_row["id"]) == {}
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_bulk_set(self, db, site_row):
|
||||||
|
n = await db.bulk_set_site_tool_toggles(
|
||||||
|
site_row["id"],
|
||||||
|
[("coolify_list_applications", False), ("coolify_start_application", False)],
|
||||||
|
reason="bulk:deploy",
|
||||||
|
)
|
||||||
|
assert n == 2
|
||||||
|
toggles = await db.get_site_tool_toggles(site_row["id"])
|
||||||
|
assert toggles == {
|
||||||
|
"coolify_list_applications": False,
|
||||||
|
"coolify_start_application": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_toggle_cascades_on_site_delete(self, db, user_row, site_row):
|
||||||
|
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
|
||||||
|
await db.delete_site(site_row["id"], user_row["id"])
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM site_tool_toggles WHERE site_id = ?", (site_row["id"],)
|
||||||
|
)
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_default_tool_scope_is_admin(self, db, site_row):
|
||||||
|
assert await db.get_site_tool_scope(site_row["id"]) == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_set_tool_scope(self, db, site_row):
|
||||||
|
await db.set_site_tool_scope(site_row["id"], "read")
|
||||||
|
assert await db.get_site_tool_scope(site_row["id"]) == "read"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_unknown_site_tool_scope_defaults_admin(self, db):
|
||||||
|
assert await db.get_site_tool_scope("does-not-exist") == "admin"
|
||||||
|
|
||||||
|
|
||||||
class TestModuleHelpers:
|
class TestModuleHelpers:
|
||||||
"""Test get_database() and initialize_database() helpers."""
|
"""Test get_database() and initialize_database() helpers."""
|
||||||
|
|
||||||
|
|||||||
264
tests/test_site_tools_api.py
Normal file
264
tests/test_site_tools_api.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"""Integration tests for the per-site tool visibility API (F.7b).
|
||||||
|
|
||||||
|
Exercises the five routes registered in server.py:
|
||||||
|
|
||||||
|
* ``GET /api/sites/{site_id}/tools``
|
||||||
|
* ``PATCH /api/sites/{site_id}/tools/{tool_name}``
|
||||||
|
* ``POST /api/sites/{site_id}/tools/bulk-toggle``
|
||||||
|
* ``PATCH /api/sites/{site_id}/tool-scope``
|
||||||
|
* ``GET /api/scope-presets``
|
||||||
|
|
||||||
|
Uses the real ``create_multi_endpoint_app()`` so routes wire to the actual
|
||||||
|
Coolify tool registry. User session auth is stubbed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import core.dashboard.routes as routes_module
|
||||||
|
import core.database as db_module
|
||||||
|
import core.tool_access as tool_access_module
|
||||||
|
from core.database import Database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def patched_db(tmp_path, monkeypatch):
|
||||||
|
database = Database(str(tmp_path / "api.db"))
|
||||||
|
await database.initialize()
|
||||||
|
monkeypatch.setattr(db_module, "_database", database)
|
||||||
|
yield database
|
||||||
|
await database.close()
|
||||||
|
monkeypatch.setattr(db_module, "_database", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_access(monkeypatch):
|
||||||
|
monkeypatch.setattr(tool_access_module, "_manager", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_row(patched_db):
|
||||||
|
return await patched_db.create_user(
|
||||||
|
email="api@example.com",
|
||||||
|
name="api",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-api-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def coolify_site(patched_db, user_row):
|
||||||
|
return await patched_db.create_site(
|
||||||
|
user_id=user_row["id"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
alias="prod",
|
||||||
|
url="https://coolify.example.com",
|
||||||
|
credentials=b"x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def other_user_site(patched_db):
|
||||||
|
"""A site belonging to a different user — for ownership-check tests."""
|
||||||
|
other = await patched_db.create_user(
|
||||||
|
email="other@example.com",
|
||||||
|
name="other",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-other",
|
||||||
|
)
|
||||||
|
return await patched_db.create_site(
|
||||||
|
user_id=other["id"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
alias="theirs",
|
||||||
|
url="https://other.example.com",
|
||||||
|
credentials=b"x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch, user_row, patched_db, patched_access):
|
||||||
|
"""Build the Starlette app and patch user session auth.
|
||||||
|
|
||||||
|
Also pre-sets a matching CSRF cookie + default X-CSRF-Token header so that
|
||||||
|
mutating requests to ``/api/sites/*`` bypass the Double-Submit CSRF guard
|
||||||
|
in ``DashboardCSRFMiddleware``.
|
||||||
|
"""
|
||||||
|
from server import create_multi_endpoint_app
|
||||||
|
|
||||||
|
def fake_require_user_session(_request):
|
||||||
|
return {"user_id": user_row["id"], "type": "user"}, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes_module, "_require_user_session", fake_require_user_session)
|
||||||
|
|
||||||
|
app = create_multi_endpoint_app()
|
||||||
|
tc = TestClient(app)
|
||||||
|
tc.cookies.set("dashboard_csrf", "test-csrf-token")
|
||||||
|
tc.headers.update({"x-csrf-token": "test-csrf-token"})
|
||||||
|
return tc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/sites/{site_id}/tools
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestListSiteTools:
|
||||||
|
def test_returns_plugin_tools_all_enabled(self, client, coolify_site):
|
||||||
|
resp = client.get(f"/api/sites/{coolify_site['id']}/tools")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["site_id"] == coolify_site["id"]
|
||||||
|
assert body["plugin_type"] == "coolify"
|
||||||
|
assert body["tool_scope"] == "admin"
|
||||||
|
tools = body["tools"]
|
||||||
|
by_name = {t["name"]: t for t in tools}
|
||||||
|
assert "coolify_list_applications" in by_name
|
||||||
|
assert "coolify_delete_server" in by_name
|
||||||
|
assert all(t["enabled"] for t in tools)
|
||||||
|
|
||||||
|
def test_carries_category_and_sensitivity(self, client, coolify_site):
|
||||||
|
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
|
||||||
|
by_name = {t["name"]: t for t in tools}
|
||||||
|
assert by_name["coolify_list_applications"]["category"] == "read"
|
||||||
|
assert by_name["coolify_delete_server"]["category"] == "system"
|
||||||
|
assert by_name["coolify_get_application_logs"]["sensitivity"] == "sensitive"
|
||||||
|
|
||||||
|
def test_only_own_sites_visible(self, client, other_user_site):
|
||||||
|
"""A site owned by a different user must 404."""
|
||||||
|
resp = client.get(f"/api/sites/{other_user_site['id']}/tools")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /api/sites/{site_id}/tools/{tool_name}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPatchSiteTool:
|
||||||
|
def test_disable_reflected_in_list(self, client, coolify_site):
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications",
|
||||||
|
json={"enabled": False, "reason": "not needed"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["enabled"] is False
|
||||||
|
|
||||||
|
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
|
||||||
|
by_name = {t["name"]: t["enabled"] for t in tools}
|
||||||
|
assert by_name["coolify_list_applications"] is False
|
||||||
|
assert by_name["coolify_start_application"] is True
|
||||||
|
|
||||||
|
def test_reenable_round_trip(self, client, coolify_site):
|
||||||
|
base = f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications"
|
||||||
|
client.patch(base, json={"enabled": False})
|
||||||
|
client.patch(base, json={"enabled": True})
|
||||||
|
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
|
||||||
|
by_name = {t["name"]: t["enabled"] for t in tools}
|
||||||
|
assert by_name["coolify_list_applications"] is True
|
||||||
|
|
||||||
|
def test_unknown_tool_404(self, client, coolify_site):
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/coolify_nonsense",
|
||||||
|
json={"enabled": False},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
def test_wrong_plugin_400(self, client, coolify_site):
|
||||||
|
"""Tool from another plugin should be rejected."""
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/wordpress_list_posts",
|
||||||
|
json={"enabled": False},
|
||||||
|
)
|
||||||
|
assert r.status_code in (400, 404)
|
||||||
|
|
||||||
|
def test_missing_enabled_400(self, client, coolify_site):
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/sites/{site_id}/tools/bulk-toggle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulkToggle:
|
||||||
|
def test_bulk_disable_deploy_scope(self, client, coolify_site):
|
||||||
|
r = client.post(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
|
||||||
|
json={"scope": "deploy", "enabled": False},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["affected"] >= 5
|
||||||
|
|
||||||
|
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
|
||||||
|
by_name = {t["name"]: t["enabled"] for t in tools}
|
||||||
|
assert by_name["coolify_list_applications"] is False
|
||||||
|
assert by_name["coolify_start_application"] is False
|
||||||
|
assert by_name["coolify_create_application_public"] is True
|
||||||
|
assert by_name["coolify_delete_server"] is True
|
||||||
|
|
||||||
|
def test_unknown_scope_400(self, client, coolify_site):
|
||||||
|
r = client.post(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
|
||||||
|
json={"scope": "bogus", "enabled": False},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_bad_body_400(self, client, coolify_site):
|
||||||
|
r = client.post(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
|
||||||
|
json={"scope": "read"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /api/sites/{site_id}/tool-scope
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetSiteToolScope:
|
||||||
|
def test_set_read_scope(self, client, coolify_site):
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tool-scope",
|
||||||
|
json={"scope": "read"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["tool_scope"] == "read"
|
||||||
|
listing = client.get(f"/api/sites/{coolify_site['id']}/tools").json()
|
||||||
|
assert listing["tool_scope"] == "read"
|
||||||
|
|
||||||
|
def test_invalid_scope_400(self, client, coolify_site):
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tool-scope",
|
||||||
|
json={"scope": "superadmin"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_accepts_all_known_presets(self, client, coolify_site):
|
||||||
|
for scope in ("read", "read:sensitive", "deploy", "write", "admin", "custom"):
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tool-scope",
|
||||||
|
json={"scope": scope},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, scope
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/scope-presets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestScopePresets:
|
||||||
|
def test_returns_all_scopes(self, client):
|
||||||
|
body = client.get("/api/scope-presets").json()
|
||||||
|
assert "presets" in body
|
||||||
|
presets = body["presets"]
|
||||||
|
assert set(presets.keys()) == {"read", "read:sensitive", "deploy", "write", "admin"}
|
||||||
|
assert "read" in presets["read"]
|
||||||
|
assert "system" in presets["admin"]
|
||||||
110
tests/test_sites_tool_access_ui.py
Normal file
110
tests/test_sites_tool_access_ui.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Smoke tests for sites edit page Tool Access section and sites view page (F.7b session 2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import core.dashboard.routes as routes_module
|
||||||
|
import core.database as db_module
|
||||||
|
import core.tool_access as tool_access_module
|
||||||
|
from core.database import Database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def patched_db(tmp_path, monkeypatch):
|
||||||
|
database = Database(str(tmp_path / "ui.db"))
|
||||||
|
await database.initialize()
|
||||||
|
monkeypatch.setattr(db_module, "_database", database)
|
||||||
|
yield database
|
||||||
|
await database.close()
|
||||||
|
monkeypatch.setattr(db_module, "_database", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_access(monkeypatch):
|
||||||
|
monkeypatch.setattr(tool_access_module, "_manager", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_row(patched_db):
|
||||||
|
return await patched_db.create_user(
|
||||||
|
email="ui@example.com",
|
||||||
|
name="uitester",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-ui-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def coolify_site(patched_db, user_row):
|
||||||
|
return await patched_db.create_site(
|
||||||
|
user_id=user_row["id"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
alias="ui-prod",
|
||||||
|
url="https://coolify.example.com",
|
||||||
|
credentials=b"x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch, user_row, patched_db, patched_access):
|
||||||
|
from server import create_multi_endpoint_app
|
||||||
|
|
||||||
|
def fake_user_session(_request):
|
||||||
|
return {"user_id": user_row["id"], "type": "user"}, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes_module, "_require_user_session", fake_user_session)
|
||||||
|
|
||||||
|
# Patch auth for dashboard_keys_unified
|
||||||
|
class FakeAuth:
|
||||||
|
def get_session_from_request(self, _r):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user_session_from_request(self, _r):
|
||||||
|
return {"user_id": user_row["id"], "type": "user"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes_module, "get_dashboard_auth", lambda: FakeAuth())
|
||||||
|
|
||||||
|
app = create_multi_endpoint_app()
|
||||||
|
tc = TestClient(app, follow_redirects=False)
|
||||||
|
tc.cookies.set("dashboard_csrf", "test-csrf")
|
||||||
|
tc.headers.update({"x-csrf-token": "test-csrf"})
|
||||||
|
return tc
|
||||||
|
|
||||||
|
|
||||||
|
class TestSitesEditToolAccess:
|
||||||
|
def test_edit_page_renders_without_500(self, client, coolify_site):
|
||||||
|
r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_edit_page_contains_tool_access_card(self, client, coolify_site):
|
||||||
|
r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "tool-access-card" in r.text or "Tool Access" in r.text
|
||||||
|
|
||||||
|
def test_edit_page_has_scope_select(self, client, coolify_site):
|
||||||
|
r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "tool-scope-select" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestSitesViewPage:
|
||||||
|
def test_view_page_renders_without_500(self, client, coolify_site):
|
||||||
|
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_view_page_shows_mcp_url(self, client, coolify_site):
|
||||||
|
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "mcp-url" in r.text
|
||||||
|
assert coolify_site["alias"] in r.text
|
||||||
|
|
||||||
|
def test_view_page_has_client_selector(self, client, coolify_site):
|
||||||
|
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "config-client" in r.text
|
||||||
|
|
||||||
|
def test_nonexistent_site_redirects(self, client):
|
||||||
|
r = client.get("/dashboard/sites/nonexistent-id")
|
||||||
|
assert r.status_code in (302, 303)
|
||||||
364
tests/test_tool_access.py
Normal file
364
tests/test_tool_access.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
"""Tests for site-scoped tool visibility & per-site toggles (F.7b).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
* ``ToolDefinition`` backward-compatible defaults
|
||||||
|
* ``ToolAccessManager.apply_scope_filter`` for each scope
|
||||||
|
* Per-site toggle filtering
|
||||||
|
* Site-level ``tool_scope`` preset as a second restrictive layer
|
||||||
|
* ``bulk_toggle_by_scope`` scoped to a single plugin
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator, Generator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import core.database as db_module
|
||||||
|
import core.tool_access as tool_access_module
|
||||||
|
import core.tool_registry as tool_registry_module
|
||||||
|
from core.database import Database
|
||||||
|
from core.tool_access import (
|
||||||
|
SCOPE_TO_CATEGORIES,
|
||||||
|
ToolAccessManager,
|
||||||
|
scopes_to_categories,
|
||||||
|
)
|
||||||
|
from core.tool_registry import ToolDefinition, ToolRegistry
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _noop_handler(**_kwargs):
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tool(name: str, category: str = "read", plugin_type: str = "coolify") -> ToolDefinition:
|
||||||
|
return ToolDefinition(
|
||||||
|
name=name,
|
||||||
|
description=f"desc {name}",
|
||||||
|
input_schema={"type": "object", "properties": {}},
|
||||||
|
handler=_noop_handler,
|
||||||
|
required_scope="read",
|
||||||
|
plugin_type=plugin_type,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_SAMPLE_TOOLS: list[ToolDefinition] = [
|
||||||
|
_make_tool("coolify_list_applications", "read"),
|
||||||
|
_make_tool("coolify_get_application_logs", "read_sensitive"),
|
||||||
|
_make_tool("coolify_start_application", "lifecycle"),
|
||||||
|
_make_tool("coolify_stop_application", "lifecycle"),
|
||||||
|
_make_tool("coolify_create_application_public", "crud"),
|
||||||
|
_make_tool("coolify_create_application_env", "env"),
|
||||||
|
_make_tool("coolify_get_database_backups", "backup"),
|
||||||
|
_make_tool("coolify_delete_server", "system"),
|
||||||
|
# Legacy plugin tool without a category annotation (defaults to "read").
|
||||||
|
_make_tool("wordpress_list_posts", "read", plugin_type="wordpress"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_registry(monkeypatch) -> Generator[ToolRegistry, None, None]:
|
||||||
|
"""Install a clean ToolRegistry populated with _SAMPLE_TOOLS."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
for tool in _SAMPLE_TOOLS:
|
||||||
|
registry.register(tool)
|
||||||
|
monkeypatch.setattr(tool_registry_module, "_tool_registry", registry)
|
||||||
|
yield registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db(tmp_path, monkeypatch) -> AsyncGenerator[Database, None]:
|
||||||
|
path = str(tmp_path / "toolacc.db")
|
||||||
|
database = Database(path)
|
||||||
|
await database.initialize()
|
||||||
|
monkeypatch.setattr(db_module, "_database", database)
|
||||||
|
yield database
|
||||||
|
await database.close()
|
||||||
|
monkeypatch.setattr(db_module, "_database", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def access_mgr(monkeypatch) -> ToolAccessManager:
|
||||||
|
monkeypatch.setattr(tool_access_module, "_manager", None)
|
||||||
|
return ToolAccessManager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def coolify_site(db):
|
||||||
|
user = await db.create_user(
|
||||||
|
email="toolacc@example.com",
|
||||||
|
name="ToolAcc",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-toolacc-1",
|
||||||
|
)
|
||||||
|
return await db.create_site(
|
||||||
|
user_id=user["id"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
alias="prod",
|
||||||
|
url="https://coolify.example.com",
|
||||||
|
credentials=b"x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def wordpress_site(db):
|
||||||
|
user = await db.create_user(
|
||||||
|
email="wp@example.com",
|
||||||
|
name="wp",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-wp-1",
|
||||||
|
)
|
||||||
|
return await db.create_site(
|
||||||
|
user_id=user["id"],
|
||||||
|
plugin_type="wordpress",
|
||||||
|
alias="blog",
|
||||||
|
url="https://blog.example.com",
|
||||||
|
credentials=b"x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ToolDefinition defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolDefinitionDefaults:
|
||||||
|
def test_default_category_and_sensitivity(self):
|
||||||
|
t = ToolDefinition(
|
||||||
|
name="legacy_tool",
|
||||||
|
description="legacy",
|
||||||
|
handler=_noop_handler,
|
||||||
|
plugin_type="wordpress",
|
||||||
|
)
|
||||||
|
assert t.category == "read"
|
||||||
|
assert t.sensitivity == "normal"
|
||||||
|
|
||||||
|
def test_explicit_category(self):
|
||||||
|
t = ToolDefinition(
|
||||||
|
name="new_tool",
|
||||||
|
description="x",
|
||||||
|
handler=_noop_handler,
|
||||||
|
plugin_type="coolify",
|
||||||
|
category="crud",
|
||||||
|
sensitivity="sensitive",
|
||||||
|
)
|
||||||
|
assert t.category == "crud"
|
||||||
|
assert t.sensitivity == "sensitive"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scope → category mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestScopesToCategories:
|
||||||
|
def test_read_only(self):
|
||||||
|
assert scopes_to_categories(["read"]) == {"read"}
|
||||||
|
|
||||||
|
def test_write_is_superset_of_read(self):
|
||||||
|
cats = scopes_to_categories(["write"])
|
||||||
|
assert "read" in cats and "crud" in cats and "lifecycle" in cats
|
||||||
|
assert "system" not in cats
|
||||||
|
|
||||||
|
def test_admin_includes_everything(self):
|
||||||
|
assert scopes_to_categories(["admin"]) == SCOPE_TO_CATEGORIES["admin"]
|
||||||
|
|
||||||
|
def test_additive_scopes(self):
|
||||||
|
cats = scopes_to_categories(["read", "deploy"])
|
||||||
|
assert cats == {"read", "lifecycle"}
|
||||||
|
|
||||||
|
def test_unknown_scope_ignored(self):
|
||||||
|
assert scopes_to_categories(["bogus"]) == set()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# apply_scope_filter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestScopeFilter:
|
||||||
|
def test_read_scope_drops_lifecycle_crud_system(self, access_mgr):
|
||||||
|
out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["read"])}
|
||||||
|
assert "coolify_list_applications" in out
|
||||||
|
assert "wordpress_list_posts" in out # legacy default category
|
||||||
|
assert "coolify_start_application" not in out
|
||||||
|
assert "coolify_create_application_public" not in out
|
||||||
|
assert "coolify_delete_server" not in out
|
||||||
|
assert "coolify_get_application_logs" not in out
|
||||||
|
|
||||||
|
def test_read_sensitive_includes_logs_and_backups(self, access_mgr):
|
||||||
|
out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["read:sensitive"])}
|
||||||
|
assert "coolify_get_application_logs" in out
|
||||||
|
assert "coolify_get_database_backups" in out
|
||||||
|
assert "coolify_start_application" not in out
|
||||||
|
assert "coolify_create_application_public" not in out
|
||||||
|
|
||||||
|
def test_deploy_scope_includes_lifecycle_only(self, access_mgr):
|
||||||
|
out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["deploy"])}
|
||||||
|
assert "coolify_start_application" in out
|
||||||
|
assert "coolify_stop_application" in out
|
||||||
|
assert "coolify_list_applications" in out
|
||||||
|
assert "coolify_create_application_public" not in out
|
||||||
|
assert "coolify_delete_server" not in out
|
||||||
|
|
||||||
|
def test_write_scope_excludes_system(self, access_mgr):
|
||||||
|
out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["write"])}
|
||||||
|
assert "coolify_create_application_public" in out
|
||||||
|
assert "coolify_start_application" in out
|
||||||
|
assert "coolify_create_application_env" in out
|
||||||
|
assert "coolify_delete_server" not in out
|
||||||
|
assert "coolify_get_application_logs" not in out
|
||||||
|
|
||||||
|
def test_admin_keeps_everything(self, access_mgr):
|
||||||
|
out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["admin"])}
|
||||||
|
assert out == {t.name for t in _SAMPLE_TOOLS}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-site toggles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteToggles:
|
||||||
|
async def test_disable_hides_tool(self, db, coolify_site, access_mgr, fresh_registry):
|
||||||
|
await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
names = {t.name for t in tools}
|
||||||
|
assert "coolify_list_applications" not in names
|
||||||
|
assert "coolify_start_application" in names
|
||||||
|
|
||||||
|
async def test_toggles_are_per_site(
|
||||||
|
self, db, coolify_site, wordpress_site, access_mgr, fresh_registry
|
||||||
|
):
|
||||||
|
# Disabling a tool on one site must not affect another site.
|
||||||
|
await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
|
||||||
|
# Second coolify site inherits nothing.
|
||||||
|
other_site = await db.create_site(
|
||||||
|
user_id=coolify_site["user_id"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
alias="staging",
|
||||||
|
url="https://staging.example.com",
|
||||||
|
credentials=b"x",
|
||||||
|
)
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=other_site["id"], key_scopes=["admin"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
assert "coolify_list_applications" in {t.name for t in tools}
|
||||||
|
|
||||||
|
async def test_toggle_independent_of_scope(self, db, coolify_site, access_mgr, fresh_registry):
|
||||||
|
await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["read"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
assert "coolify_list_applications" not in {t.name for t in tools}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Site-level tool_scope preset
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteToolScope:
|
||||||
|
async def test_default_admin_shows_all(self, db, coolify_site, access_mgr, fresh_registry):
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
assert {t.name for t in tools} == {
|
||||||
|
t.name for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_read_preset_restricts_even_admin_key(
|
||||||
|
self, db, coolify_site, access_mgr, fresh_registry
|
||||||
|
):
|
||||||
|
"""Site scope is restrictive — admin key but site=read → only read tools."""
|
||||||
|
await db.set_site_tool_scope(coolify_site["id"], "read")
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
assert {t.name for t in tools} == {"coolify_list_applications"}
|
||||||
|
|
||||||
|
async def test_key_scope_and_site_scope_intersect(
|
||||||
|
self, db, coolify_site, access_mgr, fresh_registry
|
||||||
|
):
|
||||||
|
"""Key=write + site=deploy → intersection = lifecycle + read."""
|
||||||
|
await db.set_site_tool_scope(coolify_site["id"], "deploy")
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["write"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
names = {t.name for t in tools}
|
||||||
|
assert "coolify_list_applications" in names # read
|
||||||
|
assert "coolify_start_application" in names # lifecycle
|
||||||
|
assert "coolify_stop_application" in names # lifecycle
|
||||||
|
assert "coolify_create_application_public" not in names # crud (not in deploy)
|
||||||
|
assert "coolify_create_application_env" not in names # env (not in deploy)
|
||||||
|
|
||||||
|
async def test_custom_preset_skips_site_filter(
|
||||||
|
self, db, coolify_site, access_mgr, fresh_registry
|
||||||
|
):
|
||||||
|
"""tool_scope='custom' means per-tool toggles only — no category gate."""
|
||||||
|
await db.set_site_tool_scope(coolify_site["id"], "custom")
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
# All coolify tools visible because no site-scope filter applied.
|
||||||
|
assert "coolify_delete_server" in {t.name for t in tools}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bulk toggle (scoped to a plugin)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulkToggle:
|
||||||
|
async def test_bulk_disable_by_scope_affects_only_plugin(
|
||||||
|
self, db, coolify_site, access_mgr, fresh_registry
|
||||||
|
):
|
||||||
|
n = await access_mgr.bulk_toggle_by_scope(
|
||||||
|
coolify_site["id"], "deploy", enabled=False, plugin_type="coolify"
|
||||||
|
)
|
||||||
|
# deploy → read + lifecycle. In sample: list + 2 lifecycle = 3
|
||||||
|
assert n == 3
|
||||||
|
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
names = {t.name for t in tools}
|
||||||
|
assert "coolify_start_application" not in names
|
||||||
|
assert "coolify_stop_application" not in names
|
||||||
|
assert "coolify_list_applications" not in names
|
||||||
|
assert "coolify_create_application_public" in names
|
||||||
|
assert "coolify_delete_server" in names
|
||||||
|
|
||||||
|
async def test_unknown_scope_raises(self, db, coolify_site, access_mgr, fresh_registry):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await access_mgr.bulk_toggle_by_scope(
|
||||||
|
coolify_site["id"], "does_not_exist", enabled=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# list_tools_for_site end-to-end
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestListToolsForSite:
|
||||||
|
async def test_returns_plugin_tools_with_enabled_flag(
|
||||||
|
self, db, coolify_site, access_mgr, fresh_registry
|
||||||
|
):
|
||||||
|
tools = await access_mgr.list_tools_for_site(coolify_site["id"], "coolify")
|
||||||
|
by_name = {t["name"]: t for t in tools}
|
||||||
|
assert "coolify_list_applications" in by_name
|
||||||
|
assert by_name["coolify_list_applications"]["enabled"] is True
|
||||||
|
assert by_name["coolify_delete_server"]["category"] == "system"
|
||||||
|
|
||||||
|
async def test_respects_toggles(self, db, coolify_site, access_mgr, fresh_registry):
|
||||||
|
await access_mgr.toggle_tool(coolify_site["id"], "coolify_delete_server", enabled=False)
|
||||||
|
tools = await access_mgr.list_tools_for_site(coolify_site["id"], "coolify")
|
||||||
|
by_name = {t["name"]: t for t in tools}
|
||||||
|
assert by_name["coolify_delete_server"]["enabled"] is False
|
||||||
@@ -85,12 +85,12 @@ def _clear_rate_limits():
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _clear_tool_cache():
|
def _clear_tool_cache():
|
||||||
"""Clear the tool schema cache between tests."""
|
"""No-op: the per-plugin tool schema cache was removed in F.7.
|
||||||
import core.user_endpoints as mod
|
|
||||||
|
|
||||||
mod._tool_schema_cache.clear()
|
Retained so the rest of the test fixtures stay unchanged; the scope-filter
|
||||||
|
pipeline runs on every ``tools/list`` call so there is nothing to clear.
|
||||||
|
"""
|
||||||
yield
|
yield
|
||||||
mod._tool_schema_cache.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
Reference in New Issue
Block a user