feat(F.7+F.17): v3.11.0 — Coolify plugin (67 tools) + tool access overhaul
Catch-up sync spanning v3.7.0 → v3.11.0 of the internal repo. Platform - Total tools: 565 → 633 (+68) across 10 plugins (Coolify added) - Tests: 481 → 828 passing New plugin: Coolify (67 tools, Track F.17) - Applications (17): CRUD, lifecycle, logs, env vars - Deployments (5): list/get/cancel/deploy, app history - Servers (8): CRUD, resources, domains, validation - Projects (8), Databases (16, 6 DB types + backups), Services (13) Tool access system (Track F.7 → F.7d) - Scope → category mapping with per-tool `category` + `sensitivity` - Schema v7: `site_tool_toggles(site_id)` + `sites.tool_scope` column - Schema v8: per-site API keys (`api_keys.site_id`) - Plugin-specific access-level presets (WP / WC / Gitea / OpenPanel / Coolify 5-tier) - Credential-requirement notice tailored per plugin and tier - Admin Tools count card on service page - Dropped redundant `write` tier on WP / WP Advanced / WooCommerce (admin-scope tool count = 0 → identical to admin tier) Dashboard - Unified site manage page (Connection / Tool Access / Connect) - /dashboard/keys unified (was /api-keys and /connect) - CSRF interceptor via meta-tag; removed conflicting cookie reader - Tailwind: pre-built CSS (scripts/build-css.sh) replaces CDN Docs - README / DOCKER_README / CLAUDE updated to 633 tools / 10 plugins - CHANGELOG entries for v3.7.0 → v3.11.0 - FastMCP compatibility note updated to 3.x (post-v3.5 upgrade) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -165,3 +165,8 @@ pytest-cache-files-*/
|
||||
worktrees/
|
||||
/.agents
|
||||
uv.lock
|
||||
|
||||
# planning-with-files skill artifacts (session-local)
|
||||
task_plan.md
|
||||
findings.md
|
||||
progress.md
|
||||
|
||||
94
CHANGELOG.md
94
CHANGELOG.md
@@ -5,6 +5,100 @@ All notable changes to MCP Hub will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.11.0] — 2026-04-14
|
||||
|
||||
### Plugin-specific access levels, regression tests, UX polish (Track F.7d)
|
||||
|
||||
Polish pass on top of v3.10.0 tool-access hardening. Access levels and credential guidance are now tailored per plugin; UX signals have been tightened.
|
||||
|
||||
#### Added
|
||||
- **Plugin-aware scope presets** (`core/tool_access.py::get_scope_presets_for_plugin`): WordPress / WordPress Advanced / WooCommerce / Gitea / Coolify (5 tiers) / OpenPanel — each with tailored EN+FA labels and one-line hints. Custom preset always available.
|
||||
- **Admin Tools card** on per-service dashboard page (4-col grid when `admin` tools > 0).
|
||||
- **Credential requirement notice** on Tool Access: describes exactly what the saved Credential must grant, per plugin/tier (WP Application Password, WooCommerce REST key scope, Gitea scoped tokens, OpenPanel client mode, Coolify API token permissions).
|
||||
- **Auto-scoped quick key** on the Connect section (site_id injected → key capped to this site's tier).
|
||||
|
||||
#### Changed
|
||||
- Scope buttons rendered dynamically from `scope_presets_json` (was: hardcoded 4 tiers).
|
||||
- WordPress / WP Advanced / WooCommerce: dropped redundant `write` tier (admin-scope tool count = 0) — now **Read Only + Full Access + Custom**.
|
||||
- Coolify tool list visually filtered by category for all 5 tiers in JS (mirrors `SCOPE_TO_CATEGORIES`).
|
||||
- OpenPanel Client ID hint clarifies the correct dashboard path (`{org}/{project-id}/settings/clients`).
|
||||
|
||||
---
|
||||
|
||||
## [3.10.0] — 2026-04-12
|
||||
|
||||
### Tool access hardening + per-site API keys + Tailwind build (Track F.7c)
|
||||
|
||||
#### Added
|
||||
- **Per-site API keys** (DB migration v8 adds `site_id` column): UI site selector in create modal; endpoint enforces site-scoped key access.
|
||||
- **Configuration Snippet section** on keys page (transport / WP / bearer notes).
|
||||
- **Pre-built Tailwind CSS** via `scripts/build-css.sh` (replaces CDN).
|
||||
- **Unified site manage page**: collapsible Connection / Tool Access / Connect sections.
|
||||
|
||||
#### Fixed
|
||||
- **CSRF bug**: removed conflicting `getCsrf()` that tried to read an `httponly` cookie; now relies on the global meta-tag CSRF interceptor in `head_assets.html`.
|
||||
- **Tool scope UI**: tools outside the selected scope are visually dimmed; summary shows in-scope count.
|
||||
- Sidebar logo hidden when collapsed.
|
||||
|
||||
#### Changed
|
||||
- **828 tests passing** (was: 766).
|
||||
- `/dashboard/connect` and `/dashboard/edit` redirect to unified site page.
|
||||
|
||||
---
|
||||
|
||||
## [3.9.0] — 2026-04-09
|
||||
|
||||
### Tool access UI + unified keys page (Track F.7b)
|
||||
|
||||
#### Added
|
||||
- `sites/edit`: Tool Access card with scope dropdown + per-tool toggle grid.
|
||||
- `sites/view`: new `/dashboard/sites/{id}` page with MCP URL + config snippets.
|
||||
- `/dashboard/keys`: unified page (user view with scope selector, admin view).
|
||||
- Sidebar: "Connect" replaced with "API Keys" → `/dashboard/keys`.
|
||||
- `sites/list`: added "Connect" link per site row.
|
||||
- 12 new tests (keys unified + tool access UI smoke).
|
||||
|
||||
#### Changed
|
||||
- **Site-scoped tool access** (schema v7): dropped `user_tool_toggles` in favour of `site_tool_toggles(site_id)`; added `sites.tool_scope` column (default `admin`). Resolves the limitation that a user with multiple sites of the same plugin (e.g. Coolify production + staging) needed independent tool filters.
|
||||
- `ToolAccessManager` now takes `site_id`; `get_visible_tools` intersects key-scope categories with the site's `tool_scope` preset, then applies `site_tool_toggles` overrides.
|
||||
- Dashboard API routes moved under `/api/sites/{site_id}/...`.
|
||||
- `/dashboard/connect` → `/dashboard/keys` (301); `/dashboard/api-keys` → `/dashboard/keys` (301).
|
||||
|
||||
---
|
||||
|
||||
## [3.8.0] — 2026-04-07
|
||||
|
||||
### Coolify plugin Phase 2+3 — projects, databases, services (Track F.17)
|
||||
|
||||
#### Added
|
||||
- **Coolify projects handler** (8 tools): CRUD projects + environments.
|
||||
- **Coolify databases handler** (16 tools): CRUD, lifecycle, 6 DB types, backups.
|
||||
- **Coolify services handler** (13 tools): CRUD, lifecycle, env vars.
|
||||
- **Scope-based tool visibility + per-user toggles** (Track F.7 core): every `ToolDefinition` now carries `category` + `sensitivity` fields. Extends the scope model with `deploy` and `read:sensitive`. `tools/list` filters by the API key's scopes via `SCOPE_TO_CATEGORIES` in `core/tool_access.py`. Coolify handlers are annotated; other plugins keep the default `read` category (backward compatible).
|
||||
|
||||
#### Changed
|
||||
- **Coolify plugin total: 67 tools** (was 30). Platform total: **633 tools**.
|
||||
- **766 tests passing** (48 new).
|
||||
|
||||
---
|
||||
|
||||
## [3.7.0] — 2026-04-04
|
||||
|
||||
### Coolify MCP plugin — Phase 1 MVP (Track F.17)
|
||||
|
||||
#### Added
|
||||
- **Coolify plugin** (30 tools, admin-only):
|
||||
- **Applications** (17): CRUD, lifecycle (start/stop/restart), logs, env vars.
|
||||
- **Deployments** (5): list, get, cancel, deploy by tag/UUID, app history.
|
||||
- **Servers** (8): CRUD, resources, domains, validation.
|
||||
- Coolify credential fields added to `site_api` (URL + API token), with health check.
|
||||
- Registered in `server.py` startup so Coolify tools show up in `/coolify/mcp`.
|
||||
|
||||
#### Changed
|
||||
- Platform plugin count: **10** (added Coolify). Tool count: **597**.
|
||||
|
||||
---
|
||||
|
||||
## [3.6.0] — 2026-04-02
|
||||
|
||||
### Gitea Plugin — Public Release (Track F.16)
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 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 597 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 633 tools total. The tool count stays constant regardless of how many sites are configured.
|
||||
|
||||
## Quick Setup
|
||||
|
||||
@@ -70,7 +70,7 @@ All configured in `pyproject.toml`:
|
||||
```
|
||||
├── server.py # Primary entry point
|
||||
├── core/ # Layer 1: Core system modules
|
||||
├── plugins/ # Layer 2: Plugin system (9 plugins)
|
||||
├── plugins/ # Layer 2: Plugin system (10 plugins)
|
||||
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
|
||||
├── tests/ # Organized test suite
|
||||
├── scripts/ # Setup & deployment scripts
|
||||
@@ -85,7 +85,7 @@ All configured in `pyproject.toml`:
|
||||
|
||||
```
|
||||
Layer 1: Core System (core/) — Auth, site discovery, tool registry, health, rate limiting
|
||||
Layer 2: Plugin System (plugins/) — 9 plugin types, each with handlers + schemas
|
||||
Layer 2: Plugin System (plugins/) — 10 plugin types, each with handlers + schemas
|
||||
Layer 3: API & Web UI (server.py + core/dashboard/) — FastMCP server, Starlette routes, dashboard
|
||||
```
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**The AI-native management hub for WordPress, WooCommerce, and self-hosted services.**
|
||||
|
||||
565 tools across 9 plugins. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
|
||||
633 tools across 10 plugins (incl. Coolify). Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
|
||||
|
||||
> **Don't want to self-host?** Try the hosted instance at **[mcp.palebluedot.live](https://mcp.palebluedot.live)** — log in with GitHub or Google, add your sites, and connect your AI client in minutes.
|
||||
|
||||
@@ -92,7 +92,7 @@ Use the most specific endpoint for your use case:
|
||||
| `/u/{user_id}/{alias}/mcp` | 22-100 | No (pre-scoped) | Hosted/OAuth users |
|
||||
| `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow |
|
||||
| `/{plugin}/mcp` | 23-101 | Yes | Multi-site management |
|
||||
| `/mcp` | 565 | Yes | Admin & discovery only |
|
||||
| `/mcp` | 633 | Yes | Admin & discovery only |
|
||||
|
||||
Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-advanced/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/appwrite/mcp`, `/directus/mcp`, `/system/mcp`
|
||||
|
||||
|
||||
26
README.md
26
README.md
@@ -13,8 +13,8 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
|
||||
[](https://www.python.org/)
|
||||
[](https://pypi.org/project/mcphub-server/)
|
||||
[](https://hub.docker.com/r/airano/mcphub)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
||||
|
||||
</div>
|
||||
@@ -49,21 +49,22 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
||||
|
||||
---
|
||||
|
||||
## 565 Tools Across 9 Plugins
|
||||
## 633 Tools Across 10 Plugins
|
||||
|
||||
| Plugin | Tools | What You Can Do |
|
||||
|--------|-------|-----------------|
|
||||
| **WordPress** | 67 | Posts, pages, media, users, menus, taxonomies, SEO (Rank Math/Yoast) |
|
||||
| **WooCommerce** | 28 | Products, orders, customers, coupons, reports, shipping |
|
||||
| **WordPress Advanced** | 22 | Database ops, bulk operations, WP-CLI, system management |
|
||||
| **Gitea** | 56 | Repos, issues, pull requests, releases, webhooks, organizations |
|
||||
| **Gitea** | 58 | Repos, issues, pull requests, releases, webhooks, organizations, labels |
|
||||
| **n8n** | 56 | Workflows, executions, credentials, variables, audit |
|
||||
| **Supabase** | 70 | Database, auth, storage, edge functions, realtime |
|
||||
| **OpenPanel** | 42 | Events, export, insights, profiles, projects, system |
|
||||
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
|
||||
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
|
||||
| **System** | 24 | Health monitoring, API keys, OAuth management, audit |
|
||||
| **Total** | **565** | Constant count — scales to unlimited sites |
|
||||
| **Coolify** | 67 | Applications, deployments, servers, projects, databases, services |
|
||||
| **System** | 23 | Health monitoring, API keys, OAuth management, audit |
|
||||
| **Total** | **633** | Constant count — scales to unlimited sites |
|
||||
|
||||
---
|
||||
|
||||
@@ -265,29 +266,30 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
## Architecture
|
||||
|
||||
```
|
||||
/mcp → Admin endpoint (all 565 tools)
|
||||
/system/mcp → System tools only (24 tools)
|
||||
/mcp → Admin endpoint (all 633 tools)
|
||||
/system/mcp → System tools only (23 tools)
|
||||
/wordpress/mcp → WordPress tools (67 tools)
|
||||
/woocommerce/mcp → WooCommerce tools (28 tools)
|
||||
/wordpress-advanced/mcp → WordPress Advanced tools (22 tools)
|
||||
/gitea/mcp → Gitea tools (56 tools)
|
||||
/gitea/mcp → Gitea tools (58 tools)
|
||||
/n8n/mcp → n8n tools (56 tools)
|
||||
/supabase/mcp → Supabase tools (70 tools)
|
||||
/openpanel/mcp → OpenPanel tools (42 tools)
|
||||
/appwrite/mcp → Appwrite tools (100 tools)
|
||||
/directus/mcp → Directus tools (100 tools)
|
||||
/coolify/mcp → Coolify tools (67 tools)
|
||||
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
||||
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
|
||||
```
|
||||
|
||||
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (565 tools) to minimize token usage.
|
||||
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (633 tools) to minimize token usage.
|
||||
|
||||
| Endpoint | Use Case | Tools |
|
||||
|----------|----------|------:|
|
||||
| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 |
|
||||
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
|
||||
| `/{plugin}/mcp` | Multi-site management | 23-101 |
|
||||
| `/mcp` | Admin & discovery only | 565 |
|
||||
| `/mcp` | Admin & discovery only | 633 |
|
||||
|
||||
### Security
|
||||
|
||||
@@ -297,7 +299,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
- **GDPR-compliant audit logging** with automatic sensitive data filtering
|
||||
- **Web dashboard** with real-time health monitoring (8 pages, EN/FA i18n)
|
||||
|
||||
> **Compatibility Note**: MCP Hub requires FastMCP 2.x (`>=2.14.0,<3.0.0`). FastMCP 3.0 introduced breaking changes and is not yet supported. If you install dependencies manually, ensure you don't upgrade to FastMCP 3.x.
|
||||
> **Compatibility Note**: MCP Hub requires FastMCP 3.x (`>=3.0.0,<4.0.0`). The legacy multi-endpoint server and ProjectManager have been removed in v3.5.0.
|
||||
|
||||
### WordPress Plugin Requirements
|
||||
|
||||
|
||||
@@ -2913,16 +2913,22 @@ async def dashboard_connect_page(request: Request) -> Response:
|
||||
|
||||
|
||||
async def dashboard_sites_view(request: Request) -> Response:
|
||||
"""GET /dashboard/sites/{id} — Show site connect page with config snippets."""
|
||||
"""GET /dashboard/sites/{id} — Unified site management page (F.7c).
|
||||
|
||||
Combines connection settings, tool access, and connect/config snippets
|
||||
into a single page with 3 sections.
|
||||
"""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
site_id = request.path_params.get("id", "")
|
||||
|
||||
import json
|
||||
|
||||
from core.config_snippets import get_supported_clients
|
||||
from core.site_api import PLUGIN_DISPLAY_NAMES as SITE_PLUGIN_NAMES
|
||||
from core.site_api import get_user_site
|
||||
from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site
|
||||
from core.tool_access import get_scope_presets_for_plugin
|
||||
|
||||
site = await get_user_site(site_id, user_session["user_id"])
|
||||
if site is None:
|
||||
@@ -2936,15 +2942,23 @@ async def dashboard_sites_view(request: Request) -> Response:
|
||||
public_url = os.environ.get("PUBLIC_URL", "http://localhost:8000").rstrip("/")
|
||||
mcp_url = f"{public_url}/u/{user_session['user_id']}/{site['alias']}/mcp"
|
||||
|
||||
plugin_fields = get_user_credential_fields()
|
||||
plugin_names = get_user_plugin_names()
|
||||
scope_presets = get_scope_presets_for_plugin(site["plugin_type"])
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard/sites/view.html",
|
||||
"dashboard/sites/manage.html",
|
||||
{
|
||||
"lang": lang,
|
||||
"t": t,
|
||||
"session": user_session,
|
||||
"site": site,
|
||||
"plugin_names": SITE_PLUGIN_NAMES,
|
||||
"plugin_names": plugin_names,
|
||||
"plugin_fields": plugin_fields,
|
||||
"plugin_fields_json": json.dumps(plugin_fields),
|
||||
"scope_presets": scope_presets,
|
||||
"scope_presets_json": json.dumps(scope_presets),
|
||||
"mcp_url": mcp_url,
|
||||
"clients": get_supported_clients(),
|
||||
"current_page": "my_sites",
|
||||
@@ -3009,7 +3023,9 @@ async def dashboard_keys_unified(request: Request) -> Response:
|
||||
)
|
||||
|
||||
if user_session:
|
||||
# User view — personal keys
|
||||
# User view — personal keys + config snippets
|
||||
from core.config_snippets import get_supported_clients
|
||||
from core.site_api import get_user_sites
|
||||
from core.user_keys import get_user_key_manager
|
||||
|
||||
user_keys = []
|
||||
@@ -3019,6 +3035,8 @@ async def dashboard_keys_unified(request: Request) -> Response:
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
sites = await get_user_sites(user_session["user_id"])
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard/keys/list.html",
|
||||
@@ -3028,6 +3046,8 @@ async def dashboard_keys_unified(request: Request) -> Response:
|
||||
"session": user_session,
|
||||
"is_admin": False,
|
||||
"user_keys": user_keys,
|
||||
"sites": sites,
|
||||
"clients": get_supported_clients(),
|
||||
"current_page": "keys",
|
||||
},
|
||||
)
|
||||
@@ -3153,43 +3173,13 @@ async def api_test_site(request: Request) -> Response:
|
||||
|
||||
|
||||
async def dashboard_sites_edit(request: Request) -> Response:
|
||||
"""GET /dashboard/sites/{id}/edit — Render the Edit Site form."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
"""GET /dashboard/sites/{id}/edit — Redirect to unified site page (F.7c)."""
|
||||
site_id = request.path_params.get("id", "")
|
||||
|
||||
from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site
|
||||
|
||||
site = await get_user_site(site_id, user_session["user_id"])
|
||||
if site is None:
|
||||
return RedirectResponse("/dashboard/sites?error=site_not_found", status_code=302)
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
query_lang = request.query_params.get("lang")
|
||||
lang = detect_language(accept_language, query_lang)
|
||||
t = get_translations(lang)
|
||||
|
||||
import json
|
||||
|
||||
plugin_fields = get_user_credential_fields()
|
||||
plugin_names = get_user_plugin_names()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard/sites/edit.html",
|
||||
{
|
||||
"lang": lang,
|
||||
"t": t,
|
||||
"session": user_session,
|
||||
"site": site,
|
||||
"plugin_fields": plugin_fields,
|
||||
"plugin_fields_json": json.dumps(plugin_fields),
|
||||
"plugin_names": plugin_names,
|
||||
"current_page": "my_sites",
|
||||
},
|
||||
)
|
||||
url = f"/dashboard/sites/{site_id}"
|
||||
if query_lang:
|
||||
url += f"?lang={query_lang}"
|
||||
return RedirectResponse(url, status_code=301)
|
||||
|
||||
|
||||
async def api_update_site(request: Request) -> Response:
|
||||
@@ -3243,11 +3233,25 @@ async def api_create_key(request: Request) -> Response:
|
||||
|
||||
try:
|
||||
key_mgr = get_user_key_manager()
|
||||
|
||||
# Validate site_id if provided (must belong to user)
|
||||
site_id = body.get("site_id")
|
||||
if site_id:
|
||||
from core.database import get_database
|
||||
|
||||
db = get_database()
|
||||
site = await db.get_site(site_id, user_session["user_id"])
|
||||
if site is None:
|
||||
return JSONResponse({"error": "Site not found"}, status_code=404)
|
||||
|
||||
# F.7c: All user keys get full access — tool visibility is controlled
|
||||
# per-site via tool_scope and per-tool toggles.
|
||||
result = await key_mgr.create_key(
|
||||
user_id=user_session["user_id"],
|
||||
name=body.get("name", "Default"),
|
||||
scopes=body.get("scopes", "read write admin"),
|
||||
scopes="read write admin",
|
||||
expires_in_days=body.get("expires_in_days"),
|
||||
site_id=site_id,
|
||||
)
|
||||
return JSONResponse({"key": result})
|
||||
except RuntimeError as e:
|
||||
@@ -3828,9 +3832,9 @@ def register_dashboard_routes(mcp):
|
||||
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add)
|
||||
mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit)
|
||||
mcp.custom_route("/dashboard/sites/{id}", methods=["GET"])(dashboard_sites_view)
|
||||
# /dashboard/connect → /dashboard/keys (301)
|
||||
# /dashboard/connect → /dashboard/sites (F.7c: sites page has connect info now)
|
||||
mcp.custom_route("/dashboard/connect", methods=["GET"])(
|
||||
lambda r: RedirectResponse("/dashboard/keys", status_code=301)
|
||||
lambda r: RedirectResponse("/dashboard/sites", status_code=301)
|
||||
)
|
||||
|
||||
# Service pages (F.3)
|
||||
|
||||
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
|
||||
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
|
||||
|
||||
# Schema version — increment when adding migrations
|
||||
SCHEMA_VERSION = 7
|
||||
SCHEMA_VERSION = 8
|
||||
|
||||
# Initial schema DDL
|
||||
_SCHEMA_SQL = """\
|
||||
@@ -83,7 +83,8 @@ CREATE TABLE IF NOT EXISTS user_api_keys (
|
||||
last_used TEXT,
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT
|
||||
expires_at TEXT,
|
||||
site_id TEXT
|
||||
);
|
||||
|
||||
-- WP plugin connection tokens (short-lived, for MCP Connect plugin)
|
||||
@@ -165,6 +166,10 @@ _MIGRATIONS: dict[int, str] = {
|
||||
"ON site_tool_toggles(site_id);\n"
|
||||
"ALTER TABLE sites ADD COLUMN tool_scope TEXT NOT NULL DEFAULT 'admin';\n"
|
||||
),
|
||||
8: (
|
||||
# F.7c: per-site API keys — allow keys scoped to a single site
|
||||
"ALTER TABLE user_api_keys ADD COLUMN site_id TEXT;\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -682,6 +687,7 @@ class Database:
|
||||
name: str,
|
||||
scopes: str = "read write",
|
||||
expires_at: str | None = None,
|
||||
site_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new user API key.
|
||||
|
||||
@@ -692,6 +698,7 @@ class Database:
|
||||
name: Human label (e.g. "Claude Desktop").
|
||||
scopes: Space-separated scopes.
|
||||
expires_at: Optional ISO 8601 expiry timestamp.
|
||||
site_id: Optional site UUID to scope key to a single site.
|
||||
|
||||
Returns:
|
||||
The created API key row as a dict.
|
||||
@@ -701,14 +708,14 @@ class Database:
|
||||
|
||||
await self.execute(
|
||||
"INSERT INTO user_api_keys "
|
||||
"(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at),
|
||||
"(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at, site_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at, site_id),
|
||||
)
|
||||
|
||||
result = await self.fetchone(
|
||||
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
|
||||
"created_at, expires_at FROM user_api_keys WHERE id = ?",
|
||||
"created_at, expires_at, site_id FROM user_api_keys WHERE id = ?",
|
||||
(key_id,),
|
||||
)
|
||||
if result is None:
|
||||
@@ -726,7 +733,7 @@ class Database:
|
||||
"""
|
||||
return await self.fetchall(
|
||||
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
|
||||
"created_at, expires_at FROM user_api_keys WHERE user_id = ? ORDER BY created_at",
|
||||
"created_at, expires_at, site_id FROM user_api_keys WHERE user_id = ? ORDER BY created_at",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
|
||||
"label": "Client ID",
|
||||
"type": "text",
|
||||
"required": True,
|
||||
"hint": "OpenPanel Dashboard → Settings → Clients → Create client with 'root' mode for full access",
|
||||
"hint": "Open your project on dashboard.openpanel.dev → Settings → Clients (URL: dashboard.openpanel.dev/{org}/{project-id}/settings/clients). Create a client in 'root' mode for full access.",
|
||||
},
|
||||
{
|
||||
"name": "client_secret",
|
||||
|
||||
@@ -111,9 +111,6 @@
|
||||
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
||||
<span class="{% if lang == 'fa' %}mr-3{% else %}ml-3{% endif %} font-bold text-lg text-gray-900 dark:text-white">MCP Hub</span>
|
||||
</div>
|
||||
<div x-show="!sidebarOpen" class="flex items-center justify-center">
|
||||
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
||||
</div>
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
|
||||
@@ -185,12 +185,13 @@
|
||||
<td class="px-6 py-4 text-gray-900 dark:text-white">{{ key.name }}</td>
|
||||
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 font-mono text-sm">mhu_{{ key.key_prefix }}...</td>
|
||||
<td class="px-6 py-4">
|
||||
{% set scopes = (key.scopes or 'read write admin').split() %}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for s in scopes %}
|
||||
<span class="px-2 py-0.5 {% if s == 'admin' %}bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400{% elif s == 'write' %}bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400{% elif s == 'deploy' %}bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400{% else %}bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400{% endif %} text-xs rounded font-medium">{{ s }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if key.site_id %}
|
||||
<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 text-xs rounded font-medium">
|
||||
{% for site in sites %}{% if site.id == key.site_id %}{{ site.alias }}{% endif %}{% endfor %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-0.5 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-xs rounded font-medium">{% if lang == 'fa' %}همه سرویسها{% else %}All Services{% endif %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm">{{ key.use_count }}</td>
|
||||
<td class="px-6 py-4">
|
||||
@@ -208,6 +209,100 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not is_admin %}
|
||||
<!-- Configuration Snippet -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}نمونه کد پیکربندی{% else %}Configuration Snippet{% endif %}
|
||||
</h3>
|
||||
|
||||
{% if sites %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}سایت{% else %}Site{% endif %}</label>
|
||||
<select id="config-site" onchange="updateConfig()"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||
{% for site in sites %}
|
||||
<option value="{{ site.alias }}" data-site-id="{{ site.id }}" data-plugin="{{ site.plugin_type }}">{{ site.alias }} ({{ site.plugin_type }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}کلاینت{% else %}Client{% endif %}</label>
|
||||
<select id="config-client" onchange="updateConfig()"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}">{{ client.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[120px]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</pre>
|
||||
<button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else 'right-2' }} text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors" id="copy-config-btn">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
|
||||
</div>
|
||||
|
||||
<!-- Transport type info -->
|
||||
<div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>نکته:</strong> تنظیمات بالا شامل نوع اتصال (<code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code) میباشد. از <code>sse</code> استفاده نکنید — باعث خطای <code>400 Bad Request</code> میشود.
|
||||
{% else %}
|
||||
<strong>Note:</strong> Config above includes the correct transport type (<code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code). Do not use <code>sse</code> — it will cause <code>400 Bad Request</code> errors.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- WordPress SEO plugin note -->
|
||||
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
|
||||
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
|
||||
را روی سایت وردپرسی خود نصب کنید.
|
||||
{% else %}
|
||||
<strong>WordPress:</strong> For SEO tools, install the
|
||||
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
|
||||
plugin on your WordPress site.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Connection tip for web-based clients -->
|
||||
<div id="connection-tip" style="display:none" class="mt-3 bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4">
|
||||
<p class="text-sm text-green-700 dark:text-green-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال میتوانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
|
||||
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
|
||||
{% else %}
|
||||
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
|
||||
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- API Key hint for desktop/CLI clients -->
|
||||
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>API Key:</strong> از بخش API Keys بالا بسازید و مقدار آن را در تنظیمات وارد کنید:
|
||||
{% else %}
|
||||
<strong>API Key:</strong> Create one above and use it in your config:
|
||||
{% endif %}
|
||||
</p>
|
||||
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{% if lang == 'fa' %}ابتدا یک سایت اضافه کنید.{% else %}Add a site first to see configuration snippets.{% endif %}
|
||||
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -267,16 +362,26 @@
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}</label>
|
||||
<select id="new-key-scope" class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||
<option value="read">Read — {% if lang == 'fa' %}فقط خواندن{% else %}view-only{% endif %}</option>
|
||||
<option value="read:sensitive">Read + Sensitive — {% if lang == 'fa' %}خواندن + داده حساس{% else %}logs, envs{% endif %}</option>
|
||||
<option value="deploy">Deploy — {% if lang == 'fa' %}start/stop/restart{% else %}start/stop/restart{% endif %}</option>
|
||||
<option value="write">Write — {% if lang == 'fa' %}نوشتن + lifecycle{% else %}create/update + lifecycle{% endif %}</option>
|
||||
<option value="read write admin" selected>Full Access — {% if lang == 'fa' %}دسترسی کامل{% else %}all tools{% endif %}</option>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}محدوده سرویس{% else %}Service Scope{% endif %}</label>
|
||||
<select id="new-key-site"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">{% if lang == 'fa' %}همه سرویسها (عمومی){% else %}All Services (global){% endif %}</option>
|
||||
{% if sites %}
|
||||
{% for site in sites %}
|
||||
<option value="{{ site.id }}">{{ site.alias }} ({{ site.plugin_type }})</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1.5">
|
||||
{% if lang == 'fa' %}فیلتر ابزار هر سایت در تنظیمات سایت انجام میشود.{% else %}Per-site tool filters are set in Site Settings.{% endif %}
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{% if lang == 'fa' %}کلید اختصاصی فقط برای سرویس انتخابشده قابل استفاده است.{% else %}A site-scoped key only works for the selected service.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="px-3 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg">
|
||||
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
|
||||
</span>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{% if lang == 'fa' %}فیلتر ابزار هر سایت در <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline hover:text-gray-300">تنظیمات سایت</a> انجام میشود.{% else %}Tool access is controlled per-site in <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline hover:text-gray-300">Site Settings</a>.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,6 +472,16 @@
|
||||
function openCreateModal() {
|
||||
document.getElementById('createModal').classList.remove('hidden');
|
||||
document.getElementById('createModal').classList.add('flex');
|
||||
// F.7d: pre-select site from ?site= query param when present
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const siteParam = params.get('site');
|
||||
if (siteParam) {
|
||||
const siteSelect = document.getElementById('new-key-site');
|
||||
if (siteSelect) {
|
||||
const match = Array.from(siteSelect.options).find(o => o.value === siteParam);
|
||||
if (match) siteSelect.value = siteParam;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.openCreateModal = openCreateModal;
|
||||
|
||||
@@ -421,11 +536,13 @@
|
||||
} else {
|
||||
// User: POST to /api/keys
|
||||
const name = document.getElementById('new-key-name').value.trim();
|
||||
const scope = document.getElementById('new-key-scope').value;
|
||||
if (!name) { alert(lang === 'fa' ? 'نام کلید الزامی است' : 'Key name is required'); return; }
|
||||
const siteId = document.getElementById('new-key-site')?.value || null;
|
||||
const payload = { name, scopes: 'read write admin' };
|
||||
if (siteId) payload.site_id = siteId;
|
||||
const r = await fetch('/api/keys', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, scopes: scope }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const res = await r.json();
|
||||
if (!r.ok) { alert(res.error || 'Failed'); return; }
|
||||
@@ -488,6 +605,72 @@
|
||||
else { const d = await r.json(); alert(d.error || 'Failed'); }
|
||||
};
|
||||
{% endif %}
|
||||
|
||||
// ── Config Snippets (user view) ────────────────────────────────
|
||||
{% if not is_admin %}
|
||||
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
|
||||
const WP_TYPES = ['wordpress', 'woocommerce'];
|
||||
|
||||
async function updateConfig() {
|
||||
const siteSelect = document.getElementById('config-site');
|
||||
const alias = siteSelect?.value;
|
||||
const client = document.getElementById('config-client')?.value;
|
||||
if (!alias || !client) return;
|
||||
|
||||
const isWebClient = WEB_CLIENTS.includes(client);
|
||||
|
||||
const transportNote = document.getElementById('transport-note');
|
||||
if (transportNote) transportNote.style.display = isWebClient ? 'none' : '';
|
||||
|
||||
const connTip = document.getElementById('connection-tip');
|
||||
if (connTip) connTip.style.display = isWebClient ? '' : 'none';
|
||||
|
||||
const bearerHint = document.getElementById('bearer-hint');
|
||||
if (bearerHint) bearerHint.style.display = isWebClient ? 'none' : '';
|
||||
|
||||
const selectedOption = siteSelect.options[siteSelect.selectedIndex];
|
||||
const pluginType = selectedOption?.dataset?.plugin || '';
|
||||
const wpNote = document.getElementById('wp-seo-note');
|
||||
if (wpNote) wpNote.style.display = WP_TYPES.includes(pluginType) ? '' : 'none';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/config/' + alias + '?client=' + client);
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
document.getElementById('config-output').textContent = data.config;
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('config-output').textContent = 'Error loading config';
|
||||
}
|
||||
}
|
||||
window.updateConfig = updateConfig;
|
||||
|
||||
function copyConfig() {
|
||||
const text = document.getElementById('config-output').textContent;
|
||||
navigator.clipboard.writeText(text);
|
||||
const btn = document.getElementById('copy-config-btn');
|
||||
btn.textContent = lang === 'fa' ? 'کپی شد!' : 'Copied!';
|
||||
setTimeout(() => btn.textContent = lang === 'fa' ? 'کپی' : 'Copy', 2000);
|
||||
}
|
||||
window.copyConfig = copyConfig;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// F.7d: support ?site=<id-or-alias> from /dashboard/sites/{id} link
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const siteParam = params.get('site');
|
||||
const siteSelect = document.getElementById('config-site');
|
||||
if (siteParam && siteSelect) {
|
||||
// Match either the option value (alias) or its data-site-id attribute
|
||||
const match = Array.from(siteSelect.options).find(
|
||||
o => o.value === siteParam || o.dataset?.siteId === siteParam
|
||||
);
|
||||
if (match) siteSelect.value = match.value;
|
||||
}
|
||||
if (siteSelect) updateConfig();
|
||||
// F.7d: auto-open create modal when ?create=1 (e.g. from Connect section)
|
||||
if (params.get('create') === '1') openCreateModal();
|
||||
});
|
||||
{% endif %}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -220,24 +220,14 @@ https://claude.com/api/mcp/auth_callback</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="scopes" value="read" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">read</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="scopes" value="write" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">write</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="scopes" value="admin" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">admin</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="px-3 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg">
|
||||
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}دسترسی ابزار در تنظیمات هر سایت کنترل میشود.{% else %}Tool access is controlled per-site in Site Settings.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{% if lang == 'fa' %}admin: دسترسی کامل به همه عملیات{% else %}admin: Full access to all operations{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
@@ -426,10 +416,8 @@ https://claude.com/api/mcp/auth_callback</textarea>
|
||||
e.preventDefault();
|
||||
|
||||
var formData = new FormData(this);
|
||||
var scopes = [];
|
||||
document.querySelectorAll('input[name="scopes"]:checked').forEach(function(cb) {
|
||||
scopes.push(cb.value);
|
||||
});
|
||||
// F.7c: All clients get full scope — tool access controlled per-site
|
||||
var scopes = ['read', 'write', 'admin'];
|
||||
|
||||
// Parse redirect URIs (one per line)
|
||||
var redirectUrisText = formData.get('redirect_uris') || '';
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Tailwind CSS (pre-built) -->
|
||||
<link rel="stylesheet" href="/static/tailwind.min.css">
|
||||
|
||||
<!-- HTMX for dynamic updates -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
@@ -13,34 +13,6 @@
|
||||
<!-- Alpine.js for simple interactions -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Custom Tailwind Config -->
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'vazirmatn': ['Vazirmatn', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f5f3ff',
|
||||
100: '#ede9fe',
|
||||
200: '#ddd6fe',
|
||||
300: '#c4b5fd',
|
||||
400: '#a78bfa',
|
||||
500: '#8b5cf6',
|
||||
600: '#7c3aed',
|
||||
700: '#6d28d9',
|
||||
800: '#5b21b6',
|
||||
900: '#4c1d95',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Theme Initialization -->
|
||||
<script>
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{% set admin_tools = service.tools|selectattr('scope', 'equalto', 'admin')|list %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-{{ 4 if admin_tools|length > 0 else 3 }} gap-6">
|
||||
<!-- Tools Count -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -112,6 +113,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Tools (F.7d) -->
|
||||
{% if admin_tools|length > 0 %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}ابزار مدیر{% else %}Admin Tools{% endif %}
|
||||
</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ admin_tools|length }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Setup Requirements -->
|
||||
|
||||
@@ -42,13 +42,17 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{{ svc.tools_count }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
||||
<span>·</span>
|
||||
{% set read_count = svc.tools|selectattr('scope', 'equalto', 'read')|list|length %}
|
||||
{% set write_count = svc.tools|selectattr('scope', 'equalto', 'write')|list|length %}
|
||||
{% set admin_count = svc.tools|selectattr('scope', 'equalto', 'admin')|list|length %}
|
||||
<span class="text-green-600 dark:text-green-400">{{ read_count }} read</span>
|
||||
<span class="text-orange-600 dark:text-orange-400">{{ write_count }} write</span>
|
||||
{% if admin_count > 0 %}
|
||||
<span class="text-red-600 dark:text-red-400">{{ admin_count }} admin</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
{% block page_title %}{{ t.add_site }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
@@ -18,55 +19,119 @@
|
||||
<!-- Error display -->
|
||||
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
|
||||
|
||||
<form id="add-site-form" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
|
||||
<!-- Plugin Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||
<select id="plugin_type" name="plugin_type" required
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
onchange="updateFields()">
|
||||
<option value="">{{ t.select_plugin }}</option>
|
||||
{% for ptype, pname in plugin_names.items() %}
|
||||
<option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Section 1: Add Site / Connect Service -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="toggleSection('addsite')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}اتصال سرویس{% else %}Connect Service{% endif %}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{% if lang == 'fa' %}نوع پلاگین و اطلاعات اتصال{% else %}Plugin type and connection details{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg id="addsite-chevron" class="w-5 h-5 text-gray-400 transition-transform rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="addsite-section" class="border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||
<form id="add-site-form" class="space-y-4">
|
||||
<!-- Plugin Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||
<select id="plugin_type" name="plugin_type" required
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
onchange="updateFields()">
|
||||
<option value="">{{ t.select_plugin }}</option>
|
||||
{% for ptype, pname in plugin_names.items() %}
|
||||
<option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
|
||||
<input type="url" id="url" name="url" required placeholder="https://example.com"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<p id="url-hint" class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden"></p>
|
||||
</div>
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
|
||||
<input type="url" id="url" name="url" required placeholder="https://example.com"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<p id="url-hint" class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden"></p>
|
||||
</div>
|
||||
|
||||
<!-- Alias -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||
<input type="text" id="alias" name="alias" required placeholder="myblog" pattern="[a-zA-Z0-9_-]+"
|
||||
minlength="2" maxlength="50"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t.site_alias_hint }}</p>
|
||||
</div>
|
||||
<!-- Alias -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||
<input type="text" id="alias" name="alias" required placeholder="myblog" pattern="[a-zA-Z0-9_-]+"
|
||||
minlength="2" maxlength="50"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t.site_alias_hint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Credential Fields -->
|
||||
<div id="credential-fields" class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>
|
||||
</div>
|
||||
<!-- Dynamic Credential Fields -->
|
||||
<div id="credential-fields" class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex items-center gap-4 pt-4">
|
||||
<button type="submit" id="submit-btn"
|
||||
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
|
||||
{{ t.add_site }}
|
||||
</button>
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
{{ t.cancel }}
|
||||
</a>
|
||||
<!-- Submit -->
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button type="submit" id="submit-btn"
|
||||
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm font-medium disabled:opacity-50 transition-colors">
|
||||
{{ t.add_site }}
|
||||
</button>
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors text-sm">
|
||||
{{ t.cancel }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Section 2: What happens next (info card) -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="toggleSection('info')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}مراحل بعدی{% else %}What happens next{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<svg id="info-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="info-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">1</span>
|
||||
<p>{% if lang == 'fa' %}سرویس خود را با انتخاب نوع پلاگین و وارد کردن URL و اعتبارنامه اضافه کنید.{% else %}Add your service by selecting the plugin type and entering the URL and credentials.{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">2</span>
|
||||
<p>{% if lang == 'fa' %}سطح دسترسی ابزارها و تنظیمات اتصال را از صفحه مدیریت سایت پیکربندی کنید.{% else %}Configure tool access levels and connection settings from the site management page.{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">3</span>
|
||||
<p>{% if lang == 'fa' %}یک کلید API بسازید و از قطعه کد پیکربندی برای اتصال MCP client خود استفاده کنید.{% else %}Create an API key and use the configuration snippet to connect your MCP client.{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -79,6 +144,14 @@
|
||||
'supabase': 'Your Supabase project URL (e.g., https://xxxxx.supabase.co)',
|
||||
};
|
||||
|
||||
// ── Section toggle ──────────────────────────────────────────────
|
||||
function toggleSection(name) {
|
||||
const section = document.getElementById(name + '-section');
|
||||
const chevron = document.getElementById(name + '-chevron');
|
||||
section.classList.toggle('hidden');
|
||||
chevron.classList.toggle('rotate-180');
|
||||
}
|
||||
|
||||
function updateFields() {
|
||||
const ptype = document.getElementById('plugin_type').value;
|
||||
const container = document.getElementById('credential-fields');
|
||||
@@ -131,7 +204,7 @@
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Auto-fill credential fields if plugin_type is pre-selected (e.g., from service page)
|
||||
// Auto-fill credential fields if plugin_type is pre-selected
|
||||
if (document.getElementById('plugin_type').value) {
|
||||
updateFields();
|
||||
}
|
||||
@@ -171,7 +244,13 @@
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||
// Redirect to manage page for the new site
|
||||
const siteId = data.site?.id || data.site_id;
|
||||
if (siteId) {
|
||||
window.location.href = '/dashboard/sites/' + siteId + '{% if lang and lang != "en" %}?lang={{ lang }}{% endif %}';
|
||||
} else {
|
||||
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||
}
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Failed to add site';
|
||||
errorEl.classList.remove('hidden');
|
||||
|
||||
@@ -229,11 +229,6 @@
|
||||
'system': 'System',
|
||||
};
|
||||
|
||||
function getCsrf() {
|
||||
const m = document.cookie.match(/(?:^|;\s*)dashboard_csrf=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
|
||||
async function loadToolAccess() {
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tools');
|
||||
@@ -269,7 +264,7 @@
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tool-scope', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'x-csrf-token': getCsrf() },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scope }),
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -349,7 +344,7 @@
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'x-csrf-token': getCsrf() },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!r.ok) { input.checked = !enabled; }
|
||||
|
||||
768
core/templates/dashboard/sites/manage.html
Normal file
768
core/templates/dashboard/sites/manage.html
Normal file
@@ -0,0 +1,768 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ site.alias }} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{{ site.alias }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ site.alias }}
|
||||
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300">
|
||||
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Section 1: Connection Settings -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="toggleSection('connection')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}تنظیمات اتصال{% else %}Connection Settings{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<svg id="connection-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="connection-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||
<!-- Info notice -->
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-700 dark:text-blue-300 px-4 py-3 rounded-lg text-sm">
|
||||
{{ t.keep_existing }}
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
|
||||
|
||||
<form id="edit-site-form" class="space-y-4">
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
|
||||
<input type="url" id="url" name="url" required value="{{ site.url }}"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
|
||||
<!-- Credential Fields -->
|
||||
<div id="credential-fields" class="space-y-4">
|
||||
{% set fields = plugin_fields.get(site.plugin_type, []) %}
|
||||
{% for field in fields %}
|
||||
{% if not field.get('advanced', False) %}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{{ field.label }}{% if field.required %} *{% endif %}
|
||||
</label>
|
||||
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||
placeholder="{% if field.required %}{{ t.keep_existing }}{% else %}{{ t.leave_blank_to_clear }}{% endif %}"
|
||||
data-required="{{ field.required | lower }}"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
{% if field.hint %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% set has_advanced = fields | map(attribute='advanced') | select | list | length > 0 if fields else false %}
|
||||
{% if has_advanced %}
|
||||
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
|
||||
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
|
||||
Advanced Settings
|
||||
</summary>
|
||||
<div class="px-4 pb-4 pt-2 space-y-4">
|
||||
{% for field in fields %}
|
||||
{% if field.get('advanced', False) %}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{{ field.label }}</label>
|
||||
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||
placeholder="{{ t.leave_blank_to_clear }}" data-required="false"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
{% if field.hint %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Test + Save buttons -->
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button type="submit" id="submit-btn"
|
||||
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm font-medium disabled:opacity-50 transition-colors">
|
||||
{{ t.save }}
|
||||
</button>
|
||||
<button type="button" id="test-btn" onclick="testConnection()"
|
||||
class="px-5 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">
|
||||
{% if lang == 'fa' %}تست اتصال{% else %}Test Connection{% endif %}
|
||||
</button>
|
||||
<span id="test-status" class="text-sm"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Section 2: Tool Access -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="toggleSection('tools')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}دسترسی به ابزارها{% else %}Tool Access{% endif %}
|
||||
</h3>
|
||||
<p id="tool-summary" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"></p>
|
||||
</div>
|
||||
</div>
|
||||
<svg id="tools-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="tools-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||
<div id="tool-access-loading" class="text-gray-400 dark:text-gray-500 text-sm">
|
||||
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
||||
</div>
|
||||
<div id="tool-access-content" class="hidden space-y-4">
|
||||
<!-- Scope tier selector (F.7d: rendered from plugin-specific presets) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{% if lang == 'fa' %}سطح دسترسی{% else %}Access Level{% endif %}
|
||||
</label>
|
||||
<div id="scope-tiers" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-{{ scope_presets|length }} gap-2">
|
||||
{% for preset in scope_presets %}
|
||||
<button type="button" data-scope="{{ preset.value }}" onclick="selectScope('{{ preset.value }}')"
|
||||
class="scope-btn px-3 py-2.5 rounded-lg border-2 text-sm font-medium transition-all text-center">
|
||||
<div class="font-semibold">{% if lang == 'fa' %}{{ preset.label_fa }}{% else %}{{ preset.label }}{% endif %}</div>
|
||||
<div class="text-xs opacity-70 mt-0.5">{% if lang == 'fa' %}{{ preset.hint_fa }}{% else %}{{ preset.hint }}{% endif %}</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="scope-status" class="hidden text-xs mt-2"></div>
|
||||
<div id="scope-notice" class="hidden mt-3 rounded-lg p-3 text-xs"></div>
|
||||
</div>
|
||||
|
||||
<!-- Per-tool toggles (visible for all tiers, expanded for custom) -->
|
||||
<div id="tool-overrides-wrapper">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}
|
||||
<span id="tool-count" class="text-gray-400 font-normal"></span>
|
||||
</p>
|
||||
<input type="text" id="tool-search" placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search...{% endif %}"
|
||||
class="w-48 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div id="tool-list" class="space-y-1 max-h-80 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Section 3: Connect -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="toggleSection('connect')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}اتصال{% else %}Connect{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<svg id="connect-chevron" class="w-5 h-5 text-gray-400 transition-transform rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="connect-section" class="border-t border-gray-200 dark:border-gray-700 p-6 space-y-5">
|
||||
<!-- MCP Endpoint URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{% if lang == 'fa' %}آدرس MCP{% else %}MCP Endpoint{% endif %}
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="mcp-url" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto">{{ mcp_url }}</code>
|
||||
<button onclick="copyText('mcp-url')" class="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
|
||||
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
|
||||
{% if lang == 'fa' %}برای احراز هویت از کلید API یا OAuth استفاده کنید.{% else %}Authenticate with an API key (Bearer token) or OAuth.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Config Snippets -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{% if lang == 'fa' %}نمونه کد پیکربندی{% else %}Configuration Snippet{% endif %}
|
||||
</label>
|
||||
<select id="config-client" onchange="updateConfig()"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 mb-3">
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}">{{ client.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="relative">
|
||||
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[80px]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</pre>
|
||||
<button onclick="copyConfig()" id="copy-config-btn"
|
||||
class="absolute top-2 right-2 text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors">
|
||||
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
<div id="transport-note" class="mt-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
{% if lang == 'fa' %}<strong>نکته:</strong> از <code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code استفاده کنید.
|
||||
{% else %}<strong>Note:</strong> Use <code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick API Key Create -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}کلید API{% else %}API Key{% endif %}
|
||||
</label>
|
||||
<a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-xs text-blue-500 hover:text-blue-400">
|
||||
{% if lang == 'fa' %}مدیریت کلیدها{% else %}Manage Keys{% endif %} →
|
||||
</a>
|
||||
</div>
|
||||
<div id="quick-key-area">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" id="quick-key-name" placeholder="{% if lang == 'fa' %}نام کلید (مثلا Claude Desktop){% else %}Key name (e.g. Claude Desktop){% endif %}"
|
||||
class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||
<button onclick="quickCreateKey()" id="quick-key-btn"
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white text-sm font-medium transition-colors whitespace-nowrap">
|
||||
{% if lang == 'fa' %}ایجاد کلید{% else %}Create Key{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{% if lang == 'fa' %}
|
||||
این کلید بهطور خودکار فقط برای این سرویس (<span class="font-mono">{{ site.alias }}</span>) فعال میشود.
|
||||
{% else %}
|
||||
This key will be auto-scoped to this service (<span class="font-mono">{{ site.alias }}</span>) only.
|
||||
{% endif %}
|
||||
</p>
|
||||
<div id="quick-key-result" class="hidden mt-3">
|
||||
<div class="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3 mb-2">
|
||||
<p class="text-xs text-yellow-600 dark:text-yellow-400">{% if lang == 'fa' %}این کلید فقط یکبار نمایش داده میشود!{% else %}This key is only shown once!{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" id="quick-key-value" readonly
|
||||
class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-sm font-mono text-gray-900 dark:text-white">
|
||||
<button onclick="copyText('quick-key-value', true)" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
|
||||
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const siteId = "{{ site.id }}";
|
||||
const siteAlias = "{{ site.alias }}";
|
||||
const pluginType = "{{ site.plugin_type }}";
|
||||
const pluginFields = {{ plugin_fields_json | safe }};
|
||||
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
|
||||
|
||||
// ── Section toggle ──────────────────────────────────────────────
|
||||
function toggleSection(name) {
|
||||
const section = document.getElementById(name + '-section');
|
||||
const chevron = document.getElementById(name + '-chevron');
|
||||
section.classList.toggle('hidden');
|
||||
chevron.classList.toggle('rotate-180');
|
||||
}
|
||||
|
||||
// ── Connection form ─────────────────────────────────────────────
|
||||
document.getElementById('edit-site-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submit-btn');
|
||||
const errorEl = document.getElementById('error-msg');
|
||||
errorEl.classList.add('hidden');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '{{ t.updating_site }}';
|
||||
|
||||
const url = document.getElementById('url').value;
|
||||
const creds = {};
|
||||
if (pluginFields[pluginType]) {
|
||||
pluginFields[pluginType].forEach(field => {
|
||||
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||
if (!input) return;
|
||||
const val = input.value.trim();
|
||||
if (val) creds[field.name] = val;
|
||||
else if (input.dataset.required === 'false') creds[field.name] = '';
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/sites/' + siteId, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, credentials: creds }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
btn.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved!{% endif %}';
|
||||
btn.className = btn.className.replace('bg-blue-600 hover:bg-blue-700', 'bg-green-600 hover:bg-green-700');
|
||||
setTimeout(() => {
|
||||
btn.textContent = '{{ t.save }}';
|
||||
btn.className = btn.className.replace('bg-green-600 hover:bg-green-700', 'bg-blue-600 hover:bg-blue-700');
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Failed to update site';
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.textContent = '{{ t.save }}';
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = 'Network error: ' + err.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.textContent = '{{ t.save }}';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function testConnection() {
|
||||
const btn = document.getElementById('test-btn');
|
||||
const status = document.getElementById('test-status');
|
||||
btn.disabled = true;
|
||||
status.textContent = '{% if lang == "fa" %}در حال تست...{% else %}Testing...{% endif %}';
|
||||
status.className = 'text-sm text-gray-400';
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/test', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
status.textContent = '{% if lang == "fa" %}متصل{% else %}Connected{% endif %}';
|
||||
status.className = 'text-sm text-green-500';
|
||||
} else {
|
||||
status.textContent = d.error || '{% if lang == "fa" %}خطا{% else %}Failed{% endif %}';
|
||||
status.className = 'text-sm text-red-500';
|
||||
}
|
||||
} catch (_) {
|
||||
status.textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
|
||||
status.className = 'text-sm text-red-500';
|
||||
}
|
||||
btn.disabled = false;
|
||||
setTimeout(() => { status.textContent = ''; }, 4000);
|
||||
}
|
||||
|
||||
// ── Tool Access ─────────────────────────────────────────────────
|
||||
let allTools = [];
|
||||
let currentScope = 'admin';
|
||||
|
||||
async function loadToolAccess() {
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tools');
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
allTools = data.tools || [];
|
||||
currentScope = data.tool_scope || 'admin';
|
||||
renderToolAccess();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function renderToolAccess() {
|
||||
document.getElementById('tool-access-loading').classList.add('hidden');
|
||||
document.getElementById('tool-access-content').classList.remove('hidden');
|
||||
|
||||
// Update scope buttons
|
||||
document.querySelectorAll('.scope-btn').forEach(btn => {
|
||||
const s = btn.dataset.scope;
|
||||
if (s === currentScope) {
|
||||
btn.className = 'scope-btn px-3 py-2.5 rounded-lg border-2 text-sm font-medium transition-all text-center border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400';
|
||||
} else {
|
||||
btn.className = 'scope-btn px-3 py-2.5 rounded-lg border-2 text-sm font-medium transition-all text-center border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-500';
|
||||
}
|
||||
});
|
||||
|
||||
// Summary — show in-scope tools count
|
||||
const inScopeTools = allTools.filter(t => isToolInScope(t));
|
||||
const enabled = inScopeTools.filter(t => t.enabled !== false).length;
|
||||
const scopeLabel = currentScope === 'custom' ? '' : ' [' + currentScope + ']';
|
||||
document.getElementById('tool-summary').textContent =
|
||||
'{% if lang == "fa" %}' + enabled + ' از ' + inScopeTools.length + ' ابزار فعال' + scopeLabel + '{% else %}' + enabled + ' of ' + inScopeTools.length + ' tools enabled' + scopeLabel + '{% endif %}';
|
||||
document.getElementById('tool-count').textContent = '(' + allTools.length + ')';
|
||||
|
||||
renderScopeNotice();
|
||||
renderToolList();
|
||||
}
|
||||
|
||||
// F.7d: Credential requirement — per plugin + scope, describes what
|
||||
// the Credential already saved in Connection Settings MUST grant on
|
||||
// the source service so the chosen Access Level is actually usable.
|
||||
// Phrased as a requirement on the existing credential, not an imperative
|
||||
// to create a new one.
|
||||
const CREDENTIAL_GUIDES = {
|
||||
wordpress: {
|
||||
read: {
|
||||
en: 'The Application Password saved in Connection Settings must belong to a WordPress user with at least <strong>Editor</strong> role (or lower). No CRUD capabilities required.',
|
||||
fa: 'Application Password ثبتشده در Connection Settings باید متعلق به کاربری با حداقل نقش <strong>Editor</strong> (یا پایینتر) باشد. نیاز به قابلیت CRUD نیست.'
|
||||
},
|
||||
admin: {
|
||||
en: 'The Application Password saved in Connection Settings must belong to a WordPress <strong>Administrator</strong> — required for full CRUD. SEO + plugin/theme tools additionally require the <code class="font-mono">Airano MCP SEO Bridge</code> plugin to be installed on the site.',
|
||||
fa: 'Application Password ثبتشده در Connection Settings باید متعلق به کاربر <strong>Administrator</strong> باشد تا CRUD کامل کار کند. ابزارهای SEO و مدیریت افزونه/قالب علاوه بر آن نیازمند نصب افزونه <code class="font-mono">Airano MCP SEO Bridge</code> روی سایت هستند.'
|
||||
}
|
||||
},
|
||||
wordpress_advanced: {
|
||||
read: {
|
||||
en: 'The Application Password in Connection Settings must belong to an <strong>Editor</strong>-or-lower user. Read-only WP-CLI operations are enforced by role caps.',
|
||||
fa: 'Application Password ثبتشده در Connection Settings باید برای کاربری با نقش <strong>Editor</strong> یا پایینتر باشد.'
|
||||
},
|
||||
admin: {
|
||||
en: 'The Application Password in Connection Settings must belong to an <strong>Administrator</strong> and the Docker container name must be correct for shell-level plugin/theme operations. Install <code class="font-mono">Airano MCP SEO Bridge</code> on the site to unlock SEO + plugin/theme tools.',
|
||||
fa: 'Application Password ثبتشده باید متعلق به کاربر <strong>Administrator</strong> باشد و نام Docker Container درست وارد شده باشد. برای ابزارهای SEO افزونه <code class="font-mono">Airano MCP SEO Bridge</code> روی سایت نصب باشد.'
|
||||
}
|
||||
},
|
||||
woocommerce: {
|
||||
read: {
|
||||
en: 'The REST API key (Consumer Key / Secret) saved in Connection Settings must have <strong>Read</strong> permission, and the creating WP user at least <em>Shop Manager</em> to see orders/customers.',
|
||||
fa: 'کلید REST API (Consumer Key / Secret) ثبتشده در Connection Settings باید سطح <strong>Read</strong> داشته باشد و کاربر سازنده حداقل نقش <em>Shop Manager</em> را داشته باشد.'
|
||||
},
|
||||
admin: {
|
||||
en: 'The REST API key in Connection Settings must have <strong>Read/Write</strong> permission and belong to an <em>Administrator</em> or <em>Shop Manager</em> — enables all 28 store-management tools. SEO tools additionally require the <code class="font-mono">Airano MCP SEO Bridge</code> plugin.',
|
||||
fa: 'کلید REST API ثبتشده باید سطح <strong>Read/Write</strong> داشته و متعلق به <em>Administrator</em> یا <em>Shop Manager</em> باشد تا همه ۲۸ ابزار فعال شوند. ابزارهای SEO نیازمند افزونه <code class="font-mono">Airano MCP SEO Bridge</code> هستند.'
|
||||
}
|
||||
},
|
||||
gitea: {
|
||||
read: {
|
||||
en: 'The Access Token in Connection Settings must have scopes <code class="font-mono">read:repository</code>, <code class="font-mono">read:issue</code>, <code class="font-mono">read:user</code>, <code class="font-mono">read:organization</code> (Gitea 1.19+ scoped tokens).',
|
||||
fa: 'توکن ثبتشده در Connection Settings باید اسکوپهای <code class="font-mono">read:repository</code>، <code class="font-mono">read:issue</code>، <code class="font-mono">read:user</code> و <code class="font-mono">read:organization</code> را داشته باشد.'
|
||||
},
|
||||
write: {
|
||||
en: 'The Access Token in Connection Settings must include the <code class="font-mono">read:*</code> scopes above <strong>plus</strong> <code class="font-mono">write:repository</code>, <code class="font-mono">write:issue</code>, <code class="font-mono">write:user</code>.',
|
||||
fa: 'توکن ثبتشده علاوه بر اسکوپهای <code class="font-mono">read:*</code> باید <code class="font-mono">write:repository</code>، <code class="font-mono">write:issue</code> و <code class="font-mono">write:user</code> را هم داشته باشد.'
|
||||
},
|
||||
admin: {
|
||||
en: 'The Access Token in Connection Settings must belong to a Gitea <strong>site admin</strong> and include <code class="font-mono">admin:repo_hook</code>, <code class="font-mono">admin:org_hook</code>, <code class="font-mono">admin:application</code> scopes.',
|
||||
fa: 'توکن ثبتشده باید متعلق به یک <strong>site admin</strong> در Gitea باشد و اسکوپهای <code class="font-mono">admin:repo_hook</code>، <code class="font-mono">admin:org_hook</code> و <code class="font-mono">admin:application</code> را داشته باشد.'
|
||||
}
|
||||
},
|
||||
openpanel: {
|
||||
read: {
|
||||
en: 'The Client ID / Secret in Connection Settings must belong to a client created in <strong>read</strong> mode — limited to Export + Insights on the current project.',
|
||||
fa: 'جفت Client ID / Secret ثبتشده در Connection Settings باید متعلق به Clientی با حالت <strong>read</strong> باشد (فقط Export و Insights در پروژه جاری).'
|
||||
},
|
||||
write: {
|
||||
en: 'The Client ID / Secret in Connection Settings must belong to a <strong>write</strong>-mode client (event tracking / ingestion only, no export).',
|
||||
fa: 'جفت Client ID / Secret ثبتشده باید متعلق به Clientی با حالت <strong>write</strong> باشد (فقط ارسال رویداد، بدون Export).'
|
||||
},
|
||||
admin: {
|
||||
en: 'The Client ID / Secret in Connection Settings must belong to a <strong>root</strong>-mode client — required for exporting any project, managing other projects, and full organisation access.',
|
||||
fa: 'جفت Client ID / Secret ثبتشده باید متعلق به Clientی در حالت <strong>root</strong> باشد (خروجی از همه پروژهها و مدیریت سازمان).'
|
||||
}
|
||||
},
|
||||
coolify: {
|
||||
read: {
|
||||
en: 'The API token saved in Connection Settings must have permission <code class="font-mono">read</code> — view resources only, no secrets.',
|
||||
fa: 'توکن API ثبتشده در Connection Settings باید مجوز <code class="font-mono">read</code> داشته باشد — فقط مشاهدهی منابع بدون اسرار.'
|
||||
},
|
||||
'read:sensitive': {
|
||||
en: 'The API token saved in Connection Settings must have permission <code class="font-mono">read:sensitive</code> — view resources <em>plus</em> env vars and backups.',
|
||||
fa: 'توکن API ثبتشده باید مجوز <code class="font-mono">read:sensitive</code> داشته باشد — شامل مشاهده متغیرهای محیطی و بکاپ.'
|
||||
},
|
||||
deploy: {
|
||||
en: 'The API token saved in Connection Settings must have <code class="font-mono">read</code> + <code class="font-mono">write</code> permissions (Coolify has no separate deploy permission — write enables start/stop/restart/deploy).',
|
||||
fa: 'توکن API ثبتشده باید مجوزهای <code class="font-mono">read</code> و <code class="font-mono">write</code> را داشته باشد (Coolify مجوز deploy جداگانه ندارد؛ write اجازه start/stop/restart/deploy میدهد).'
|
||||
},
|
||||
write: {
|
||||
en: 'The API token saved in Connection Settings must have <code class="font-mono">read</code> + <code class="font-mono">write</code> + <code class="font-mono">read:sensitive</code> for full CRUD + env management.',
|
||||
fa: 'توکن API ثبتشده باید <code class="font-mono">read</code> + <code class="font-mono">write</code> + <code class="font-mono">read:sensitive</code> داشته باشد تا CRUD کامل و مدیریت env کار کند.'
|
||||
},
|
||||
admin: {
|
||||
en: 'The API token saved in Connection Settings must have permission <code class="font-mono">root</code> — unrestricted, including system commands and team administration.',
|
||||
fa: 'توکن API ثبتشده باید مجوز <code class="font-mono">root</code> داشته باشد — دسترسی نامحدود شامل دستورات سیستمی و مدیریت تیم.'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function renderScopeNotice() {
|
||||
const el = document.getElementById('scope-notice');
|
||||
if (!el) return;
|
||||
const isFa = {% if lang == 'fa' %}true{% else %}false{% endif %};
|
||||
const scopeLabels = { read: 'Read', 'read:sensitive': 'Read+Secrets', deploy: 'Deploy', write: 'Write', admin: 'Full Access' };
|
||||
|
||||
if (currentScope === 'custom') {
|
||||
el.classList.add('hidden');
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const guide = (CREDENTIAL_GUIDES[pluginType] || {})[currentScope];
|
||||
if (!guide) {
|
||||
el.classList.add('hidden');
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const title = isFa
|
||||
? 'پیشنیاز Credential برای سطح <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:'
|
||||
: 'Credential requirement for <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:';
|
||||
const body = isFa ? guide.fa : guide.en;
|
||||
|
||||
el.innerHTML =
|
||||
'<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-lg p-3 text-indigo-700 dark:text-indigo-300">' +
|
||||
'<p class="mb-1">' + title + '</p>' +
|
||||
'<p>' + body + '</p>' +
|
||||
'</div>';
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function renderToolList() {
|
||||
const container = document.getElementById('tool-list');
|
||||
const search = (document.getElementById('tool-search').value || '').toLowerCase();
|
||||
container.innerHTML = '';
|
||||
|
||||
// Group by required_scope
|
||||
const groups = { read: [], write: [], admin: [] };
|
||||
for (const tool of allTools) {
|
||||
const s = tool.required_scope || 'read';
|
||||
if (!groups[s]) groups[s] = [];
|
||||
groups[s].push(tool);
|
||||
}
|
||||
|
||||
const scopeLabels = {
|
||||
read: '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}',
|
||||
write: '{% if lang == "fa" %}نوشتن{% else %}Write{% endif %}',
|
||||
admin: 'Admin',
|
||||
};
|
||||
|
||||
let visibleCount = 0;
|
||||
for (const scope of ['read', 'write', 'admin']) {
|
||||
const tools = (groups[scope] || []).filter(t => !search || t.name.toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search));
|
||||
if (!tools.length) continue;
|
||||
|
||||
const header = document.createElement('p');
|
||||
header.className = 'text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-1 mt-3 first:mt-0';
|
||||
header.textContent = scopeLabels[scope] || scope;
|
||||
container.appendChild(header);
|
||||
|
||||
for (const tool of tools) {
|
||||
container.appendChild(renderToolRow(tool));
|
||||
visibleCount++;
|
||||
}
|
||||
}
|
||||
if (visibleCount === 0) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-400 py-4 text-center">{% if lang == "fa" %}ابزاری یافت نشد{% else %}No tools found{% endif %}</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Universal scope tiers: maps scope → set of allowed required_scope values
|
||||
const SCOPE_TIERS = {
|
||||
read: new Set(['read']),
|
||||
write: new Set(['read', 'write']),
|
||||
admin: new Set(['read', 'write', 'admin']),
|
||||
custom: null, // no filter
|
||||
};
|
||||
|
||||
// F.7d: Coolify-style category overlay for fine-grained tiers.
|
||||
// Mirrors core.tool_access.SCOPE_TO_CATEGORIES.
|
||||
const SCOPE_CATEGORIES = {
|
||||
'read': new Set(['read']),
|
||||
'read:sensitive': new Set(['read', 'read_sensitive', 'backup']),
|
||||
'deploy': new Set(['read', 'lifecycle']),
|
||||
'write': new Set(['read', 'lifecycle', 'crud', 'env']),
|
||||
'admin': new Set(['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system']),
|
||||
};
|
||||
const KNOWN_CATEGORIES = new Set(['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system']);
|
||||
const CATEGORY_PLUGINS = new Set(['coolify']);
|
||||
|
||||
function isToolInScope(tool) {
|
||||
if (currentScope === 'custom') return true;
|
||||
// Coolify-style category match (only for plugins with category annotations).
|
||||
if (CATEGORY_PLUGINS.has(pluginType)) {
|
||||
const allowed = SCOPE_CATEGORIES[currentScope];
|
||||
if (!allowed) return true;
|
||||
// Tools without a known category are always visible.
|
||||
if (!KNOWN_CATEGORIES.has(tool.category)) return true;
|
||||
return allowed.has(tool.category);
|
||||
}
|
||||
// Universal 3-tier filter for everything else.
|
||||
const tier = SCOPE_TIERS[currentScope];
|
||||
if (!tier) return true;
|
||||
return tier.has(tool.required_scope || 'read');
|
||||
}
|
||||
|
||||
async function selectScope(scope) {
|
||||
currentScope = scope;
|
||||
renderToolAccess();
|
||||
const statusEl = document.getElementById('scope-status');
|
||||
statusEl.textContent = '{% if lang == "fa" %}ذخیره...{% else %}Saving...{% endif %}';
|
||||
statusEl.className = 'text-xs text-gray-400';
|
||||
statusEl.classList.remove('hidden');
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tool-scope', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scope }),
|
||||
});
|
||||
if (r.ok) {
|
||||
statusEl.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %}';
|
||||
statusEl.className = 'text-xs text-green-500';
|
||||
} else {
|
||||
const data = await r.json().catch(() => ({}));
|
||||
statusEl.textContent = data.error || '{% if lang == "fa" %}خطا{% else %}Error{% endif %}';
|
||||
statusEl.className = 'text-xs text-red-500';
|
||||
}
|
||||
} catch (_) {
|
||||
statusEl.textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
|
||||
statusEl.className = 'text-xs text-red-500';
|
||||
}
|
||||
setTimeout(() => statusEl.classList.add('hidden'), 2000);
|
||||
}
|
||||
|
||||
function renderToolRow(tool) {
|
||||
const inScope = isToolInScope(tool);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30' + (inScope ? '' : ' opacity-40');
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'flex items-center gap-2 flex-1 min-w-0';
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'text-sm truncate font-mono ' + (inScope ? 'text-gray-800 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500 line-through');
|
||||
nameEl.textContent = tool.name;
|
||||
nameEl.title = (tool.description || '') + (inScope ? '' : ' — {% if lang == "fa" %}خارج از سطح دسترسی{% else %}outside current scope{% endif %}');
|
||||
left.appendChild(nameEl);
|
||||
|
||||
if (!inScope) {
|
||||
const scopeBadge = document.createElement('span');
|
||||
scopeBadge.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500';
|
||||
scopeBadge.textContent = tool.required_scope || 'read';
|
||||
left.appendChild(scopeBadge);
|
||||
}
|
||||
|
||||
if (tool.sensitivity === 'sensitive') {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400';
|
||||
badge.textContent = '{% if lang == "fa" %}حساس{% else %}sensitive{% endif %}';
|
||||
left.appendChild(badge);
|
||||
}
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'relative inline-flex items-center cursor-pointer flex-shrink-0';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.className = 'sr-only peer';
|
||||
input.checked = tool.enabled !== false;
|
||||
input.onchange = async () => {
|
||||
const enabled = input.checked;
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!r.ok) input.checked = !enabled;
|
||||
else {
|
||||
tool.enabled = enabled;
|
||||
const en = allTools.filter(t => t.enabled !== false).length;
|
||||
document.getElementById('tool-summary').textContent =
|
||||
'{% if lang == "fa" %}' + en + ' از ' + allTools.length + ' ابزار فعال{% else %}' + en + ' of ' + allTools.length + ' tools enabled{% endif %}';
|
||||
}
|
||||
} catch (_) { input.checked = !enabled; }
|
||||
};
|
||||
const slider = document.createElement('div');
|
||||
slider.className = 'w-9 h-5 bg-gray-300 dark:bg-gray-600 peer-checked:bg-blue-600 rounded-full peer peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 transition-colors after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4';
|
||||
label.appendChild(input);
|
||||
label.appendChild(slider);
|
||||
|
||||
row.appendChild(left);
|
||||
row.appendChild(label);
|
||||
return row;
|
||||
}
|
||||
|
||||
// Tool search
|
||||
document.getElementById('tool-search').addEventListener('input', renderToolList);
|
||||
|
||||
// ── Config Snippets ─────────────────────────────────────────────
|
||||
async function updateConfig() {
|
||||
const client = document.getElementById('config-client')?.value;
|
||||
if (!client) return;
|
||||
const isWeb = WEB_CLIENTS.includes(client);
|
||||
document.getElementById('transport-note').style.display = isWeb ? 'none' : '';
|
||||
try {
|
||||
const r = await fetch('/api/config/' + siteAlias + '?client=' + client);
|
||||
const data = await r.json();
|
||||
document.getElementById('config-output').textContent = r.ok ? data.config : '{% if lang == "fa" %}خطا{% else %}Error{% endif %}';
|
||||
} catch (_) {
|
||||
document.getElementById('config-output').textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
|
||||
}
|
||||
}
|
||||
|
||||
function copyConfig() {
|
||||
const text = document.getElementById('config-output').textContent;
|
||||
navigator.clipboard.writeText(text);
|
||||
const btn = document.getElementById('copy-config-btn');
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '{% if lang == "fa" %}کپی شد!{% else %}Copied!{% endif %}';
|
||||
setTimeout(() => btn.textContent = orig, 2000);
|
||||
}
|
||||
|
||||
function copyText(elementId, isInput) {
|
||||
const el = document.getElementById(elementId);
|
||||
const text = isInput ? el.value : el.textContent.trim();
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
// ── Quick Key Create ────────────────────────────────────────────
|
||||
async function quickCreateKey() {
|
||||
const name = document.getElementById('quick-key-name').value.trim();
|
||||
if (!name) { document.getElementById('quick-key-name').focus(); return; }
|
||||
const btn = document.getElementById('quick-key-btn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
// F.7d: Connect-section keys are auto-scoped to this site
|
||||
const r = await fetch('/api/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, scopes: 'read write admin', site_id: siteId }),
|
||||
});
|
||||
const res = await r.json();
|
||||
if (r.ok) {
|
||||
document.getElementById('quick-key-value').value = res.key.key;
|
||||
document.getElementById('quick-key-result').classList.remove('hidden');
|
||||
btn.textContent = '{% if lang == "fa" %}ایجاد شد{% else %}Created!{% endif %}';
|
||||
} else {
|
||||
alert(res.error || 'Failed');
|
||||
}
|
||||
} catch (e) { alert('Network error'); }
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadToolAccess();
|
||||
updateConfig();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
3
core/templates/static/src/input.css
Normal file
3
core/templates/static/src/input.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
1
core/templates/static/tailwind.min.css
vendored
Normal file
1
core/templates/static/tailwind.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,23 +1,17 @@
|
||||
"""Tool access manager — site-scoped visibility and per-site toggles (F.7b).
|
||||
"""Tool access manager — site-scoped visibility and per-site toggles (F.7c).
|
||||
|
||||
Provides a central pipeline that filters the set of MCP tools presented for
|
||||
a user endpoint based on:
|
||||
|
||||
1. **Scope → category mapping.** Every ``ToolDefinition`` carries a
|
||||
``category`` field (e.g. ``read``, ``lifecycle``, ``crud``, ``system``).
|
||||
An API key's declared scopes **and** the site's stored ``tool_scope``
|
||||
preset each map to a set of allowed categories via
|
||||
:data:`SCOPE_TO_CATEGORIES`. A tool is visible only if its category is in
|
||||
the intersection — the narrower of the two layers wins.
|
||||
1. **Universal scope tiers.** A 3-level system (``read`` / ``write`` /
|
||||
``admin``) that works across ALL plugins using the tool's
|
||||
``required_scope`` field. For Coolify (which has fine-grained
|
||||
``category`` annotations) the legacy category mapping is kept as an
|
||||
overlay for the ``custom`` preset.
|
||||
2. **Per-site tool toggles.** Site owners may explicitly disable specific
|
||||
tools via the ``site_tool_toggles`` table. Only overrides are stored —
|
||||
tools via the ``site_tool_toggles`` table. Only overrides are stored —
|
||||
tools without an entry are enabled by default.
|
||||
|
||||
Tools whose ``category`` is not in :data:`KNOWN_CATEGORIES` are **always
|
||||
visible** (backward compatibility — legacy plugins that have not been
|
||||
annotated yet default to ``category="read"``, which belongs to the ``read``
|
||||
scope set anyway, but an unknown value would be preserved).
|
||||
|
||||
The ``tool_scope`` value ``"custom"`` is a sentinel meaning "do not apply a
|
||||
site-level preset filter" — in that case only the per-tool toggles and the
|
||||
key scope are considered.
|
||||
@@ -44,9 +38,17 @@ from core.tool_registry import ToolDefinition
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Universal 3-tier scope system (F.7c) ─────────────────────────────
|
||||
# Maps a scope tier to the set of ``required_scope`` values it may access.
|
||||
# Works for ALL plugins because every tool has ``required_scope``.
|
||||
UNIVERSAL_SCOPE_TIERS: dict[str, set[str]] = {
|
||||
"read": {"read"},
|
||||
"write": {"read", "write"},
|
||||
"admin": {"read", "write", "admin"},
|
||||
}
|
||||
|
||||
# ── Legacy Coolify category mapping (kept for ``custom`` overlay) ─────
|
||||
# Mapping from scope → set of tool categories that scope may see.
|
||||
# Used for BOTH API-key scopes and per-site ``tool_scope`` presets.
|
||||
# Scopes are additive: presenting multiple scopes yields the union.
|
||||
SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
|
||||
"read": {"read"},
|
||||
"read:sensitive": {"read", "read_sensitive", "backup"},
|
||||
@@ -63,8 +65,7 @@ SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
|
||||
},
|
||||
}
|
||||
|
||||
# All known categories — any tool whose category is outside this set is
|
||||
# treated as "always visible" for backward compatibility.
|
||||
# All known Coolify categories.
|
||||
KNOWN_CATEGORIES: set[str] = {
|
||||
"read",
|
||||
"read_sensitive",
|
||||
@@ -78,6 +79,9 @@ KNOWN_CATEGORIES: set[str] = {
|
||||
# Sentinel meaning "no site-level preset filter — use per-tool toggles only".
|
||||
SCOPE_CUSTOM = "custom"
|
||||
|
||||
# Plugins that have fine-grained category annotations.
|
||||
_CATEGORY_PLUGINS: set[str] = {"coolify"}
|
||||
|
||||
|
||||
def scopes_to_categories(scopes: list[str]) -> set[str]:
|
||||
"""Return the union of categories allowed by the given scope list.
|
||||
@@ -94,6 +98,197 @@ def scopes_to_categories(scopes: list[str]) -> set[str]:
|
||||
return allowed
|
||||
|
||||
|
||||
def _scopes_to_required(scopes: list[str]) -> set[str]:
|
||||
"""Return the union of ``required_scope`` values allowed by universal tiers."""
|
||||
allowed: set[str] = set()
|
||||
for scope in scopes:
|
||||
allowed |= UNIVERSAL_SCOPE_TIERS.get(scope.strip(), set())
|
||||
return allowed
|
||||
|
||||
|
||||
def get_scope_presets_for_plugin(plugin_type: str) -> list[dict[str, str]]:
|
||||
"""Return the appropriate scope presets for a plugin type (F.7d).
|
||||
|
||||
Each preset is a dict with ``value`` (the canonical scope key persisted
|
||||
to ``sites.tool_scope``), ``label`` / ``label_fa`` (button title), and
|
||||
``hint`` / ``hint_fa`` (one-line description shown under the title).
|
||||
|
||||
The valid scope values are constrained by ``_VALID_TOOL_SCOPES`` in
|
||||
``core.dashboard.routes`` — currently:
|
||||
``{"read", "read:sensitive", "deploy", "write", "admin", "custom"}``.
|
||||
"""
|
||||
custom = {
|
||||
"value": "custom",
|
||||
"label": "Custom",
|
||||
"label_fa": "سفارشی",
|
||||
"hint": "Per-tool toggles",
|
||||
"hint_fa": "هر ابزار جداگانه",
|
||||
}
|
||||
|
||||
if plugin_type == "coolify":
|
||||
# 5 fine-grained Coolify tiers + custom (matches SCOPE_TO_CATEGORIES).
|
||||
return [
|
||||
{
|
||||
"value": "read",
|
||||
"label": "Read",
|
||||
"label_fa": "خواندن",
|
||||
"hint": "List/inspect resources",
|
||||
"hint_fa": "مشاهده و فهرست منابع",
|
||||
},
|
||||
{
|
||||
"value": "read:sensitive",
|
||||
"label": "Read + Secrets",
|
||||
"label_fa": "خواندن + اسرار",
|
||||
"hint": "Includes env vars and backups",
|
||||
"hint_fa": "شامل متغیرهای محیطی و بکاپ",
|
||||
},
|
||||
{
|
||||
"value": "deploy",
|
||||
"label": "Deploy",
|
||||
"label_fa": "استقرار",
|
||||
"hint": "Read + lifecycle (start/stop/restart)",
|
||||
"hint_fa": "مشاهده + راهاندازی/توقف/ریستارت",
|
||||
},
|
||||
{
|
||||
"value": "write",
|
||||
"label": "Write",
|
||||
"label_fa": "نوشتن",
|
||||
"hint": "Read + lifecycle + CRUD + env",
|
||||
"hint_fa": "مشاهده + لایفسایکل + CRUD + env",
|
||||
},
|
||||
{
|
||||
"value": "admin",
|
||||
"label": "Root",
|
||||
"label_fa": "روت",
|
||||
"hint": "Everything including system commands",
|
||||
"hint_fa": "همه چیز شامل دستورات سیستم",
|
||||
},
|
||||
custom,
|
||||
]
|
||||
|
||||
if plugin_type == "openpanel":
|
||||
return [
|
||||
{
|
||||
"value": "read",
|
||||
"label": "Read",
|
||||
"label_fa": "خواندن",
|
||||
"hint": "Export current project only",
|
||||
"hint_fa": "خروجی پروژه فعلی",
|
||||
},
|
||||
{
|
||||
"value": "write",
|
||||
"label": "Write",
|
||||
"label_fa": "نوشتن",
|
||||
"hint": "Default ingestion (track events)",
|
||||
"hint_fa": "ارسال رویداد (ingestion)",
|
||||
},
|
||||
{
|
||||
"value": "admin",
|
||||
"label": "Root",
|
||||
"label_fa": "روت",
|
||||
"hint": "Export any project",
|
||||
"hint_fa": "خروجی هر پروژه",
|
||||
},
|
||||
custom,
|
||||
]
|
||||
|
||||
if plugin_type == "woocommerce":
|
||||
# WooCommerce has no admin-scope tools (read=14, write=14, admin=0),
|
||||
# so Write and "Read + Write" tiers are identical. Present a single
|
||||
# full-access tier instead of two duplicates.
|
||||
return [
|
||||
{
|
||||
"value": "read",
|
||||
"label": "Read Only",
|
||||
"label_fa": "فقط خواندن",
|
||||
"hint": "Browse products, orders, customers",
|
||||
"hint_fa": "مشاهده محصولات، سفارشها و مشتریان",
|
||||
},
|
||||
{
|
||||
"value": "admin",
|
||||
"label": "Read + Write",
|
||||
"label_fa": "خواندن + نوشتن",
|
||||
"hint": "Full store management (all 28 tools)",
|
||||
"hint_fa": "مدیریت کامل فروشگاه (همه ۲۸ ابزار)",
|
||||
},
|
||||
custom,
|
||||
]
|
||||
|
||||
if plugin_type in {"wordpress", "wordpress_advanced"}:
|
||||
# WordPress has no admin-scope tools (read=27, write=40, admin=0).
|
||||
# SEO + plugin/theme tools require the Airano MCP SEO Bridge plugin
|
||||
# to be installed on the WP site itself. Present 2 tiers + custom.
|
||||
return [
|
||||
{
|
||||
"value": "read",
|
||||
"label": "Read Only",
|
||||
"label_fa": "فقط خواندن",
|
||||
"hint": "View posts, pages, media",
|
||||
"hint_fa": "مشاهده نوشتهها، صفحات و رسانه",
|
||||
},
|
||||
{
|
||||
"value": "admin",
|
||||
"label": "Full Access",
|
||||
"label_fa": "دسترسی کامل",
|
||||
"hint": "All tools (CRUD + SEO via add-on)",
|
||||
"hint_fa": "همه ابزارها (CRUD و SEO با افزونه)",
|
||||
},
|
||||
custom,
|
||||
]
|
||||
|
||||
if plugin_type == "gitea":
|
||||
return [
|
||||
{
|
||||
"value": "read",
|
||||
"label": "Read",
|
||||
"label_fa": "خواندن",
|
||||
"hint": "Browse repos, issues, users",
|
||||
"hint_fa": "مشاهده مخازن، ایشوها، کاربران",
|
||||
},
|
||||
{
|
||||
"value": "write",
|
||||
"label": "Read + Write",
|
||||
"label_fa": "خواندن + نوشتن",
|
||||
"hint": "Create issues, PRs, branches",
|
||||
"hint_fa": "ایجاد ایشو، PR و شاخه",
|
||||
},
|
||||
{
|
||||
"value": "admin",
|
||||
"label": "Admin",
|
||||
"label_fa": "مدیر",
|
||||
"hint": "Repo + org + user admin",
|
||||
"hint_fa": "مدیریت مخزن، سازمان و کاربر",
|
||||
},
|
||||
custom,
|
||||
]
|
||||
|
||||
# Universal default for all other plugins.
|
||||
return [
|
||||
{
|
||||
"value": "read",
|
||||
"label": "Read",
|
||||
"label_fa": "فقط خواندن",
|
||||
"hint": "View only",
|
||||
"hint_fa": "فقط مشاهده",
|
||||
},
|
||||
{
|
||||
"value": "write",
|
||||
"label": "Read + Write",
|
||||
"label_fa": "خواندن + نوشتن",
|
||||
"hint": "CRUD ops",
|
||||
"hint_fa": "عملیات CRUD",
|
||||
},
|
||||
{
|
||||
"value": "admin",
|
||||
"label": "Full Access",
|
||||
"label_fa": "دسترسی کامل",
|
||||
"hint": "All tools",
|
||||
"hint_fa": "همه ابزارها",
|
||||
},
|
||||
custom,
|
||||
]
|
||||
|
||||
|
||||
class ToolAccessManager:
|
||||
"""Central manager for scope-based visibility and per-site tool toggles."""
|
||||
|
||||
@@ -101,24 +296,35 @@ class ToolAccessManager:
|
||||
self,
|
||||
tools: list[ToolDefinition],
|
||||
scopes: list[str],
|
||||
plugin_type: str | None = None,
|
||||
) -> list[ToolDefinition]:
|
||||
"""Drop tools whose category is not allowed by the presented scopes.
|
||||
"""Drop tools not allowed by the presented scopes.
|
||||
|
||||
Tools with an unknown category (e.g. legacy plugins not yet annotated)
|
||||
are always kept — backward compatibility.
|
||||
For plugins with category annotations (Coolify) the legacy
|
||||
category-based filter is used. For all other plugins the universal
|
||||
3-tier filter based on ``required_scope`` is applied.
|
||||
|
||||
Args:
|
||||
tools: Candidate tool list.
|
||||
scopes: Scopes presented on the API key (or a single-element list
|
||||
containing a site's ``tool_scope`` preset).
|
||||
plugin_type: Plugin type hint. When provided and the plugin is
|
||||
NOT in ``_CATEGORY_PLUGINS``, the universal tier filter is
|
||||
used.
|
||||
|
||||
Returns:
|
||||
Filtered tool list.
|
||||
"""
|
||||
# Try universal tiers first (works for all plugins)
|
||||
allowed_scopes = _scopes_to_required(scopes)
|
||||
|
||||
if allowed_scopes and (plugin_type is None or plugin_type not in _CATEGORY_PLUGINS):
|
||||
# Universal filter: match tool.required_scope against allowed tiers
|
||||
return [t for t in tools if t.required_scope in allowed_scopes]
|
||||
|
||||
# Fallback: legacy category-based filter for Coolify / custom scopes
|
||||
allowed = scopes_to_categories(scopes)
|
||||
if not allowed:
|
||||
# No recognised scopes — preserve legacy behaviour and return
|
||||
# only tools with unknown categories.
|
||||
return [t for t in tools if t.category not in KNOWN_CATEGORIES]
|
||||
|
||||
result: list[ToolDefinition] = []
|
||||
@@ -185,7 +391,7 @@ class ToolAccessManager:
|
||||
registry = get_tool_registry()
|
||||
tools = registry.get_by_plugin_type(plugin_type)
|
||||
|
||||
tools = self.apply_scope_filter(tools, key_scopes)
|
||||
tools = self.apply_scope_filter(tools, key_scopes, plugin_type=plugin_type)
|
||||
|
||||
try:
|
||||
db = get_database()
|
||||
@@ -194,7 +400,7 @@ class ToolAccessManager:
|
||||
site_scope = "admin"
|
||||
|
||||
if site_scope and site_scope != SCOPE_CUSTOM:
|
||||
tools = self.apply_scope_filter(tools, [site_scope])
|
||||
tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type)
|
||||
|
||||
tools = await self.apply_site_toggles(tools, site_id)
|
||||
return tools
|
||||
|
||||
@@ -240,6 +240,28 @@ async def user_mcp_handler(request: Request) -> Response:
|
||||
_jsonrpc_error(None, -32600, "API key does not match user"),
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# Check site-scoped key: if key is scoped to a site, it can only
|
||||
# be used for that specific site's alias.
|
||||
key_site_id = key_info.get("site_id")
|
||||
if key_site_id:
|
||||
try:
|
||||
from core.database import get_database
|
||||
|
||||
db = get_database()
|
||||
site = await db.get_site(key_site_id, user_id)
|
||||
if site is None or site.get("alias") != alias:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(
|
||||
None,
|
||||
-32600,
|
||||
"API key is scoped to a different site",
|
||||
),
|
||||
status_code=403,
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # DB unavailable — allow through
|
||||
|
||||
key_scopes = key_info.get("scopes", "read").split()
|
||||
else:
|
||||
# Try OAuth JWT token (issued after consent flow via GitHub/Google login)
|
||||
|
||||
@@ -49,8 +49,8 @@ class UserKeyManager:
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Cache: raw_key -> (key_id, user_id, scopes, cached_at)
|
||||
self._cache: dict[str, tuple[str, str, str, float]] = {}
|
||||
# Cache: raw_key -> (key_id, user_id, scopes, site_id, cached_at)
|
||||
self._cache: dict[str, tuple[str, str, str, str | None, float]] = {}
|
||||
|
||||
async def create_key(
|
||||
self,
|
||||
@@ -58,6 +58,7 @@ class UserKeyManager:
|
||||
name: str,
|
||||
scopes: str = "read write admin",
|
||||
expires_in_days: int | None = None,
|
||||
site_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new API key for a user.
|
||||
|
||||
@@ -66,10 +67,11 @@ class UserKeyManager:
|
||||
name: Human label (e.g. "Claude Desktop").
|
||||
scopes: Access scopes (default: "read write admin" for full access).
|
||||
expires_in_days: Optional expiry in days from now. None = never.
|
||||
site_id: Optional site UUID to scope key to a single site.
|
||||
|
||||
Returns:
|
||||
Dict with ``key`` (plaintext, shown once), ``key_id``, ``name``,
|
||||
``scopes``, ``created_at``, ``expires_at``.
|
||||
``scopes``, ``created_at``, ``expires_at``, ``site_id``.
|
||||
"""
|
||||
from core.database import get_database
|
||||
|
||||
@@ -89,9 +91,10 @@ class UserKeyManager:
|
||||
name=name,
|
||||
scopes=scopes,
|
||||
expires_at=expires_at,
|
||||
site_id=site_id,
|
||||
)
|
||||
|
||||
logger.info("Created user API key %s for user %s", row["id"], user_id)
|
||||
logger.info("Created user API key %s for user %s (site=%s)", row["id"], user_id, site_id)
|
||||
return {
|
||||
"key": raw_key, # shown once
|
||||
"key_id": row["id"],
|
||||
@@ -99,6 +102,7 @@ class UserKeyManager:
|
||||
"scopes": row["scopes"],
|
||||
"created_at": row["created_at"],
|
||||
"expires_at": row["expires_at"],
|
||||
"site_id": row.get("site_id"),
|
||||
}
|
||||
|
||||
async def validate_key(self, api_key: str) -> dict[str, Any] | None:
|
||||
@@ -118,7 +122,7 @@ class UserKeyManager:
|
||||
# Check cache first
|
||||
cached = self._cache.get(api_key)
|
||||
if cached is not None:
|
||||
key_id, user_id, scopes, cached_at = cached
|
||||
key_id, user_id, scopes, site_id, cached_at = cached
|
||||
if time.time() - cached_at < _CACHE_TTL_SECONDS:
|
||||
# Update usage in background (fire-and-forget via DB)
|
||||
try:
|
||||
@@ -130,7 +134,7 @@ class UserKeyManager:
|
||||
asyncio.create_task(db.update_api_key_usage(key_id))
|
||||
except Exception:
|
||||
pass # Non-critical
|
||||
return {"key_id": key_id, "user_id": user_id, "scopes": scopes}
|
||||
return {"key_id": key_id, "user_id": user_id, "scopes": scopes, "site_id": site_id}
|
||||
else:
|
||||
del self._cache[api_key]
|
||||
|
||||
@@ -159,10 +163,12 @@ class UserKeyManager:
|
||||
await db.update_api_key_usage(row["id"])
|
||||
|
||||
# Cache the result
|
||||
site_id = row.get("site_id")
|
||||
self._cache[api_key] = (
|
||||
row["id"],
|
||||
row["user_id"],
|
||||
row["scopes"],
|
||||
site_id,
|
||||
time.time(),
|
||||
)
|
||||
|
||||
@@ -170,6 +176,7 @@ class UserKeyManager:
|
||||
"key_id": row["id"],
|
||||
"user_id": row["user_id"],
|
||||
"scopes": row["scopes"],
|
||||
"site_id": site_id,
|
||||
}
|
||||
|
||||
async def list_keys(self, user_id: str) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mcphub-server"
|
||||
version = "3.9.0"
|
||||
version = "3.11.0"
|
||||
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
|
||||
authors = [
|
||||
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
||||
|
||||
9
scripts/build-css.sh
Executable file
9
scripts/build-css.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build Tailwind CSS from templates (requires bun or npx)
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
bunx tailwindcss@3 \
|
||||
-i core/templates/static/src/input.css \
|
||||
-o core/templates/static/tailwind.min.css \
|
||||
--minify
|
||||
echo "Built core/templates/static/tailwind.min.css"
|
||||
11
scripts/dev-css.sh
Executable file
11
scripts/dev-css.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Watch templates and rebuild Tailwind CSS on changes (dev mode).
|
||||
# Stops with Ctrl+C. Requires bun (bunx).
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
echo "Watching core/templates/**/*.html for Tailwind changes..."
|
||||
bunx tailwindcss@3 \
|
||||
-i core/templates/static/src/input.css \
|
||||
-o core/templates/static/tailwind.min.css \
|
||||
--minify \
|
||||
--watch
|
||||
29
tailwind.config.js
Normal file
29
tailwind.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./core/templates/**/*.html',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'vazirmatn': ['Vazirmatn', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f5f3ff',
|
||||
100: '#ede9fe',
|
||||
200: '#ddd6fe',
|
||||
300: '#c4b5fd',
|
||||
400: '#a78bfa',
|
||||
500: '#8b5cf6',
|
||||
600: '#7c3aed',
|
||||
700: '#6d28d9',
|
||||
800: '#5b21b6',
|
||||
900: '#4c1d95',
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
143
tests/test_csrf_interceptor.py
Normal file
143
tests/test_csrf_interceptor.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Regression tests for the global CSRF interceptor (F.7c).
|
||||
|
||||
Background: previously, dashboard pages defined a local ``getCsrf()`` JS helper
|
||||
that read the dashboard CSRF cookie via ``document.cookie``. The cookie is
|
||||
``HttpOnly``, so JS could never read it and every non-GET fetch sent an empty
|
||||
``X-CSRF-Token`` header — leading to 403 errors when toggling tools or
|
||||
changing the access scope.
|
||||
|
||||
The fix moved CSRF handling into a single global interceptor in
|
||||
``head_assets.html`` that reads from a ``<meta name="csrf-token">`` tag rendered
|
||||
server-side in ``base.html``. This file pins down the contract so the bug
|
||||
cannot silently regress:
|
||||
|
||||
1. Every dashboard page renders a non-empty ``<meta name="csrf-token">`` tag.
|
||||
2. The global ``htmx:configRequest`` + ``window.fetch`` interceptor is
|
||||
present in ``head_assets.html``.
|
||||
3. No dashboard template defines its own ``getCsrf()`` helper or sets an
|
||||
explicit ``x-csrf-token`` header from JS — those would short-circuit the
|
||||
global interceptor again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "core" / "templates"
|
||||
DASHBOARD_DIR = TEMPLATES_DIR / "dashboard"
|
||||
|
||||
|
||||
# ── Static template checks ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestStaticTemplateContract:
|
||||
"""Lint-style checks that don't require a running app."""
|
||||
|
||||
def test_head_assets_has_global_csrf_interceptor(self):
|
||||
head = (DASHBOARD_DIR / "partials" / "head_assets.html").read_text()
|
||||
# The HTMX hook
|
||||
assert "htmx:configRequest" in head, "missing global HTMX CSRF hook"
|
||||
# The fetch monkey-patch
|
||||
assert "window.fetch" in head, "missing global fetch CSRF hook"
|
||||
# Both must read from the meta tag — not the cookie
|
||||
assert 'meta[name="csrf-token"]' in head, "interceptor must read from meta tag"
|
||||
# Sanity: must set the X-CSRF-Token header
|
||||
assert "X-CSRF-Token" in head
|
||||
|
||||
def test_base_template_renders_csrf_meta_tag(self):
|
||||
base = (DASHBOARD_DIR / "base.html").read_text()
|
||||
assert '<meta name="csrf-token"' in base
|
||||
assert (
|
||||
"request.state.csrf_token" in base
|
||||
), "CSRF meta must be populated from request.state.csrf_token"
|
||||
|
||||
def test_no_dashboard_template_defines_local_getcsrf(self):
|
||||
"""Local getCsrf() helpers reintroduce the httponly cookie bug."""
|
||||
offenders = []
|
||||
for path in DASHBOARD_DIR.rglob("*.html"):
|
||||
text = path.read_text()
|
||||
if "function getCsrf" in text or "const getCsrf =" in text:
|
||||
offenders.append(str(path.relative_to(DASHBOARD_DIR)))
|
||||
assert not offenders, (
|
||||
f"templates must rely on the global CSRF interceptor — "
|
||||
f"found local getCsrf() in: {offenders}"
|
||||
)
|
||||
|
||||
def test_no_dashboard_template_sets_explicit_csrf_header(self):
|
||||
"""Explicit ``x-csrf-token`` headers from JS skip the global interceptor.
|
||||
|
||||
Allowed exception: head_assets.html, which IS the interceptor.
|
||||
"""
|
||||
offenders = []
|
||||
for path in DASHBOARD_DIR.rglob("*.html"):
|
||||
if path.name == "head_assets.html":
|
||||
continue
|
||||
text = path.read_text().lower()
|
||||
if "'x-csrf-token'" in text or '"x-csrf-token"' in text:
|
||||
offenders.append(str(path.relative_to(DASHBOARD_DIR)))
|
||||
assert not offenders, (
|
||||
f"templates must not set X-CSRF-Token explicitly — let the "
|
||||
f"global interceptor handle it. Offenders: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
# ── Live render check ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def patched_db(tmp_path, monkeypatch):
|
||||
database = Database(str(tmp_path / "csrf.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="csrf@example.com",
|
||||
name="csrfuser",
|
||||
provider="github",
|
||||
provider_id="gh-csrf-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)
|
||||
|
||||
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()
|
||||
return TestClient(app, follow_redirects=False)
|
||||
|
||||
|
||||
class TestRenderedCsrfMeta:
|
||||
def test_keys_page_has_non_empty_csrf_meta(self, user_client):
|
||||
r = user_client.get("/dashboard/keys")
|
||||
assert r.status_code == 200
|
||||
# The meta tag must be present *and* populated with a real token.
|
||||
assert '<meta name="csrf-token"' in r.text
|
||||
# An empty content="" would re-introduce the bug.
|
||||
assert 'content=""' not in r.text.split('<meta name="csrf-token"', 1)[1].split(">", 1)[0]
|
||||
@@ -69,11 +69,11 @@ class TestUnifiedKeysUserView:
|
||||
assert r.status_code == 301
|
||||
assert "/dashboard/keys" in r.headers["location"]
|
||||
|
||||
def test_user_view_has_scope_selector(self, user_client):
|
||||
def test_user_view_has_full_access_badge(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
|
||||
# F.7c: No scope selector — shows "Full Access" badge instead
|
||||
assert "Full Access" in r.text or "دسترسی کامل" in r.text
|
||||
|
||||
def test_user_view_shows_create_button(self, user_client):
|
||||
r = user_client.get("/dashboard/keys")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Smoke tests for sites edit page Tool Access section and sites view page (F.7b session 2)."""
|
||||
"""Smoke tests for unified site management page (F.7c redesign)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -73,38 +73,50 @@ def client(monkeypatch, user_row, patched_db, patched_access):
|
||||
return tc
|
||||
|
||||
|
||||
class TestSitesEditToolAccess:
|
||||
def test_edit_page_renders_without_500(self, client, coolify_site):
|
||||
class TestSitesEditRedirect:
|
||||
def test_edit_page_redirects_to_manage(self, client, coolify_site):
|
||||
"""F.7c: /sites/{id}/edit now redirects to unified /sites/{id}."""
|
||||
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
|
||||
assert r.status_code == 301
|
||||
assert f"/dashboard/sites/{coolify_site['id']}" in r.headers["location"]
|
||||
|
||||
|
||||
class TestSitesViewPage:
|
||||
def test_view_page_renders_without_500(self, client, coolify_site):
|
||||
class TestUnifiedSiteManagePage:
|
||||
def test_manage_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):
|
||||
def test_manage_page_has_connection_section(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "connection-section" in r.text
|
||||
|
||||
def test_manage_page_has_tool_access_section(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "tool-access-content" in r.text or "Tool Access" in r.text
|
||||
|
||||
def test_manage_page_has_scope_tiers(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "scope-tiers" in r.text
|
||||
|
||||
def test_manage_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):
|
||||
def test_manage_page_has_config_snippets(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_manage_page_has_quick_key_create(self, client, coolify_site):
|
||||
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
|
||||
assert r.status_code == 200
|
||||
assert "quick-key-btn" in r.text
|
||||
|
||||
def test_nonexistent_site_redirects(self, client):
|
||||
r = client.get("/dashboard/sites/nonexistent-id")
|
||||
assert r.status_code in (302, 303)
|
||||
|
||||
@@ -181,24 +181,41 @@ class TestScopesToCategories:
|
||||
|
||||
|
||||
class TestScopeFilter:
|
||||
"""F.7c: Coolify uses legacy category filter (plugin_type='coolify'),
|
||||
other plugins use universal required_scope filter."""
|
||||
|
||||
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"])}
|
||||
# Coolify tools filtered by category
|
||||
coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"]
|
||||
out = {
|
||||
t.name
|
||||
for t in access_mgr.apply_scope_filter(coolify_tools, ["read"], plugin_type="coolify")
|
||||
}
|
||||
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"])}
|
||||
coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"]
|
||||
out = {
|
||||
t.name
|
||||
for t in access_mgr.apply_scope_filter(
|
||||
coolify_tools, ["read:sensitive"], plugin_type="coolify"
|
||||
)
|
||||
}
|
||||
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"])}
|
||||
coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"]
|
||||
out = {
|
||||
t.name
|
||||
for t in access_mgr.apply_scope_filter(coolify_tools, ["deploy"], plugin_type="coolify")
|
||||
}
|
||||
assert "coolify_start_application" in out
|
||||
assert "coolify_stop_application" in out
|
||||
assert "coolify_list_applications" in out
|
||||
@@ -206,7 +223,11 @@ class TestScopeFilter:
|
||||
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"])}
|
||||
coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"]
|
||||
out = {
|
||||
t.name
|
||||
for t in access_mgr.apply_scope_filter(coolify_tools, ["write"], plugin_type="coolify")
|
||||
}
|
||||
assert "coolify_create_application_public" in out
|
||||
assert "coolify_start_application" in out
|
||||
assert "coolify_create_application_env" in out
|
||||
@@ -214,9 +235,22 @@ class TestScopeFilter:
|
||||
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"])}
|
||||
out = {
|
||||
t.name
|
||||
for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["admin"], plugin_type="coolify")
|
||||
}
|
||||
assert out == {t.name for t in _SAMPLE_TOOLS}
|
||||
|
||||
def test_universal_read_filters_by_required_scope(self, access_mgr):
|
||||
"""Non-Coolify plugins use universal required_scope filter."""
|
||||
wp_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "wordpress"]
|
||||
out = {
|
||||
t.name
|
||||
for t in access_mgr.apply_scope_filter(wp_tools, ["read"], plugin_type="wordpress")
|
||||
}
|
||||
# All wordpress sample tools have required_scope="read"
|
||||
assert "wordpress_list_posts" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-site toggles
|
||||
|
||||
@@ -112,6 +112,8 @@ def mock_key_mgr():
|
||||
def mock_db():
|
||||
"""Patch get_database to return a mock."""
|
||||
db = AsyncMock()
|
||||
db.get_site_tool_scope = AsyncMock(return_value="admin")
|
||||
db.get_site_tool_toggles = AsyncMock(return_value={})
|
||||
db.get_site_by_alias = AsyncMock(
|
||||
return_value={
|
||||
"id": "site-uuid-001",
|
||||
@@ -148,6 +150,10 @@ def mock_tool_registry():
|
||||
tool_def = MagicMock()
|
||||
tool_def.name = "wordpress_list_posts"
|
||||
tool_def.description = "List WordPress posts"
|
||||
tool_def.plugin_type = "wordpress"
|
||||
tool_def.required_scope = "read"
|
||||
tool_def.category = "read"
|
||||
tool_def.sensitivity = "normal"
|
||||
tool_def.input_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -221,6 +227,62 @@ class TestAuthentication:
|
||||
body = json.loads(response.body)
|
||||
assert "does not match" in body["error"]["message"]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_site_scoped_key_wrong_site(self, mock_key_mgr, mock_db):
|
||||
"""Site-scoped key (site_id=A) used for site B should return 403."""
|
||||
# Key is scoped to site-A
|
||||
mock_key_mgr.validate_key.return_value = {
|
||||
"key_id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"scopes": "read write",
|
||||
"site_id": "site-uuid-A",
|
||||
}
|
||||
# The site looked up by site_id (A) has alias "blog-a", but the request
|
||||
# is for alias "myblog" (which is site-B in get_site_by_alias).
|
||||
mock_db.get_site = AsyncMock(
|
||||
return_value={
|
||||
"id": "site-uuid-A",
|
||||
"alias": "blog-a",
|
||||
"user_id": "user-uuid-001",
|
||||
"plugin_type": "wordpress",
|
||||
"url": "https://blog-a.example.com",
|
||||
"credentials": b"x",
|
||||
"status": "active",
|
||||
}
|
||||
)
|
||||
request = _make_request(alias="myblog")
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 403
|
||||
body = json.loads(response.body)
|
||||
assert "scoped to a different site" in body["error"]["message"]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_site_scoped_key_matching_site(self, mock_key_mgr, mock_db, mock_tool_registry):
|
||||
"""Site-scoped key used for the matching alias should pass auth."""
|
||||
mock_key_mgr.validate_key.return_value = {
|
||||
"key_id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"scopes": "read write",
|
||||
"site_id": "site-uuid-001",
|
||||
}
|
||||
mock_db.get_site = AsyncMock(
|
||||
return_value={
|
||||
"id": "site-uuid-001",
|
||||
"alias": "myblog",
|
||||
"user_id": "user-uuid-001",
|
||||
"plugin_type": "wordpress",
|
||||
"url": "https://myblog.example.com",
|
||||
"credentials": b"x",
|
||||
"status": "active",
|
||||
}
|
||||
)
|
||||
request = _make_request(alias="myblog", method_name="tools/list")
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body)
|
||||
assert "result" in body
|
||||
assert "tools" in body["result"]
|
||||
|
||||
|
||||
# ── Site Lookup Tests ────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user