Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 788439e377 | |||
| d8a0805412 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -165,3 +165,8 @@ pytest-cache-files-*/
|
|||||||
worktrees/
|
worktrees/
|
||||||
/.agents
|
/.agents
|
||||||
uv.lock
|
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/),
|
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).
|
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
|
## [3.6.0] — 2026-04-02
|
||||||
|
|
||||||
### Gitea Plugin — Public Release (Track F.16)
|
### Gitea Plugin — Public Release (Track F.16)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ All configured in `pyproject.toml`:
|
|||||||
```
|
```
|
||||||
├── server.py # Primary entry point
|
├── server.py # Primary entry point
|
||||||
├── core/ # Layer 1: Core system modules
|
├── 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)
|
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
|
||||||
├── tests/ # Organized test suite
|
├── tests/ # Organized test suite
|
||||||
├── scripts/ # Setup & deployment scripts
|
├── 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 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
|
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.**
|
**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.
|
> **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 |
|
| `/u/{user_id}/{alias}/mcp` | 22-100 | No (pre-scoped) | Hosted/OAuth users |
|
||||||
| `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow |
|
| `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow |
|
||||||
| `/{plugin}/mcp` | 23-101 | Yes | Multi-site management |
|
| `/{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`
|
Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-advanced/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/appwrite/mcp`, `/directus/mcp`, `/system/mcp`
|
||||||
|
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -13,7 +13,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
|
|||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
[](https://pypi.org/project/mcphub-server/)
|
[](https://pypi.org/project/mcphub-server/)
|
||||||
[](https://hub.docker.com/r/airano/mcphub)
|
[](https://hub.docker.com/r/airano/mcphub)
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
||||||
|
|
||||||
@@ -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 |
|
| Plugin | Tools | What You Can Do |
|
||||||
|--------|-------|-----------------|
|
|--------|-------|-----------------|
|
||||||
| **WordPress** | 67 | Posts, pages, media, users, menus, taxonomies, SEO (Rank Math/Yoast) |
|
| **WordPress** | 67 | Posts, pages, media, users, menus, taxonomies, SEO (Rank Math/Yoast) |
|
||||||
| **WooCommerce** | 28 | Products, orders, customers, coupons, reports, shipping |
|
| **WooCommerce** | 28 | Products, orders, customers, coupons, reports, shipping |
|
||||||
| **WordPress Advanced** | 22 | Database ops, bulk operations, WP-CLI, system management |
|
| **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 |
|
| **n8n** | 56 | Workflows, executions, credentials, variables, audit |
|
||||||
| **Supabase** | 70 | Database, auth, storage, edge functions, realtime |
|
| **Supabase** | 70 | Database, auth, storage, edge functions, realtime |
|
||||||
| **OpenPanel** | 42 | Events, export, insights, profiles, projects, system |
|
| **OpenPanel** | 42 | Events, export, insights, profiles, projects, system |
|
||||||
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
|
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
|
||||||
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
|
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
|
||||||
| **System** | 24 | Health monitoring, API keys, OAuth management, audit |
|
| **Coolify** | 67 | Applications, deployments, servers, projects, databases, services |
|
||||||
| **Total** | **565** | Constant count — scales to unlimited sites |
|
| **System** | 23 | Health monitoring, API keys, OAuth management, audit |
|
||||||
|
| **Total** | **633** | Constant count — scales to unlimited sites |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -266,17 +267,17 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
|
|
||||||
```
|
```
|
||||||
/mcp → Admin endpoint (all 633 tools)
|
/mcp → Admin endpoint (all 633 tools)
|
||||||
/system/mcp → System tools only (24 tools)
|
/system/mcp → System tools only (23 tools)
|
||||||
/wordpress/mcp → WordPress tools (67 tools)
|
/wordpress/mcp → WordPress tools (67 tools)
|
||||||
/woocommerce/mcp → WooCommerce tools (28 tools)
|
/woocommerce/mcp → WooCommerce tools (28 tools)
|
||||||
/wordpress-advanced/mcp → WordPress Advanced tools (22 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)
|
/n8n/mcp → n8n tools (56 tools)
|
||||||
/supabase/mcp → Supabase tools (70 tools)
|
/supabase/mcp → Supabase tools (70 tools)
|
||||||
/openpanel/mcp → OpenPanel tools (42 tools)
|
/openpanel/mcp → OpenPanel tools (42 tools)
|
||||||
/appwrite/mcp → Appwrite tools (100 tools)
|
/appwrite/mcp → Appwrite tools (100 tools)
|
||||||
/coolify/mcp → Coolify tools (67 tools)
|
|
||||||
/directus/mcp → Directus tools (100 tools)
|
/directus/mcp → Directus tools (100 tools)
|
||||||
|
/coolify/mcp → Coolify tools (67 tools)
|
||||||
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
||||||
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
|
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
|
||||||
```
|
```
|
||||||
@@ -288,7 +289,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 |
|
| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 |
|
||||||
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
|
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
|
||||||
| `/{plugin}/mcp` | Multi-site management | 23-101 |
|
| `/{plugin}/mcp` | Multi-site management | 23-101 |
|
||||||
| `/mcp` | Admin & discovery only | 565 |
|
| `/mcp` | Admin & discovery only | 633 |
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
@@ -298,7 +299,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
- **GDPR-compliant audit logging** with automatic sensitive data filtering
|
- **GDPR-compliant audit logging** with automatic sensitive data filtering
|
||||||
- **Web dashboard** with real-time health monitoring (8 pages, EN/FA i18n)
|
- **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
|
### WordPress Plugin Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ DASHBOARD_TRANSLATIONS = {
|
|||||||
"admin_login": "Admin Login with API Key",
|
"admin_login": "Admin Login with API Key",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"admin_badge": "Admin",
|
"admin_badge": "Admin",
|
||||||
|
"keys": "API Keys",
|
||||||
},
|
},
|
||||||
"fa": {
|
"fa": {
|
||||||
# Navigation
|
# Navigation
|
||||||
@@ -265,6 +266,7 @@ DASHBOARD_TRANSLATIONS = {
|
|||||||
"admin_login": "ورود مدیر با کلید API",
|
"admin_login": "ورود مدیر با کلید API",
|
||||||
"profile": "پروفایل",
|
"profile": "پروفایل",
|
||||||
"admin_badge": "مدیر",
|
"admin_badge": "مدیر",
|
||||||
|
"keys": "کلیدهای API",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2905,6 +2907,154 @@ async def dashboard_connect_page(request: Request) -> Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Site View Route (F.7b session 2)
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def dashboard_sites_view(request: Request) -> Response:
|
||||||
|
"""GET /dashboard/sites/{id} — 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 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:
|
||||||
|
return RedirectResponse("/dashboard/sites?error=site_not_found", status_code=302)
|
||||||
|
|
||||||
|
accept_language = request.headers.get("accept-language")
|
||||||
|
query_lang = request.query_params.get("lang")
|
||||||
|
lang = detect_language(accept_language, query_lang)
|
||||||
|
t = get_translations(lang)
|
||||||
|
|
||||||
|
public_url = os.environ.get("PUBLIC_URL", "http://localhost:8000").rstrip("/")
|
||||||
|
mcp_url = f"{public_url}/u/{user_session['user_id']}/{site['alias']}/mcp"
|
||||||
|
|
||||||
|
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/manage.html",
|
||||||
|
{
|
||||||
|
"lang": lang,
|
||||||
|
"t": t,
|
||||||
|
"session": user_session,
|
||||||
|
"site": site,
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Unified Keys Route (F.7b session 2)
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def dashboard_keys_unified(request: Request) -> Response:
|
||||||
|
"""GET /dashboard/keys — Unified API keys page (user or admin view)."""
|
||||||
|
accept_language = request.headers.get("accept-language")
|
||||||
|
query_lang = request.query_params.get("lang")
|
||||||
|
lang = detect_language(accept_language, query_lang)
|
||||||
|
t = get_translations(lang)
|
||||||
|
|
||||||
|
auth = get_dashboard_auth()
|
||||||
|
admin_session = auth.get_session_from_request(request)
|
||||||
|
user_session = auth.get_user_session_from_request(request)
|
||||||
|
|
||||||
|
if admin_session and is_admin_session(admin_session):
|
||||||
|
# Admin view — reuse existing admin keys logic
|
||||||
|
project_filter = request.query_params.get("project", "")
|
||||||
|
status_filter = request.query_params.get("status", "active")
|
||||||
|
search = request.query_params.get("search", "")
|
||||||
|
page = int(request.query_params.get("page", 1))
|
||||||
|
|
||||||
|
keys_data = await get_all_api_keys(
|
||||||
|
project_id=project_filter if project_filter else None,
|
||||||
|
status=status_filter,
|
||||||
|
search=search if search else None,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
|
||||||
|
from core.site_manager import get_site_manager
|
||||||
|
|
||||||
|
site_manager = get_site_manager()
|
||||||
|
available_projects = site_manager.list_all_sites()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"dashboard/keys/list.html",
|
||||||
|
{
|
||||||
|
"lang": lang,
|
||||||
|
"t": t,
|
||||||
|
"session": admin_session,
|
||||||
|
"is_admin": True,
|
||||||
|
"api_keys": keys_data["api_keys"],
|
||||||
|
"total_count": keys_data["total_count"],
|
||||||
|
"total_pages": keys_data["total_pages"],
|
||||||
|
"page_number": keys_data["current_page"],
|
||||||
|
"per_page": keys_data["per_page"],
|
||||||
|
"available_projects": available_projects,
|
||||||
|
"selected_project": project_filter,
|
||||||
|
"selected_status": status_filter,
|
||||||
|
"search_query": search,
|
||||||
|
"current_page": "keys",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_session:
|
||||||
|
# User view — personal keys + 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 = []
|
||||||
|
try:
|
||||||
|
key_mgr = get_user_key_manager()
|
||||||
|
user_keys = await key_mgr.list_keys(user_session["user_id"])
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sites = await get_user_sites(user_session["user_id"])
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"dashboard/keys/list.html",
|
||||||
|
{
|
||||||
|
"lang": lang,
|
||||||
|
"t": t,
|
||||||
|
"session": user_session,
|
||||||
|
"is_admin": False,
|
||||||
|
"user_keys": user_keys,
|
||||||
|
"sites": sites,
|
||||||
|
"clients": get_supported_clients(),
|
||||||
|
"current_page": "keys",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(url="/auth/login", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# Site Management API Routes (E.3)
|
# Site Management API Routes (E.3)
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
@@ -3023,43 +3173,13 @@ async def api_test_site(request: Request) -> Response:
|
|||||||
|
|
||||||
|
|
||||||
async def dashboard_sites_edit(request: Request) -> Response:
|
async def dashboard_sites_edit(request: Request) -> Response:
|
||||||
"""GET /dashboard/sites/{id}/edit — Render the Edit Site form."""
|
"""GET /dashboard/sites/{id}/edit — Redirect to unified site page (F.7c)."""
|
||||||
user_session, redirect = _require_user_session(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
|
|
||||||
site_id = request.path_params.get("id", "")
|
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")
|
query_lang = request.query_params.get("lang")
|
||||||
lang = detect_language(accept_language, query_lang)
|
url = f"/dashboard/sites/{site_id}"
|
||||||
t = get_translations(lang)
|
if query_lang:
|
||||||
|
url += f"?lang={query_lang}"
|
||||||
import json
|
return RedirectResponse(url, status_code=301)
|
||||||
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def api_update_site(request: Request) -> Response:
|
async def api_update_site(request: Request) -> Response:
|
||||||
@@ -3113,11 +3233,25 @@ async def api_create_key(request: Request) -> Response:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
key_mgr = get_user_key_manager()
|
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(
|
result = await key_mgr.create_key(
|
||||||
user_id=user_session["user_id"],
|
user_id=user_session["user_id"],
|
||||||
name=body.get("name", "Default"),
|
name=body.get("name", "Default"),
|
||||||
scopes=body.get("scopes", "read write admin"),
|
scopes="read write admin",
|
||||||
expires_in_days=body.get("expires_in_days"),
|
expires_in_days=body.get("expires_in_days"),
|
||||||
|
site_id=site_id,
|
||||||
)
|
)
|
||||||
return JSONResponse({"key": result})
|
return JSONResponse({"key": result})
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
@@ -3162,6 +3296,181 @@ async def api_delete_key(request: Request) -> Response:
|
|||||||
return JSONResponse({"error": str(e)}, status_code=503)
|
return JSONResponse({"error": str(e)}, status_code=503)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# F.7b: per-site tool visibility management
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_TOOL_SCOPES = {"read", "read:sensitive", "deploy", "write", "admin", "custom"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_owned_site(request: Request) -> tuple[dict | None, Response | None]:
|
||||||
|
"""Resolve ``{site_id}`` path param → site row owned by the current user.
|
||||||
|
|
||||||
|
Returns ``(site, None)`` on success, or ``(None, error_response)``.
|
||||||
|
"""
|
||||||
|
user_session, redirect = _require_user_session(request)
|
||||||
|
if redirect:
|
||||||
|
return None, JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||||
|
|
||||||
|
site_id = request.path_params.get("site_id", "")
|
||||||
|
if not site_id:
|
||||||
|
return None, JSONResponse({"error": "Missing site_id"}, status_code=400)
|
||||||
|
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_database()
|
||||||
|
except RuntimeError:
|
||||||
|
return None, JSONResponse({"error": "Database unavailable"}, status_code=503)
|
||||||
|
|
||||||
|
site = await db.get_site(site_id, user_session["user_id"])
|
||||||
|
if site is None:
|
||||||
|
return None, JSONResponse({"error": "Site not found"}, status_code=404)
|
||||||
|
return site, None
|
||||||
|
|
||||||
|
|
||||||
|
async def api_list_site_tools(request: Request) -> Response:
|
||||||
|
"""GET /api/sites/{site_id}/tools — list tools for a site with toggle state."""
|
||||||
|
site, err = await _require_owned_site(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
assert site is not None
|
||||||
|
|
||||||
|
from core.tool_access import get_tool_access_manager
|
||||||
|
|
||||||
|
access = get_tool_access_manager()
|
||||||
|
tools = await access.list_tools_for_site(site["id"], site["plugin_type"])
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"site_id": site["id"],
|
||||||
|
"plugin_type": site["plugin_type"],
|
||||||
|
"tool_scope": site.get("tool_scope", "admin"),
|
||||||
|
"tools": tools,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_patch_site_tool(request: Request) -> Response:
|
||||||
|
"""PATCH /api/sites/{site_id}/tools/{tool_name} — toggle a single tool."""
|
||||||
|
site, err = await _require_owned_site(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
assert site is not None
|
||||||
|
|
||||||
|
tool_name = request.path_params.get("tool_name", "")
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
|
||||||
|
|
||||||
|
if "enabled" not in body or not isinstance(body["enabled"], bool):
|
||||||
|
return JSONResponse({"error": "Missing boolean 'enabled' field"}, status_code=400)
|
||||||
|
|
||||||
|
from core.tool_access import get_tool_access_manager
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
tool_def = get_tool_registry().get_by_name(tool_name)
|
||||||
|
if tool_def is None:
|
||||||
|
return JSONResponse({"error": f"Unknown tool '{tool_name}'"}, status_code=404)
|
||||||
|
if tool_def.plugin_type != site["plugin_type"]:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": f"Tool '{tool_name}' does not belong to this site's plugin"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
access = get_tool_access_manager()
|
||||||
|
try:
|
||||||
|
await access.toggle_tool(
|
||||||
|
site["id"],
|
||||||
|
tool_name,
|
||||||
|
bool(body["enabled"]),
|
||||||
|
body.get("reason"),
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, "tool_name": tool_name, "enabled": body["enabled"]})
|
||||||
|
|
||||||
|
|
||||||
|
async def api_bulk_toggle_site_tools(request: Request) -> Response:
|
||||||
|
"""POST /api/sites/{site_id}/tools/bulk-toggle — toggle a category set."""
|
||||||
|
site, err = await _require_owned_site(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
assert site is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
|
||||||
|
|
||||||
|
scope = body.get("scope")
|
||||||
|
enabled = body.get("enabled")
|
||||||
|
if not isinstance(scope, str) or not isinstance(enabled, bool):
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Body must contain string 'scope' and bool 'enabled'"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
from core.tool_access import get_tool_access_manager
|
||||||
|
|
||||||
|
access = get_tool_access_manager()
|
||||||
|
try:
|
||||||
|
affected = await access.bulk_toggle_by_scope(
|
||||||
|
site["id"], scope, enabled, plugin_type=site["plugin_type"]
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, "affected": affected})
|
||||||
|
|
||||||
|
|
||||||
|
async def api_set_site_tool_scope(request: Request) -> Response:
|
||||||
|
"""PATCH /api/sites/{site_id}/tool-scope — update the site's scope preset."""
|
||||||
|
site, err = await _require_owned_site(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
assert site is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
|
||||||
|
|
||||||
|
scope = body.get("scope")
|
||||||
|
if not isinstance(scope, str) or scope not in _VALID_TOOL_SCOPES:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": (
|
||||||
|
"Body must contain 'scope' with one of: "
|
||||||
|
+ ", ".join(sorted(_VALID_TOOL_SCOPES))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
await db.set_site_tool_scope(site["id"], scope)
|
||||||
|
return JSONResponse({"ok": True, "site_id": site["id"], "tool_scope": scope})
|
||||||
|
|
||||||
|
|
||||||
|
async def api_scope_presets(request: Request) -> Response:
|
||||||
|
"""GET /api/scope-presets — static scope → categories mapping."""
|
||||||
|
user_session, redirect = _require_user_session(request)
|
||||||
|
if redirect:
|
||||||
|
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||||
|
del user_session
|
||||||
|
|
||||||
|
from core.tool_access import SCOPE_TO_CATEGORIES
|
||||||
|
|
||||||
|
return JSONResponse({"presets": {k: sorted(v) for k, v in SCOPE_TO_CATEGORIES.items()}})
|
||||||
|
|
||||||
|
|
||||||
async def api_get_config(request: Request) -> Response:
|
async def api_get_config(request: Request) -> Response:
|
||||||
"""GET /api/config/{alias} — Get config snippets for a site."""
|
"""GET /api/config/{alias} — Get config snippets for a site."""
|
||||||
user_session, redirect = _require_user_session(request)
|
user_session, redirect = _require_user_session(request)
|
||||||
@@ -3471,8 +3780,11 @@ def register_dashboard_routes(mcp):
|
|||||||
dashboard_project_detail
|
dashboard_project_detail
|
||||||
)
|
)
|
||||||
|
|
||||||
# API Keys routes
|
# API Keys routes (unified — /dashboard/keys replaces /dashboard/api-keys and /dashboard/connect)
|
||||||
mcp.custom_route("/dashboard/api-keys", methods=["GET"])(dashboard_api_keys_list)
|
mcp.custom_route("/dashboard/keys", methods=["GET"])(dashboard_keys_unified)
|
||||||
|
mcp.custom_route("/dashboard/api-keys", methods=["GET"])(
|
||||||
|
lambda r: RedirectResponse("/dashboard/keys", status_code=301)
|
||||||
|
)
|
||||||
|
|
||||||
# OAuth Clients routes
|
# OAuth Clients routes
|
||||||
mcp.custom_route("/dashboard/oauth-clients", methods=["GET"])(dashboard_oauth_clients_list)
|
mcp.custom_route("/dashboard/oauth-clients", methods=["GET"])(dashboard_oauth_clients_list)
|
||||||
@@ -3519,7 +3831,11 @@ def register_dashboard_routes(mcp):
|
|||||||
mcp.custom_route("/dashboard/sites", methods=["GET"])(dashboard_sites_list)
|
mcp.custom_route("/dashboard/sites", methods=["GET"])(dashboard_sites_list)
|
||||||
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add)
|
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add)
|
||||||
mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit)
|
mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit)
|
||||||
mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page)
|
mcp.custom_route("/dashboard/sites/{id}", methods=["GET"])(dashboard_sites_view)
|
||||||
|
# /dashboard/connect → /dashboard/sites (F.7c: sites page has connect info now)
|
||||||
|
mcp.custom_route("/dashboard/connect", methods=["GET"])(
|
||||||
|
lambda r: RedirectResponse("/dashboard/sites", status_code=301)
|
||||||
|
)
|
||||||
|
|
||||||
# Service pages (F.3)
|
# Service pages (F.3)
|
||||||
mcp.custom_route("/dashboard/services", methods=["GET"])(dashboard_services_list)
|
mcp.custom_route("/dashboard/services", methods=["GET"])(dashboard_services_list)
|
||||||
|
|||||||
181
core/database.py
181
core/database.py
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
|
|||||||
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
|
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
|
||||||
|
|
||||||
# Schema version — increment when adding migrations
|
# Schema version — increment when adding migrations
|
||||||
SCHEMA_VERSION = 5
|
SCHEMA_VERSION = 8
|
||||||
|
|
||||||
# Initial schema DDL
|
# Initial schema DDL
|
||||||
_SCHEMA_SQL = """\
|
_SCHEMA_SQL = """\
|
||||||
@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS sites (
|
|||||||
status_msg TEXT,
|
status_msg TEXT,
|
||||||
last_health TEXT,
|
last_health TEXT,
|
||||||
last_tested_at TEXT,
|
last_tested_at TEXT,
|
||||||
|
tool_scope TEXT NOT NULL DEFAULT 'admin',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
UNIQUE(user_id, alias)
|
UNIQUE(user_id, alias)
|
||||||
);
|
);
|
||||||
@@ -82,7 +83,8 @@ CREATE TABLE IF NOT EXISTS user_api_keys (
|
|||||||
last_used TEXT,
|
last_used TEXT,
|
||||||
use_count INTEGER NOT NULL DEFAULT 0,
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
expires_at TEXT
|
expires_at TEXT,
|
||||||
|
site_id TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
-- WP plugin connection tokens (short-lived, for MCP Connect plugin)
|
-- WP plugin connection tokens (short-lived, for MCP Connect plugin)
|
||||||
@@ -100,6 +102,18 @@ CREATE TABLE IF NOT EXISTS schema_version (
|
|||||||
version INTEGER PRIMARY KEY,
|
version INTEGER PRIMARY KEY,
|
||||||
applied_at TEXT NOT NULL
|
applied_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- F.7b: per-site tool toggles (scope-based visibility overrides)
|
||||||
|
CREATE TABLE IF NOT EXISTS site_tool_toggles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
tool_name TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
reason TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(site_id, tool_name)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site ON site_tool_toggles(site_id);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Migration registry: version -> SQL string
|
# Migration registry: version -> SQL string
|
||||||
@@ -120,6 +134,42 @@ _MIGRATIONS: dict[int, str] = {
|
|||||||
" updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n"
|
" updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n"
|
||||||
");\n"
|
");\n"
|
||||||
),
|
),
|
||||||
|
6: (
|
||||||
|
# F.7: per-user tool toggles for scope-based visibility & per-tool disable
|
||||||
|
"CREATE TABLE IF NOT EXISTS user_tool_toggles (\n"
|
||||||
|
" id TEXT PRIMARY KEY,\n"
|
||||||
|
" user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n"
|
||||||
|
" tool_name TEXT NOT NULL,\n"
|
||||||
|
" enabled INTEGER NOT NULL DEFAULT 1,\n"
|
||||||
|
" reason TEXT,\n"
|
||||||
|
" updated_at TEXT NOT NULL,\n"
|
||||||
|
" UNIQUE(user_id, tool_name)\n"
|
||||||
|
");\n"
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_user_tool_toggles_user "
|
||||||
|
"ON user_tool_toggles(user_id);\n"
|
||||||
|
),
|
||||||
|
7: (
|
||||||
|
# F.7b: move tool toggles from per-user to per-site and add a
|
||||||
|
# per-site preset column. user_tool_toggles was merged on Phase-1
|
||||||
|
# but never populated with real data — safe to drop.
|
||||||
|
"DROP TABLE IF EXISTS user_tool_toggles;\n"
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_tool_toggles (\n"
|
||||||
|
" id TEXT PRIMARY KEY,\n"
|
||||||
|
" site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,\n"
|
||||||
|
" tool_name TEXT NOT NULL,\n"
|
||||||
|
" enabled INTEGER NOT NULL DEFAULT 1,\n"
|
||||||
|
" reason TEXT,\n"
|
||||||
|
" updated_at TEXT NOT NULL,\n"
|
||||||
|
" UNIQUE(site_id, tool_name)\n"
|
||||||
|
");\n"
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site "
|
||||||
|
"ON site_tool_toggles(site_id);\n"
|
||||||
|
"ALTER TABLE sites ADD COLUMN tool_scope TEXT NOT NULL DEFAULT 'admin';\n"
|
||||||
|
),
|
||||||
|
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"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -637,6 +687,7 @@ class Database:
|
|||||||
name: str,
|
name: str,
|
||||||
scopes: str = "read write",
|
scopes: str = "read write",
|
||||||
expires_at: str | None = None,
|
expires_at: str | None = None,
|
||||||
|
site_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a new user API key.
|
"""Create a new user API key.
|
||||||
|
|
||||||
@@ -647,6 +698,7 @@ class Database:
|
|||||||
name: Human label (e.g. "Claude Desktop").
|
name: Human label (e.g. "Claude Desktop").
|
||||||
scopes: Space-separated scopes.
|
scopes: Space-separated scopes.
|
||||||
expires_at: Optional ISO 8601 expiry timestamp.
|
expires_at: Optional ISO 8601 expiry timestamp.
|
||||||
|
site_id: Optional site UUID to scope key to a single site.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The created API key row as a dict.
|
The created API key row as a dict.
|
||||||
@@ -656,14 +708,14 @@ class Database:
|
|||||||
|
|
||||||
await self.execute(
|
await self.execute(
|
||||||
"INSERT INTO user_api_keys "
|
"INSERT INTO user_api_keys "
|
||||||
"(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at) "
|
"(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at, site_id) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at),
|
(key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at, site_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await self.fetchone(
|
result = await self.fetchone(
|
||||||
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
|
"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,),
|
(key_id,),
|
||||||
)
|
)
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -681,7 +733,7 @@ class Database:
|
|||||||
"""
|
"""
|
||||||
return await self.fetchall(
|
return await self.fetchall(
|
||||||
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
|
"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,),
|
(user_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -726,6 +778,121 @@ class Database:
|
|||||||
(_utc_now(), key_id),
|
(_utc_now(), key_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Site tool toggles & tool_scope (F.7b)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_site_tool_toggles(self, site_id: str) -> dict[str, bool]:
|
||||||
|
"""Get explicit tool toggle overrides for a site.
|
||||||
|
|
||||||
|
Only rows where a tool has been explicitly toggled are stored.
|
||||||
|
Tools not in the result are implicitly enabled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping ``tool_name`` → ``enabled`` (bool).
|
||||||
|
"""
|
||||||
|
rows = await self.fetchall(
|
||||||
|
"SELECT tool_name, enabled FROM site_tool_toggles WHERE site_id = ?",
|
||||||
|
(site_id,),
|
||||||
|
)
|
||||||
|
return {row["tool_name"]: bool(row["enabled"]) for row in rows}
|
||||||
|
|
||||||
|
async def set_site_tool_toggle(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
enabled: bool,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Upsert a single tool toggle for a site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
tool_name: Fully-qualified tool name (e.g. ``coolify_list_applications``).
|
||||||
|
enabled: Whether the tool should be visible on this site.
|
||||||
|
reason: Optional note.
|
||||||
|
"""
|
||||||
|
toggle_id = str(uuid.uuid4())
|
||||||
|
now = _utc_now()
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO site_tool_toggles (id, site_id, tool_name, enabled, reason, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(site_id, tool_name) DO UPDATE SET "
|
||||||
|
"enabled = excluded.enabled, reason = excluded.reason, updated_at = excluded.updated_at",
|
||||||
|
(toggle_id, site_id, tool_name, 1 if enabled else 0, reason, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_site_tool_toggle(self, site_id: str, tool_name: str) -> bool:
|
||||||
|
"""Delete a site's toggle for a tool (reverts to the default).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
tool_name: Fully-qualified tool name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was deleted.
|
||||||
|
"""
|
||||||
|
cursor = await self.execute(
|
||||||
|
"DELETE FROM site_tool_toggles WHERE site_id = ? AND tool_name = ?",
|
||||||
|
(site_id, tool_name),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
async def bulk_set_site_tool_toggles(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
toggles: list[tuple[str, bool]],
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Upsert multiple tool toggles for a site in one transaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
toggles: List of ``(tool_name, enabled)`` pairs.
|
||||||
|
reason: Optional shared reason applied to every row.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of rows affected.
|
||||||
|
"""
|
||||||
|
if not toggles:
|
||||||
|
return 0
|
||||||
|
now = _utc_now()
|
||||||
|
rows = [
|
||||||
|
(str(uuid.uuid4()), site_id, tool_name, 1 if enabled else 0, reason, now)
|
||||||
|
for tool_name, enabled in toggles
|
||||||
|
]
|
||||||
|
await self.executemany(
|
||||||
|
"INSERT INTO site_tool_toggles (id, site_id, tool_name, enabled, reason, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(site_id, tool_name) DO UPDATE SET "
|
||||||
|
"enabled = excluded.enabled, reason = excluded.reason, updated_at = excluded.updated_at",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
async def get_site_tool_scope(self, site_id: str) -> str:
|
||||||
|
"""Return the site's ``tool_scope`` preset (defaults to ``'admin'``)."""
|
||||||
|
row = await self.fetchone("SELECT tool_scope FROM sites WHERE id = ?", (site_id,))
|
||||||
|
if row is None:
|
||||||
|
return "admin"
|
||||||
|
return row["tool_scope"] or "admin"
|
||||||
|
|
||||||
|
async def set_site_tool_scope(self, site_id: str, scope: str) -> None:
|
||||||
|
"""Update the ``tool_scope`` preset for a site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
scope: One of ``read``, ``read:sensitive``, ``deploy``,
|
||||||
|
``write``, ``admin``, ``custom``.
|
||||||
|
"""
|
||||||
|
await self.execute(
|
||||||
|
"UPDATE sites SET tool_scope = ? WHERE id = ?",
|
||||||
|
(scope, site_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# Module-level helpers
|
# Module-level helpers
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
|
|||||||
"label": "Client ID",
|
"label": "Client ID",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"required": True,
|
"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",
|
"name": "client_secret",
|
||||||
|
|||||||
@@ -111,9 +111,6 @@
|
|||||||
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
<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>
|
<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>
|
||||||
<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">
|
<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">
|
<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"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
@@ -136,8 +133,8 @@
|
|||||||
01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||||
'/dashboard/sites'),
|
'/dashboard/sites'),
|
||||||
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
|
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
|
||||||
('connect', t.get('connect', 'Connect'), 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656
|
('keys', t.get('keys', 'API Keys'), 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
|
||||||
5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', '/dashboard/connect'),
|
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/keys'),
|
||||||
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
|
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
|
||||||
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
|
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
|
||||||
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
|
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
|
||||||
|
|||||||
676
core/templates/dashboard/keys/list.html
Normal file
676
core/templates/dashboard/keys/list.html
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
{# ── Admin view: full filters, all project keys ── #}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{% if lang == 'fa' %}مدیریت کلیدهای API (Admin){% else %}Manage project API keys (admin){% endif %}
|
||||||
|
</p>
|
||||||
|
<button onclick="openCreateModal()"
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm font-medium transition-colors flex items-center">
|
||||||
|
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Filters -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||||
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
|
||||||
|
<select name="project" onchange="this.form.submit()"
|
||||||
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||||
|
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
||||||
|
<option value="*" {% if selected_project == '*' %}selected{% endif %}>{% if lang == 'fa' %}کلیدهای عمومی (*){% else %}Global Keys (*){% endif %}</option>
|
||||||
|
{% for project in available_projects %}
|
||||||
|
<option value="{{ project.full_id }}" {% if selected_project == project.full_id %}selected{% endif %}>
|
||||||
|
{{ project.plugin_type }}: {{ project.alias or project.site_id }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
|
||||||
|
<select name="status" onchange="this.form.submit()"
|
||||||
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||||
|
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
|
||||||
|
<option value="all" {% if selected_status == 'all' %}selected{% endif %}>{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
|
||||||
|
<option value="revoked" {% if selected_status == 'revoked' %}selected{% endif %}>{% if lang == 'fa' %}لغو شده{% else %}Revoked{% endif %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<input type="text" name="search" value="{{ search_query }}"
|
||||||
|
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search description...{% endif %}"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{{ t.search }}</button>
|
||||||
|
{% if search_query or selected_project or selected_status != 'active' %}
|
||||||
|
<a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">{{ t['clear'] }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Keys Table -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}شناسه{% else %}Key ID{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% if api_keys %}
|
||||||
|
{% for key in api_keys %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
|
||||||
|
<button onclick="copyToClipboard('{{ key.key_id }}')" class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-gray-400 hover:text-white transition-colors" title="Copy ID">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if key.project_id == '*' %}
|
||||||
|
<span class="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg font-medium">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</span>
|
||||||
|
{% else %}<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.project_id }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% for scope in key.scope.split() %}
|
||||||
|
<span class="px-2 py-0.5 {% if scope == 'admin' %}bg-red-500/20 text-red-400{% elif scope == 'write' %}bg-orange-500/20 text-orange-400{% else %}bg-blue-500/20 text-blue-400{% endif %} text-xs rounded font-medium">{{ scope }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4"><span class="text-sm text-gray-500 dark:text-gray-400">{{ key.description or '-' }}</span></td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if key.revoked %}<div class="flex items-center"><span class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span><span class="text-sm text-red-400">{% if lang == 'fa' %}لغو شده{% else %}Revoked{% endif %}</span></div>
|
||||||
|
{% else %}<div class="flex items-center"><span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span><span class="text-sm text-green-400">{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</span></div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4"><span class="text-sm text-gray-700 dark:text-gray-300">{{ key.usage_count }}</span></td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if not key.revoked %}
|
||||||
|
<button onclick="openRevokeModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
|
||||||
|
class="p-2 bg-red-500/20 hover:bg-red-500/30 rounded-lg text-red-400 transition-colors" title="{% if lang == 'fa' %}لغو{% else %}Revoke{% endif %}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
|
||||||
|
class="p-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors" title="{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="px-6 py-12 text-center">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}</p>
|
||||||
|
<button onclick="openCreateModal()" class="mt-4 px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}</button>
|
||||||
|
</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}نمایش {{ ((page_number-1)*per_page)+1 }} تا {{ [page_number*per_page, total_count]|min }} از {{ total_count }}{% else %}Showing {{ ((page_number-1)*per_page)+1 }}–{{ [page_number*per_page, total_count]|min }} of {{ total_count }}{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if page_number > 1 %}<a href="?page={{ page_number-1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}</a>{% endif %}
|
||||||
|
{% for p in range(1, total_pages+1) %}{% if p == page_number %}<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ p }}</span>{% elif p == 1 or p == total_pages or (p >= page_number-2 and p <= page_number+2) %}<a href="?page={{ p }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{{ p }}</a>{% endif %}{% endfor %}
|
||||||
|
{% if page_number < total_pages %}<a href="?page={{ page_number+1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# ── User view: personal keys with scope selector ── #}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{% if lang == 'fa' %}کلیدهای API شخصی برای دسترسی به MCP{% else %}Your personal API keys for MCP access{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-400 dark:text-gray-500 text-xs mt-1">
|
||||||
|
{% if lang == 'fa' %}فیلتر ابزارهای هر سایت در <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">تنظیمات سایت</a> انجام میشود.{% else %}Per-site tool filters are configured in <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">Site Settings</a>.{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="openCreateModal()"
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm font-medium transition-colors flex items-center">
|
||||||
|
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Keys Table -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{% if lang == 'fa' %}نام{% else %}Name{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Prefix</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Uses</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% if user_keys %}
|
||||||
|
{% for key in user_keys %}
|
||||||
|
<tr id="ukey-{{ key.id }}">
|
||||||
|
<td class="px-6 py-4 text-gray-900 dark:text-white">{{ key.name }}</td>
|
||||||
|
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 font-mono text-sm">mhu_{{ key.key_prefix }}...</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% 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">
|
||||||
|
<button onclick="deleteUserKey('{{ key.id }}')" class="text-sm text-red-600 dark:text-red-400 hover:text-red-500">{{ t.delete }}</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="px-6 py-12 text-center">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">{{ t.no_api_keys }}</p>
|
||||||
|
<button onclick="openCreateModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}</button>
|
||||||
|
</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
|
||||||
|
{# ── Modals ── #}
|
||||||
|
|
||||||
|
<!-- Create Key Modal -->
|
||||||
|
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{% if is_admin %}
|
||||||
|
{# Admin create form — posts to existing admin endpoint #}
|
||||||
|
<form id="createKeyForm" method="POST" action="/api/dashboard/api-keys/create" class="p-6 space-y-4">
|
||||||
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}</label>
|
||||||
|
<select name="project_id" required class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
|
||||||
|
<option value="*">{% if lang == 'fa' %}همه پروژهها (*){% else %}All Projects (*){% endif %}</option>
|
||||||
|
{% for project in available_projects %}
|
||||||
|
<option value="{{ project.full_id }}">{{ project.plugin_type }}: {{ project.alias or project.site_id }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}</label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center"><input type="checkbox" name="scope_read" value="read" checked class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read</span></label>
|
||||||
|
<label class="flex items-center"><input type="checkbox" name="scope_write" value="write" class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write</span></label>
|
||||||
|
<label class="flex items-center"><input type="checkbox" name="scope_admin" value="admin" class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">admin</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}توضیحات (اختیاری){% else %}Description (optional){% endif %}</label>
|
||||||
|
<input type="text" name="description" placeholder="{% if lang == 'fa' %}مثال: CI/CD{% else %}e.g., CI/CD key{% endif %}"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}انقضا{% else %}Expiration{% endif %}</label>
|
||||||
|
<select name="expires_in_days" class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
|
||||||
|
<option value="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
|
||||||
|
<option value="7">7 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
|
||||||
|
<option value="30">30 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
|
||||||
|
<option value="90">90 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
|
||||||
|
<option value="365">1 {% if lang == 'fa' %}سال{% else %}year{% endif %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
{# User create form — uses /api/keys via JS #}
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}نام کلید{% else %}Key Name{% endif %}</label>
|
||||||
|
<input type="text" id="new-key-name" placeholder="{% if lang == 'fa' %}مثال: Claude Desktop{% else %}e.g., Claude Desktop{% endif %}"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}محدوده سرویس{% else %}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">
|
||||||
|
{% 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>
|
||||||
|
{% endif %}
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
|
<button onclick="closeCreateModal()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
|
||||||
|
<button onclick="submitCreate()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}ایجاد کلید{% else %}Create Key{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Key Display Modal -->
|
||||||
|
<div id="newKeyModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
{% if lang == 'fa' %}کلید API ایجاد شد{% else %}API Key Created{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-yellow-400">{% if lang == 'fa' %}این کلید فقط یکبار نمایش داده میشود.{% else %}This key will only be shown once. Save it now!{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-400 mb-2">API Key</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="text" id="newKeyValue" readonly class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white font-mono text-sm">
|
||||||
|
<button onclick="copyNewKey()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
|
<button onclick="closeNewKeyModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}بستن{% else %}Done{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
<!-- Revoke Modal -->
|
||||||
|
<div id="revokeModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700"><h3 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}لغو کلید API{% else %}Revoke API Key{% endif %}</h3></div>
|
||||||
|
<div class="p-6"><p class="text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}آیا مطمئنید که میخواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟{% else %}Are you sure you want to revoke <span id="revokeKeyName" class="font-mono text-white"></span>?{% endif %}</p></div>
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
|
<button onclick="closeRevokeModal()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
|
||||||
|
<button id="confirmRevokeBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}لغو کلید{% else %}Revoke{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Modal -->
|
||||||
|
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700"><h3 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}حذف کلید API{% else %}Delete API Key{% endif %}</h3></div>
|
||||||
|
<div class="p-6"><p class="text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}آیا مطمئنید که میخواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟{% else %}Are you sure you want to delete <span id="deleteKeyName" class="font-mono text-white"></span>?{% endif %}</p></div>
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
|
<button onclick="closeDeleteModal()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
|
||||||
|
<button id="confirmDeleteBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const isAdmin = {{ 'true' if is_admin else 'false' }};
|
||||||
|
const lang = '{{ lang }}';
|
||||||
|
let currentRevokeKeyId = null;
|
||||||
|
let currentDeleteKeyId = null;
|
||||||
|
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => showToast(lang === 'fa' ? 'کپی شد!' : 'Copied!'));
|
||||||
|
}
|
||||||
|
window.copyToClipboard = copyToClipboard;
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'fixed bottom-4 right-4 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white text-sm z-50';
|
||||||
|
t.textContent = msg;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
document.getElementById('createModal').classList.remove('hidden');
|
||||||
|
document.getElementById('createModal').classList.add('flex');
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
document.getElementById('createModal').classList.add('hidden');
|
||||||
|
document.getElementById('createModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
window.closeCreateModal = closeCreateModal;
|
||||||
|
|
||||||
|
function closeNewKeyModal() {
|
||||||
|
document.getElementById('newKeyModal').classList.add('hidden');
|
||||||
|
document.getElementById('newKeyModal').classList.remove('flex');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
window.closeNewKeyModal = closeNewKeyModal;
|
||||||
|
|
||||||
|
function copyNewKey() {
|
||||||
|
copyToClipboard(document.getElementById('newKeyValue').value);
|
||||||
|
}
|
||||||
|
window.copyNewKey = copyNewKey;
|
||||||
|
|
||||||
|
function showNewKey(key) {
|
||||||
|
document.getElementById('newKeyValue').value = key;
|
||||||
|
document.getElementById('newKeyModal').classList.remove('hidden');
|
||||||
|
document.getElementById('newKeyModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
if (isAdmin) {
|
||||||
|
// Admin: collect scopes + submit form via JS
|
||||||
|
const form = document.getElementById('createKeyForm');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const scopes = [];
|
||||||
|
if (form.querySelector('[name="scope_read"]')?.checked) scopes.push('read');
|
||||||
|
if (form.querySelector('[name="scope_write"]')?.checked) scopes.push('write');
|
||||||
|
if (form.querySelector('[name="scope_admin"]')?.checked) scopes.push('admin');
|
||||||
|
if (scopes.length === 0) { alert(lang === 'fa' ? 'حداقل یک دسترسی انتخاب کنید' : 'Select at least one scope'); return; }
|
||||||
|
const data = {
|
||||||
|
project_id: formData.get('project_id'),
|
||||||
|
scope: scopes.join(' '),
|
||||||
|
description: formData.get('description') || null,
|
||||||
|
expires_in_days: formData.get('expires_in_days') ? parseInt(formData.get('expires_in_days')) : null,
|
||||||
|
};
|
||||||
|
const r = await fetch('/api/dashboard/api-keys/create', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
|
if (res.error) { alert(res.error); return; }
|
||||||
|
closeCreateModal();
|
||||||
|
showNewKey(res.key);
|
||||||
|
} else {
|
||||||
|
// User: POST to /api/keys
|
||||||
|
const name = document.getElementById('new-key-name').value.trim();
|
||||||
|
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(payload),
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
|
if (!r.ok) { alert(res.error || 'Failed'); return; }
|
||||||
|
closeCreateModal();
|
||||||
|
showNewKey(res.key.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.submitCreate = submitCreate;
|
||||||
|
|
||||||
|
async function deleteUserKey(keyId) {
|
||||||
|
if (!confirm(lang === 'fa' ? 'این کلید حذف شود؟' : 'Delete this key?')) return;
|
||||||
|
const r = await fetch('/api/keys/' + keyId, { method: 'DELETE' });
|
||||||
|
if (r.ok) {
|
||||||
|
const row = document.getElementById('ukey-' + keyId);
|
||||||
|
if (row) row.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.deleteUserKey = deleteUserKey;
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
function openRevokeModal(keyId, keyName) {
|
||||||
|
currentRevokeKeyId = keyId;
|
||||||
|
document.getElementById('revokeKeyName').textContent = keyName;
|
||||||
|
document.getElementById('revokeModal').classList.remove('hidden');
|
||||||
|
document.getElementById('revokeModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
window.openRevokeModal = openRevokeModal;
|
||||||
|
|
||||||
|
function closeRevokeModal() {
|
||||||
|
document.getElementById('revokeModal').classList.add('hidden');
|
||||||
|
document.getElementById('revokeModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
window.closeRevokeModal = closeRevokeModal;
|
||||||
|
|
||||||
|
document.getElementById('confirmRevokeBtn').onclick = async function() {
|
||||||
|
if (!currentRevokeKeyId) return;
|
||||||
|
const r = await fetch('/api/dashboard/api-keys/' + currentRevokeKeyId + '/revoke', { method: 'POST', credentials: 'same-origin' });
|
||||||
|
if (r.ok) location.reload();
|
||||||
|
else { const d = await r.json(); alert(d.error || 'Failed'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
function openDeleteModal(keyId, keyName) {
|
||||||
|
currentDeleteKeyId = keyId;
|
||||||
|
document.getElementById('deleteKeyName').textContent = keyName;
|
||||||
|
document.getElementById('deleteModal').classList.remove('hidden');
|
||||||
|
document.getElementById('deleteModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
window.openDeleteModal = openDeleteModal;
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
document.getElementById('deleteModal').classList.add('hidden');
|
||||||
|
document.getElementById('deleteModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
window.closeDeleteModal = closeDeleteModal;
|
||||||
|
|
||||||
|
document.getElementById('confirmDeleteBtn').onclick = async function() {
|
||||||
|
if (!currentDeleteKeyId) return;
|
||||||
|
const r = await fetch('/api/dashboard/api-keys/' + currentDeleteKeyId, { method: 'DELETE', credentials: 'same-origin' });
|
||||||
|
if (r.ok) location.reload();
|
||||||
|
else { const d = await r.json(); alert(d.error || 'Failed'); }
|
||||||
|
};
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// ── 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>
|
||||||
|
|
||||||
<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 items-center gap-3">
|
||||||
<div class="flex flex-wrap gap-2">
|
<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">
|
||||||
<label class="flex items-center gap-2">
|
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
|
||||||
<input type="checkbox" name="scopes" value="read" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
</span>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">read</span>
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
</label>
|
{% if lang == 'fa' %}دسترسی ابزار در تنظیمات هر سایت کنترل میشود.{% else %}Tool access is controlled per-site in Site Settings.{% endif %}
|
||||||
<label class="flex items-center gap-2">
|
</p>
|
||||||
<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>
|
</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>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-4">
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
@@ -426,10 +416,8 @@ https://claude.com/api/mcp/auth_callback</textarea>
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
var formData = new FormData(this);
|
var formData = new FormData(this);
|
||||||
var scopes = [];
|
// F.7c: All clients get full scope — tool access controlled per-site
|
||||||
document.querySelectorAll('input[name="scopes"]:checked').forEach(function(cb) {
|
var scopes = ['read', 'write', 'admin'];
|
||||||
scopes.push(cb.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse redirect URIs (one per line)
|
// Parse redirect URIs (one per line)
|
||||||
var redirectUrisText = formData.get('redirect_uris') || '';
|
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"
|
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
<!-- Tailwind CSS (pre-built) -->
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<link rel="stylesheet" href="/static/tailwind.min.css">
|
||||||
|
|
||||||
<!-- HTMX for dynamic updates -->
|
<!-- HTMX for dynamic updates -->
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
@@ -13,34 +13,6 @@
|
|||||||
<!-- Alpine.js for simple interactions -->
|
<!-- Alpine.js for simple interactions -->
|
||||||
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
<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 -->
|
<!-- Theme Initialization -->
|
||||||
<script>
|
<script>
|
||||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
|||||||
@@ -57,7 +57,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Overview Cards -->
|
<!-- 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 -->
|
<!-- Tools Count -->
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
<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 class="flex items-center justify-between">
|
||||||
@@ -112,6 +113,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Setup Requirements -->
|
<!-- Setup Requirements -->
|
||||||
|
|||||||
@@ -42,13 +42,17 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>{{ svc.tools_count }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
{% set read_count = svc.tools|selectattr('scope', 'equalto', 'read')|list|length %}
|
{% set read_count = svc.tools|selectattr('scope', 'equalto', 'read')|list|length %}
|
||||||
{% set write_count = svc.tools|selectattr('scope', 'equalto', 'write')|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-green-600 dark:text-green-400">{{ read_count }} read</span>
|
||||||
<span class="text-orange-600 dark:text-orange-400">{{ write_count }} write</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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
{% block page_title %}{{ t.add_site }}{% endblock %}
|
{% block page_title %}{{ t.add_site }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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">
|
<div class="flex items-center gap-4 mb-6">
|
||||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
<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">
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
@@ -18,55 +19,119 @@
|
|||||||
<!-- Error display -->
|
<!-- 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>
|
<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 -->
|
<!-- Section 1: Add Site / Connect Service -->
|
||||||
<div>
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
<select id="plugin_type" name="plugin_type" required
|
<button type="button" onclick="toggleSection('addsite')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
||||||
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"
|
<div class="flex items-center gap-3">
|
||||||
onchange="updateFields()">
|
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
<option value="">{{ t.select_plugin }}</option>
|
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{% for ptype, pname in plugin_names.items() %}
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
<option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
|
</svg>
|
||||||
{% endfor %}
|
</div>
|
||||||
</select>
|
<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 -->
|
<!-- URL -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
|
<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"
|
<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">
|
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>
|
<p id="url-hint" class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alias -->
|
<!-- Alias -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
<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_-]+"
|
<input type="text" id="alias" name="alias" required placeholder="myblog" pattern="[a-zA-Z0-9_-]+"
|
||||||
minlength="2" maxlength="50"
|
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">
|
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>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t.site_alias_hint }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dynamic Credential Fields -->
|
<!-- Dynamic Credential Fields -->
|
||||||
<div id="credential-fields" class="space-y-4">
|
<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>
|
<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>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit -->
|
<!-- Submit -->
|
||||||
<div class="flex items-center gap-4 pt-4">
|
<div class="flex items-center gap-3 pt-2">
|
||||||
<button type="submit" id="submit-btn"
|
<button type="submit" id="submit-btn"
|
||||||
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
|
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 }}
|
{{ t.add_site }}
|
||||||
</button>
|
</button>
|
||||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
<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">
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors text-sm">
|
||||||
{{ t.cancel }}
|
{{ t.cancel }}
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -79,6 +144,14 @@
|
|||||||
'supabase': 'Your Supabase project URL (e.g., https://xxxxx.supabase.co)',
|
'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() {
|
function updateFields() {
|
||||||
const ptype = document.getElementById('plugin_type').value;
|
const ptype = document.getElementById('plugin_type').value;
|
||||||
const container = document.getElementById('credential-fields');
|
const container = document.getElementById('credential-fields');
|
||||||
@@ -131,7 +204,7 @@
|
|||||||
container.innerHTML = html;
|
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) {
|
if (document.getElementById('plugin_type').value) {
|
||||||
updateFields();
|
updateFields();
|
||||||
}
|
}
|
||||||
@@ -171,7 +244,13 @@
|
|||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (resp.ok) {
|
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 {
|
} else {
|
||||||
errorEl.textContent = data.error || 'Failed to add site';
|
errorEl.textContent = data.error || 'Failed to add site';
|
||||||
errorEl.classList.remove('hidden');
|
errorEl.classList.remove('hidden');
|
||||||
|
|||||||
@@ -110,6 +110,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tool Access Card -->
|
||||||
|
<div id="tool-access-card" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}دسترسی به ابزارها{% else %}Tool Access{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div id="tool-access-loading" class="text-gray-400 dark:text-gray-500 text-sm">
|
||||||
|
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
||||||
|
</div>
|
||||||
|
<div id="tool-access-content" class="hidden space-y-4">
|
||||||
|
<!-- Scope selector -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{% if lang == 'fa' %}سطح دسترسی{% else %}Access Scope{% endif %}
|
||||||
|
</label>
|
||||||
|
<select id="tool-scope-select"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
<option value="read">{% if lang == 'fa' %}فقط خواندن (Read){% else %}Read{% endif %}</option>
|
||||||
|
<option value="read:sensitive">{% if lang == 'fa' %}خواندن + داده حساس{% else %}Read + Sensitive{% endif %}</option>
|
||||||
|
<option value="deploy">{% if lang == 'fa' %}استقرار (start/stop/restart){% else %}Deploy{% endif %}</option>
|
||||||
|
<option value="write">{% if lang == 'fa' %}نوشتن + Lifecycle{% else %}Write{% endif %}</option>
|
||||||
|
<option value="admin">{% if lang == 'fa' %}دسترسی کامل{% else %}Admin (all tools){% endif %}</option>
|
||||||
|
<option value="custom">{% if lang == 'fa' %}سفارشی (per-tool){% else %}Custom{% endif %}</option>
|
||||||
|
</select>
|
||||||
|
<p id="scope-desc" class="text-xs text-gray-500 dark:text-gray-400 mt-1.5"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status message for scope save -->
|
||||||
|
<div id="scope-status" class="hidden text-xs"></div>
|
||||||
|
|
||||||
|
<!-- Advanced: per-tool overrides -->
|
||||||
|
<details id="tool-overrides-section">
|
||||||
|
<summary class="cursor-pointer text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none py-1">
|
||||||
|
{% if lang == 'fa' %}پیشرفته — انتخاب دستی ابزارها{% else %}Advanced — per-tool overrides{% endif %}
|
||||||
|
</summary>
|
||||||
|
<div id="tool-list" class="mt-3 space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
@@ -168,5 +209,157 @@
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Tool Access ──────────────────────────────────────────────
|
||||||
|
const SCOPE_DESCS = {
|
||||||
|
'read': '{% if lang == "fa" %}ابزارهای read-only (list/get) — بدون دادههای حساس{% else %}Read-only list/get tools — no sensitive data{% endif %}',
|
||||||
|
'read:sensitive': '{% if lang == "fa" %}read + لاگها، بکاپها و متغیرهای محیطی{% else %}Read + logs, backups, and env vars{% endif %}',
|
||||||
|
'deploy': '{% if lang == "fa" %}read + start/stop/restart/deploy{% else %}Read + start/stop/restart/deploy{% endif %}',
|
||||||
|
'write': '{% if lang == "fa" %}read + lifecycle + ایجاد/بروزرسانی + env{% else %}Read + lifecycle + create/update + env{% endif %}',
|
||||||
|
'admin': '{% if lang == "fa" %}دسترسی کامل به همه ابزارها{% else %}Full access to all tools{% endif %}',
|
||||||
|
'custom': '{% if lang == "fa" %}بدون فیلتر سطح — فقط toggleهای دستی اعمال میشوند{% else %}No scope filter — only per-tool toggles apply{% endif %}',
|
||||||
|
};
|
||||||
|
const CAT_LABELS = {
|
||||||
|
'read': '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}',
|
||||||
|
'read_sensitive': '{% if lang == "fa" %}خواندن حساس{% else %}Sensitive Read{% endif %}',
|
||||||
|
'lifecycle': 'Lifecycle',
|
||||||
|
'crud': 'CRUD',
|
||||||
|
'env': '{% if lang == "fa" %}محیطی{% else %}Environment{% endif %}',
|
||||||
|
'backup': 'Backup',
|
||||||
|
'system': 'System',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadToolAccess() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sites/' + siteId + '/tools');
|
||||||
|
if (!r.ok) { hideToolAccess(); return; }
|
||||||
|
const data = await r.json();
|
||||||
|
renderToolAccess(data);
|
||||||
|
} catch (_) { hideToolAccess(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideToolAccess() {
|
||||||
|
document.getElementById('tool-access-loading').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolAccess(data) {
|
||||||
|
const loading = document.getElementById('tool-access-loading');
|
||||||
|
const content = document.getElementById('tool-access-content');
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
content.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Set scope dropdown
|
||||||
|
const select = document.getElementById('tool-scope-select');
|
||||||
|
select.value = data.tool_scope || 'admin';
|
||||||
|
updateScopeDesc(data.tool_scope || 'admin');
|
||||||
|
|
||||||
|
// Scope change handler
|
||||||
|
select.onchange = async () => {
|
||||||
|
const scope = select.value;
|
||||||
|
updateScopeDesc(scope);
|
||||||
|
const statusEl = document.getElementById('scope-status');
|
||||||
|
statusEl.textContent = '{% if lang == "fa" %}در حال ذخیره...{% else %}Saving...{% endif %}';
|
||||||
|
statusEl.className = 'text-xs text-gray-400';
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sites/' + siteId + '/tool-scope', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ scope }),
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
statusEl.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %}';
|
||||||
|
statusEl.className = 'text-xs text-green-500';
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = '{% if lang == "fa" %}خطا{% else %}Error saving{% endif %}';
|
||||||
|
statusEl.className = 'text-xs text-red-500';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
statusEl.textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
|
||||||
|
statusEl.className = 'text-xs text-red-500';
|
||||||
|
}
|
||||||
|
setTimeout(() => statusEl.classList.add('hidden'), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render per-tool list grouped by category
|
||||||
|
const grouped = {};
|
||||||
|
for (const tool of data.tools) {
|
||||||
|
const cat = tool.category || 'read';
|
||||||
|
if (!grouped[cat]) grouped[cat] = [];
|
||||||
|
grouped[cat].push(tool);
|
||||||
|
}
|
||||||
|
const catOrder = ['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system'];
|
||||||
|
const container = document.getElementById('tool-list');
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (const cat of catOrder) {
|
||||||
|
if (!grouped[cat]) continue;
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'space-y-1';
|
||||||
|
const header = document.createElement('p');
|
||||||
|
header.className = 'text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2';
|
||||||
|
header.textContent = CAT_LABELS[cat] || cat;
|
||||||
|
section.appendChild(header);
|
||||||
|
for (const tool of grouped[cat]) {
|
||||||
|
section.appendChild(renderToolRow(tool));
|
||||||
|
}
|
||||||
|
container.appendChild(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScopeDesc(scope) {
|
||||||
|
const el = document.getElementById('scope-desc');
|
||||||
|
el.textContent = SCOPE_DESCS[scope] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolRow(tool) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.id = 'tool-row-' + tool.name;
|
||||||
|
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30';
|
||||||
|
|
||||||
|
const left = document.createElement('div');
|
||||||
|
left.className = 'flex items-center gap-2 flex-1 min-w-0';
|
||||||
|
|
||||||
|
const nameEl = document.createElement('span');
|
||||||
|
nameEl.className = 'text-sm text-gray-800 dark:text-gray-200 truncate font-mono';
|
||||||
|
nameEl.textContent = tool.name;
|
||||||
|
nameEl.title = tool.description || '';
|
||||||
|
left.appendChild(nameEl);
|
||||||
|
|
||||||
|
if (tool.sensitivity === 'sensitive') {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400';
|
||||||
|
badge.textContent = '{% if lang == "fa" %}حساس{% else %}sensitive{% endif %}';
|
||||||
|
left.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle switch
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'relative inline-flex items-center cursor-pointer flex-shrink-0';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'checkbox';
|
||||||
|
input.className = 'sr-only peer';
|
||||||
|
input.checked = tool.enabled !== false;
|
||||||
|
input.onchange = async () => {
|
||||||
|
const enabled = input.checked;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
if (!r.ok) { input.checked = !enabled; }
|
||||||
|
} catch (_) { input.checked = !enabled; }
|
||||||
|
};
|
||||||
|
const slider = document.createElement('div');
|
||||||
|
slider.className = 'w-9 h-5 bg-gray-300 dark:bg-gray-600 peer-checked:bg-blue-600 rounded-full peer peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 transition-colors after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4';
|
||||||
|
label.appendChild(input);
|
||||||
|
label.appendChild(slider);
|
||||||
|
|
||||||
|
row.appendChild(left);
|
||||||
|
row.appendChild(label);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadToolAccess);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -80,6 +80,10 @@
|
|||||||
id="test-btn-{{ site.id }}">
|
id="test-btn-{{ site.id }}">
|
||||||
{{ t.test_connection }}
|
{{ t.test_connection }}
|
||||||
</button>
|
</button>
|
||||||
|
<a href="/dashboard/sites/{{ site.id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500">
|
||||||
|
{{ t.get('connect', 'Connect') }}
|
||||||
|
</a>
|
||||||
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
{{ t.edit }}
|
{{ t.edit }}
|
||||||
|
|||||||
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 %}
|
||||||
134
core/templates/dashboard/sites/view.html
Normal file
134
core/templates/dashboard/sites/view.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ site.alias }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ site.alias }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-3xl mx-auto space-y-6">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ site.alias }}
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300">
|
||||||
|
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="ml-auto text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
{% if lang == 'fa' %}ویرایش{% else %}Edit{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MCP Endpoint URL -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
{% if lang == 'fa' %}آدرس MCP{% else %}MCP Endpoint{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code id="mcp-url" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto">
|
||||||
|
{{ mcp_url }}
|
||||||
|
</code>
|
||||||
|
<button onclick="copyMcpUrl()" class="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
|
||||||
|
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
برای احراز هویت از کلید API (Bearer) یا OAuth استفاده کنید.
|
||||||
|
{% else %}
|
||||||
|
Authenticate with an API key (Bearer token) or OAuth.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Snippets -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
{% if lang == 'fa' %}نمونه کدهای پیکربندی{% else %}Configuration Snippets{% endif %}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{% if lang == 'fa' %}انتخاب کلاینت{% else %}Select Client{% endif %}
|
||||||
|
</label>
|
||||||
|
<select id="config-client" onchange="updateConfig()"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||||
|
{% for client in clients %}
|
||||||
|
<option value="{{ client.id }}">{{ client.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[100px]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</pre>
|
||||||
|
<button onclick="copyConfig()" id="copy-config-btn"
|
||||||
|
class="absolute top-2 right-2 text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors">
|
||||||
|
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>نکته:</strong> از <code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code استفاده کنید.
|
||||||
|
{% else %}
|
||||||
|
<strong>Note:</strong> Use <code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>API Key:</strong> از صفحه <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">کلیدها</a> بسازید:
|
||||||
|
{% else %}
|
||||||
|
<strong>API Key:</strong> Create one on the <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">API Keys</a> page:
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const siteAlias = "{{ site.alias }}";
|
||||||
|
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
|
||||||
|
|
||||||
|
async function updateConfig() {
|
||||||
|
const client = document.getElementById('config-client')?.value;
|
||||||
|
if (!client) return;
|
||||||
|
const isWeb = WEB_CLIENTS.includes(client);
|
||||||
|
document.getElementById('transport-note').style.display = isWeb ? 'none' : '';
|
||||||
|
document.getElementById('bearer-hint').style.display = isWeb ? 'none' : '';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/config/' + siteAlias + '?client=' + client);
|
||||||
|
const data = await r.json();
|
||||||
|
document.getElementById('config-output').textContent = r.ok ? data.config : '{% if lang == "fa" %}خطا در بارگذاری{% else %}Error loading config{% endif %}';
|
||||||
|
} catch (_) {
|
||||||
|
document.getElementById('config-output').textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyMcpUrl() {
|
||||||
|
navigator.clipboard.writeText(document.getElementById('mcp-url').textContent.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyConfig() {
|
||||||
|
const text = document.getElementById('config-output').textContent;
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
const btn = document.getElementById('copy-config-btn');
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = '{% if lang == "fa" %}کپی شد!{% else %}Copied!{% endif %}';
|
||||||
|
setTimeout(() => btn.textContent = orig, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', updateConfig);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
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
532
core/tool_access.py
Normal file
532
core/tool_access.py
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
"""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. **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 without an entry are enabled by default.
|
||||||
|
|
||||||
|
The ``tool_scope`` value ``"custom"`` is a sentinel meaning "do not apply a
|
||||||
|
site-level preset filter" — in that case only the per-tool toggles and the
|
||||||
|
key scope are considered.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from core.tool_access import get_tool_access_manager
|
||||||
|
|
||||||
|
mgr = get_tool_access_manager()
|
||||||
|
visible = await mgr.get_visible_tools(
|
||||||
|
site_id=site["id"],
|
||||||
|
key_scopes=["read"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from core.tool_registry import ToolDefinition
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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.
|
||||||
|
SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
|
||||||
|
"read": {"read"},
|
||||||
|
"read:sensitive": {"read", "read_sensitive", "backup"},
|
||||||
|
"deploy": {"read", "lifecycle"},
|
||||||
|
"write": {"read", "lifecycle", "crud", "env"},
|
||||||
|
"admin": {
|
||||||
|
"read",
|
||||||
|
"read_sensitive",
|
||||||
|
"lifecycle",
|
||||||
|
"crud",
|
||||||
|
"env",
|
||||||
|
"backup",
|
||||||
|
"system",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# All known Coolify categories.
|
||||||
|
KNOWN_CATEGORIES: set[str] = {
|
||||||
|
"read",
|
||||||
|
"read_sensitive",
|
||||||
|
"lifecycle",
|
||||||
|
"crud",
|
||||||
|
"env",
|
||||||
|
"backup",
|
||||||
|
"system",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sentinel meaning "no site-level preset filter — use per-tool toggles only".
|
||||||
|
SCOPE_CUSTOM = "custom"
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scopes: List of scope strings as presented on the API key / token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of category names the scopes collectively allow.
|
||||||
|
"""
|
||||||
|
allowed: set[str] = set()
|
||||||
|
for scope in scopes:
|
||||||
|
allowed |= SCOPE_TO_CATEGORIES.get(scope.strip(), set())
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
def apply_scope_filter(
|
||||||
|
self,
|
||||||
|
tools: list[ToolDefinition],
|
||||||
|
scopes: list[str],
|
||||||
|
plugin_type: str | None = None,
|
||||||
|
) -> list[ToolDefinition]:
|
||||||
|
"""Drop tools not allowed by the presented scopes.
|
||||||
|
|
||||||
|
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:
|
||||||
|
return [t for t in tools if t.category not in KNOWN_CATEGORIES]
|
||||||
|
|
||||||
|
result: list[ToolDefinition] = []
|
||||||
|
for tool in tools:
|
||||||
|
if tool.category not in KNOWN_CATEGORIES:
|
||||||
|
result.append(tool)
|
||||||
|
continue
|
||||||
|
if tool.category in allowed:
|
||||||
|
result.append(tool)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def apply_site_toggles(
|
||||||
|
self,
|
||||||
|
tools: list[ToolDefinition],
|
||||||
|
site_id: str,
|
||||||
|
) -> list[ToolDefinition]:
|
||||||
|
"""Drop tools the site owner has explicitly disabled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tools: Candidate tool list.
|
||||||
|
site_id: Site UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered tool list.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_database()
|
||||||
|
except RuntimeError:
|
||||||
|
return tools
|
||||||
|
|
||||||
|
toggles = await db.get_site_tool_toggles(site_id)
|
||||||
|
if not toggles:
|
||||||
|
return tools
|
||||||
|
return [t for t in tools if toggles.get(t.name, True)]
|
||||||
|
|
||||||
|
async def get_visible_tools(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
key_scopes: list[str],
|
||||||
|
plugin_type: str,
|
||||||
|
) -> list[ToolDefinition]:
|
||||||
|
"""Return the visible tool list for a site on a given plugin.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
1. ``ToolRegistry.get_by_plugin_type``
|
||||||
|
2. Key-scope filter (API key's declared scopes)
|
||||||
|
3. Site-scope filter (site's stored ``tool_scope`` preset,
|
||||||
|
skipped when it is ``custom``)
|
||||||
|
4. Per-site toggle filter (``site_tool_toggles``)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID (the MCP endpoint alias resolves to this).
|
||||||
|
key_scopes: Scopes presented on the API key / token.
|
||||||
|
plugin_type: Plugin type (e.g. ``coolify``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of visible ``ToolDefinition`` objects.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tools = registry.get_by_plugin_type(plugin_type)
|
||||||
|
|
||||||
|
tools = self.apply_scope_filter(tools, key_scopes, plugin_type=plugin_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_database()
|
||||||
|
site_scope = await db.get_site_tool_scope(site_id)
|
||||||
|
except RuntimeError:
|
||||||
|
site_scope = "admin"
|
||||||
|
|
||||||
|
if site_scope and site_scope != SCOPE_CUSTOM:
|
||||||
|
tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type)
|
||||||
|
|
||||||
|
tools = await self.apply_site_toggles(tools, site_id)
|
||||||
|
return tools
|
||||||
|
|
||||||
|
async def toggle_tool(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
enabled: bool,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Enable or disable a single tool for a site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
tool_name: Fully-qualified tool name.
|
||||||
|
enabled: True to enable, False to disable.
|
||||||
|
reason: Optional note.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
await db.set_site_tool_toggle(site_id, tool_name, enabled, reason)
|
||||||
|
logger.info(
|
||||||
|
"site %s toggled %s → %s",
|
||||||
|
site_id,
|
||||||
|
tool_name,
|
||||||
|
"enabled" if enabled else "disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def bulk_toggle_by_scope(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
scope_name: str,
|
||||||
|
enabled: bool,
|
||||||
|
plugin_type: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Toggle every tool whose category belongs to the given scope.
|
||||||
|
|
||||||
|
Only the *exclusive* category set of the scope is affected — i.e.
|
||||||
|
the categories explicitly listed under ``SCOPE_TO_CATEGORIES[scope_name]``.
|
||||||
|
Tools outside those categories are left unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
scope_name: Scope key (``"read"``, ``"deploy"``, ...).
|
||||||
|
enabled: True to enable, False to disable.
|
||||||
|
plugin_type: Optional filter — only affect tools from this plugin.
|
||||||
|
When ``None`` every plugin's tools in that category are touched.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of tools affected.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
categories = SCOPE_TO_CATEGORIES.get(scope_name)
|
||||||
|
if categories is None:
|
||||||
|
raise ValueError(f"Unknown scope '{scope_name}'")
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
candidates = registry.get_all()
|
||||||
|
if plugin_type is not None:
|
||||||
|
candidates = [t for t in candidates if t.plugin_type == plugin_type]
|
||||||
|
affected = [t.name for t in candidates if t.category in categories]
|
||||||
|
|
||||||
|
if not affected:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
await db.bulk_set_site_tool_toggles(
|
||||||
|
site_id,
|
||||||
|
[(name, enabled) for name in affected],
|
||||||
|
reason=f"bulk:{scope_name}",
|
||||||
|
)
|
||||||
|
return len(affected)
|
||||||
|
|
||||||
|
async def list_tools_for_site(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
plugin_type: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return every tool for a plugin, annotated with per-site toggle state.
|
||||||
|
|
||||||
|
Used by the dashboard API to present the per-site management view.
|
||||||
|
Does not apply scope filters — the UI decides what to show.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
plugin_type: Plugin type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with tool metadata + ``enabled`` flag.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_database()
|
||||||
|
toggles = await db.get_site_tool_toggles(site_id)
|
||||||
|
except RuntimeError:
|
||||||
|
toggles = {}
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tools = registry.get_by_plugin_type(plugin_type)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": t.name,
|
||||||
|
"description": t.description,
|
||||||
|
"plugin_type": t.plugin_type,
|
||||||
|
"category": t.category,
|
||||||
|
"sensitivity": t.sensitivity,
|
||||||
|
"required_scope": t.required_scope,
|
||||||
|
"enabled": toggles.get(t.name, True),
|
||||||
|
}
|
||||||
|
for t in tools
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
_manager: ToolAccessManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_access_manager() -> ToolAccessManager:
|
||||||
|
"""Return the singleton :class:`ToolAccessManager`."""
|
||||||
|
global _manager
|
||||||
|
if _manager is None:
|
||||||
|
_manager = ToolAccessManager()
|
||||||
|
return _manager
|
||||||
@@ -181,6 +181,9 @@ class ToolGenerator:
|
|||||||
description = spec["description"]
|
description = spec["description"]
|
||||||
schema = spec["schema"]
|
schema = spec["schema"]
|
||||||
scope = spec.get("scope", "read")
|
scope = spec.get("scope", "read")
|
||||||
|
# F.7: optional category + sensitivity for scope-based visibility
|
||||||
|
category = spec.get("category", "read")
|
||||||
|
sensitivity = spec.get("sensitivity", "normal")
|
||||||
|
|
||||||
# Create full tool name
|
# Create full tool name
|
||||||
tool_name = f"{plugin_type}_{action_name}"
|
tool_name = f"{plugin_type}_{action_name}"
|
||||||
@@ -202,6 +205,8 @@ class ToolGenerator:
|
|||||||
handler=handler,
|
handler=handler,
|
||||||
required_scope=scope,
|
required_scope=scope,
|
||||||
plugin_type=plugin_type,
|
plugin_type=plugin_type,
|
||||||
|
category=category,
|
||||||
|
sensitivity=sensitivity,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _add_site_parameter(
|
def _add_site_parameter(
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class ToolDefinition(BaseModel):
|
|||||||
handler: Async function that executes the tool
|
handler: Async function that executes the tool
|
||||||
required_scope: Required API key scope ("read", "write", "admin")
|
required_scope: Required API key scope ("read", "write", "admin")
|
||||||
plugin_type: Plugin type this tool belongs to (e.g., "wordpress")
|
plugin_type: Plugin type this tool belongs to (e.g., "wordpress")
|
||||||
|
category: Tool category for scope-based visibility filtering (F.7)
|
||||||
|
One of: "read", "read_sensitive", "lifecycle", "crud", "env",
|
||||||
|
"backup", "system". Defaults to "read" for backward compatibility.
|
||||||
|
sensitivity: "normal" or "sensitive" (logs, envs, backups, connection strings).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str = Field(..., description="Unique tool identifier")
|
name: str = Field(..., description="Unique tool identifier")
|
||||||
@@ -40,6 +44,14 @@ class ToolDefinition(BaseModel):
|
|||||||
default="read", description="Required API key scope (read/write/admin)"
|
default="read", description="Required API key scope (read/write/admin)"
|
||||||
)
|
)
|
||||||
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
||||||
|
category: str = Field(
|
||||||
|
default="read",
|
||||||
|
description="Tool category for scope-based visibility (F.7)",
|
||||||
|
)
|
||||||
|
sensitivity: str = Field(
|
||||||
|
default="normal",
|
||||||
|
description="Data sensitivity: normal or sensitive (F.7)",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type
|
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ from typing import Any
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse, Response
|
from starlette.responses import JSONResponse, Response
|
||||||
|
|
||||||
|
from core.tool_registry import ToolDefinition
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Per-user rate limiting defaults
|
# Per-user rate limiting defaults
|
||||||
@@ -36,9 +38,6 @@ USER_RATE_LIMIT_PER_HR = int(os.getenv("USER_RATE_LIMIT_PER_HR", "500"))
|
|||||||
# In-memory rate limit tracking: user_id -> list of timestamps
|
# In-memory rate limit tracking: user_id -> list of timestamps
|
||||||
_rate_limits: dict[str, list[float]] = {}
|
_rate_limits: dict[str, list[float]] = {}
|
||||||
|
|
||||||
# Cache for tool schemas per plugin type (computed once)
|
|
||||||
_tool_schema_cache: dict[str, list[dict[str, Any]]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
|
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
|
||||||
"""Check per-user rate limits.
|
"""Check per-user rate limits.
|
||||||
@@ -71,24 +70,15 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
|
|||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
|
def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
|
||||||
"""Get MCP tool definitions for a plugin type (cached).
|
"""Convert ToolDefinition objects into MCP ``tools/list`` response shape.
|
||||||
|
|
||||||
Returns tool schemas with the ``site`` parameter removed
|
Strips the auto-injected ``site`` parameter, since user endpoints bind a
|
||||||
(auto-injected for user endpoints).
|
single site per alias.
|
||||||
"""
|
"""
|
||||||
if plugin_type in _tool_schema_cache:
|
|
||||||
return _tool_schema_cache[plugin_type]
|
|
||||||
|
|
||||||
from core.tool_registry import get_tool_registry
|
|
||||||
|
|
||||||
registry = get_tool_registry()
|
|
||||||
tools = registry.get_by_plugin_type(plugin_type)
|
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for tool_def in tools:
|
for tool_def in tools:
|
||||||
schema = deepcopy(tool_def.input_schema)
|
schema = deepcopy(tool_def.input_schema)
|
||||||
# Remove 'site' parameter (auto-injected)
|
|
||||||
if "properties" in schema:
|
if "properties" in schema:
|
||||||
schema["properties"].pop("site", None)
|
schema["properties"].pop("site", None)
|
||||||
if "required" in schema and "site" in schema["required"]:
|
if "required" in schema and "site" in schema["required"]:
|
||||||
@@ -101,11 +91,24 @@ def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
|
|||||||
"inputSchema": schema,
|
"inputSchema": schema,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
_tool_schema_cache[plugin_type] = result
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_visible_tools_for_site(
|
||||||
|
site_id: str,
|
||||||
|
key_scopes: list[str],
|
||||||
|
plugin_type: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return tools/list payload filtered by key scope + site scope + toggles (F.7b)."""
|
||||||
|
from core.tool_access import get_tool_access_manager
|
||||||
|
|
||||||
|
access = get_tool_access_manager()
|
||||||
|
tools = await access.get_visible_tools(
|
||||||
|
site_id=site_id, key_scopes=key_scopes, plugin_type=plugin_type
|
||||||
|
)
|
||||||
|
return _tools_to_mcp_schema(tools)
|
||||||
|
|
||||||
|
|
||||||
async def _execute_tool(
|
async def _execute_tool(
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
@@ -237,6 +240,28 @@ async def user_mcp_handler(request: Request) -> Response:
|
|||||||
_jsonrpc_error(None, -32600, "API key does not match user"),
|
_jsonrpc_error(None, -32600, "API key does not match user"),
|
||||||
status_code=403,
|
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()
|
key_scopes = key_info.get("scopes", "read").split()
|
||||||
else:
|
else:
|
||||||
# Try OAuth JWT token (issued after consent flow via GitHub/Google login)
|
# Try OAuth JWT token (issued after consent flow via GitHub/Google login)
|
||||||
@@ -353,7 +378,7 @@ async def user_mcp_handler(request: Request) -> Response:
|
|||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
elif method == "tools/list":
|
elif method == "tools/list":
|
||||||
tools = _get_tools_for_plugin(site["plugin_type"])
|
tools = await _get_visible_tools_for_site(site["id"], key_scopes, site["plugin_type"])
|
||||||
return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
|
return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
|
||||||
|
|
||||||
elif method == "tools/call":
|
elif method == "tools/call":
|
||||||
@@ -378,19 +403,56 @@ async def user_mcp_handler(request: Request) -> Response:
|
|||||||
required_scope = tool_def.required_scope
|
required_scope = tool_def.required_scope
|
||||||
# key_scopes is set during authentication (both mhu_ and JWT paths)
|
# key_scopes is set during authentication (both mhu_ and JWT paths)
|
||||||
|
|
||||||
|
# F.7b: enforce category-based scope allowlist in addition to the
|
||||||
|
# legacy read/write/admin hierarchy. A tool is allowed only if BOTH
|
||||||
|
# (a) the legacy hierarchy grants it, AND
|
||||||
|
# (b) the tool's category is in BOTH the key-scope set AND the
|
||||||
|
# site's stored tool_scope set (the narrower layer wins).
|
||||||
|
from core.tool_access import KNOWN_CATEGORIES, SCOPE_CUSTOM, scopes_to_categories
|
||||||
|
|
||||||
scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
|
scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
|
||||||
required_level = scope_hierarchy.get(required_scope, 0)
|
required_level = scope_hierarchy.get(required_scope, 0)
|
||||||
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0])
|
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0])
|
||||||
|
legacy_ok = key_level >= required_level
|
||||||
|
|
||||||
if key_level < required_level:
|
key_cats = scopes_to_categories(key_scopes)
|
||||||
|
key_category_ok = tool_def.category not in KNOWN_CATEGORIES or tool_def.category in key_cats
|
||||||
|
|
||||||
|
# Site-level scope check (skipped for "custom" preset).
|
||||||
|
site_scope = site.get("tool_scope") or "admin"
|
||||||
|
if site_scope and site_scope != SCOPE_CUSTOM:
|
||||||
|
site_cats = scopes_to_categories([site_scope])
|
||||||
|
site_category_ok = (
|
||||||
|
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in site_cats
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
site_category_ok = True
|
||||||
|
|
||||||
|
if not (legacy_ok and key_category_ok and site_category_ok):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
_jsonrpc_error(
|
_jsonrpc_error(
|
||||||
req_id,
|
req_id,
|
||||||
-32600,
|
-32600,
|
||||||
f"Insufficient scope. Tool '{tool_name}' requires '{required_scope}' scope.",
|
f"Insufficient scope. Tool '{tool_name}' requires "
|
||||||
|
f"scope '{required_scope}' (category '{tool_def.category}').",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# F.7b: honour per-site tool toggles — a disabled tool cannot be called
|
||||||
|
# even if scopes would otherwise allow it.
|
||||||
|
try:
|
||||||
|
toggles = await db.get_site_tool_toggles(site["id"])
|
||||||
|
if not toggles.get(tool_name, True):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(
|
||||||
|
req_id,
|
||||||
|
-32600,
|
||||||
|
f"Tool '{tool_name}' is disabled for this site.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc: # non-fatal — fall through on DB errors
|
||||||
|
logger.warning("Failed to check site tool toggles for %s: %s", site["id"], exc)
|
||||||
|
|
||||||
# Decrypt credentials
|
# Decrypt credentials
|
||||||
try:
|
try:
|
||||||
from core.encryption import get_credential_encryption
|
from core.encryption import get_credential_encryption
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ class UserKeyManager:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
# Cache: raw_key -> (key_id, user_id, scopes, cached_at)
|
# Cache: raw_key -> (key_id, user_id, scopes, site_id, cached_at)
|
||||||
self._cache: dict[str, tuple[str, str, str, float]] = {}
|
self._cache: dict[str, tuple[str, str, str, str | None, float]] = {}
|
||||||
|
|
||||||
async def create_key(
|
async def create_key(
|
||||||
self,
|
self,
|
||||||
@@ -58,6 +58,7 @@ class UserKeyManager:
|
|||||||
name: str,
|
name: str,
|
||||||
scopes: str = "read write admin",
|
scopes: str = "read write admin",
|
||||||
expires_in_days: int | None = None,
|
expires_in_days: int | None = None,
|
||||||
|
site_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a new API key for a user.
|
"""Create a new API key for a user.
|
||||||
|
|
||||||
@@ -66,10 +67,11 @@ class UserKeyManager:
|
|||||||
name: Human label (e.g. "Claude Desktop").
|
name: Human label (e.g. "Claude Desktop").
|
||||||
scopes: Access scopes (default: "read write admin" for full access).
|
scopes: Access scopes (default: "read write admin" for full access).
|
||||||
expires_in_days: Optional expiry in days from now. None = never.
|
expires_in_days: Optional expiry in days from now. None = never.
|
||||||
|
site_id: Optional site UUID to scope key to a single site.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with ``key`` (plaintext, shown once), ``key_id``, ``name``,
|
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
|
from core.database import get_database
|
||||||
|
|
||||||
@@ -89,9 +91,10 @@ class UserKeyManager:
|
|||||||
name=name,
|
name=name,
|
||||||
scopes=scopes,
|
scopes=scopes,
|
||||||
expires_at=expires_at,
|
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 {
|
return {
|
||||||
"key": raw_key, # shown once
|
"key": raw_key, # shown once
|
||||||
"key_id": row["id"],
|
"key_id": row["id"],
|
||||||
@@ -99,6 +102,7 @@ class UserKeyManager:
|
|||||||
"scopes": row["scopes"],
|
"scopes": row["scopes"],
|
||||||
"created_at": row["created_at"],
|
"created_at": row["created_at"],
|
||||||
"expires_at": row["expires_at"],
|
"expires_at": row["expires_at"],
|
||||||
|
"site_id": row.get("site_id"),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def validate_key(self, api_key: str) -> dict[str, Any] | None:
|
async def validate_key(self, api_key: str) -> dict[str, Any] | None:
|
||||||
@@ -118,7 +122,7 @@ class UserKeyManager:
|
|||||||
# Check cache first
|
# Check cache first
|
||||||
cached = self._cache.get(api_key)
|
cached = self._cache.get(api_key)
|
||||||
if cached is not None:
|
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:
|
if time.time() - cached_at < _CACHE_TTL_SECONDS:
|
||||||
# Update usage in background (fire-and-forget via DB)
|
# Update usage in background (fire-and-forget via DB)
|
||||||
try:
|
try:
|
||||||
@@ -130,7 +134,7 @@ class UserKeyManager:
|
|||||||
asyncio.create_task(db.update_api_key_usage(key_id))
|
asyncio.create_task(db.update_api_key_usage(key_id))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Non-critical
|
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:
|
else:
|
||||||
del self._cache[api_key]
|
del self._cache[api_key]
|
||||||
|
|
||||||
@@ -159,10 +163,12 @@ class UserKeyManager:
|
|||||||
await db.update_api_key_usage(row["id"])
|
await db.update_api_key_usage(row["id"])
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result
|
||||||
|
site_id = row.get("site_id")
|
||||||
self._cache[api_key] = (
|
self._cache[api_key] = (
|
||||||
row["id"],
|
row["id"],
|
||||||
row["user_id"],
|
row["user_id"],
|
||||||
row["scopes"],
|
row["scopes"],
|
||||||
|
site_id,
|
||||||
time.time(),
|
time.time(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -170,6 +176,7 @@ class UserKeyManager:
|
|||||||
"key_id": row["id"],
|
"key_id": row["id"],
|
||||||
"user_id": row["user_id"],
|
"user_id": row["user_id"],
|
||||||
"scopes": row["scopes"],
|
"scopes": row["scopes"],
|
||||||
|
"site_id": site_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def list_keys(self, user_id: str) -> list[dict[str, Any]]:
|
async def list_keys(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
|||||||
136
docs/prompts/next-session-f7-tool-access.md
Normal file
136
docs/prompts/next-session-f7-tool-access.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
|
||||||
|
|
||||||
|
## فایلهای مرجع
|
||||||
|
- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal) ← Phase F.7 طراحی کامل
|
||||||
|
- core/tool_generator.py (mcphub-internal) ← تولید ابزار فعلی
|
||||||
|
- core/tool_registry.py (mcphub-internal) ← رجیستری ابزار
|
||||||
|
- core/user_endpoints.py (mcphub-internal) ← فیلتر ابزار در endpoint کاربر
|
||||||
|
- core/user_keys.py (mcphub-internal) ← سیستم API key کاربر
|
||||||
|
- core/database.py (mcphub-internal) ← دیتابیس و مایگریشن
|
||||||
|
- core/plugin_visibility.py (mcphub-internal) ← فیلتر پلاگین فعلی
|
||||||
|
- core/dashboard/routes.py (mcphub-internal) ← روتهای داشبورد
|
||||||
|
- server.py (mcphub-internal) ← middleware و scope enforcement
|
||||||
|
- CLAUDE.md (mcphub-internal)
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
- MCPHub: v3.8.0 — 633 ابزار، 10 پلاگین، 67 ابزار Coolify
|
||||||
|
- تستها: 766 (internal), 734 (public)
|
||||||
|
- CI سبز
|
||||||
|
- Scope فعلی: read/write/admin (سه سطح ساده)
|
||||||
|
- فیلتر فعلی: فقط plugin-level (ENABLED_PLUGINS) — بدون per-tool toggle
|
||||||
|
|
||||||
|
## ریپازیتوری
|
||||||
|
- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
|
||||||
|
|
||||||
|
## هدف session: F.7 — Smart Tool Visibility & Scope-Based Access Control
|
||||||
|
|
||||||
|
### مشکلاتی که حل میشوند
|
||||||
|
1. همه ابزارهای یک پلاگین فعال به همه کاربران نشان داده میشوند — کنترل per-tool نداریم
|
||||||
|
2. وقتی کاربر API key با scope خاص (مثلا read) میسازد، باز هم همه ابزارها در tools/list نمایش داده میشوند
|
||||||
|
3. ابزارهای وردپرس که نیاز به افزونههای کمکی دارند (SEO Bridge, WP-CLI) بدون بررسی prerequisite نشان داده میشوند
|
||||||
|
4. کاربران نمیتوانند ابزارهایی که نیاز ندارند را غیرفعال کنند
|
||||||
|
|
||||||
|
### مدل scope پیشنهادی (گسترشیافته)
|
||||||
|
فعلی: `read`, `write`, `admin`
|
||||||
|
جدید:
|
||||||
|
- `deploy` — عملیات lifecycle (start/stop/restart/deploy) + read
|
||||||
|
- `read:sensitive` — read + لاگ، env var، بکاپ، connection string
|
||||||
|
|
||||||
|
**نگاشت scope → دستهبندی ابزار:**
|
||||||
|
| Scope | ابزارها |
|
||||||
|
|-------|---------|
|
||||||
|
| `read` | list_*, get_* (بدون sensitive) |
|
||||||
|
| `read:sensitive` | read + *_logs, *_envs, *_backups |
|
||||||
|
| `deploy` | read + start_*, stop_*, restart_*, deploy |
|
||||||
|
| `write` | deploy + create_*, update_*, delete_*_env |
|
||||||
|
| `admin` | write + delete_* (منابع)، create_server |
|
||||||
|
|
||||||
|
### بخش اول: Core — ساختار داده و مدیریت دسترسی (بدون UI)
|
||||||
|
|
||||||
|
#### مرحله ۱: دیتابیس
|
||||||
|
- [ ] جدول `user_tool_toggles` در `core/database.py`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_tool_toggles (
|
||||||
|
id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tool_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
reason TEXT, updated_at TEXT NOT NULL, UNIQUE(user_id, tool_name)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- [ ] جدول `scope_presets` — پریستهای scope سیستمی + سفارشی
|
||||||
|
- [ ] Migration اجرا شود
|
||||||
|
|
||||||
|
#### مرحله ۲: ماژول tool_access.py
|
||||||
|
- [ ] `core/tool_access.py` — کلاس `ToolAccessManager`
|
||||||
|
- [ ] `get_visible_tools(user_id, scopes, plugin_type)` → لیست فیلتر شده
|
||||||
|
- [ ] `apply_scope_filter(tools, scopes)` → فقط ابزارهای مجاز بر اساس scope
|
||||||
|
- [ ] `apply_user_toggles(tools, user_id)` → اعمال toggleهای کاربر
|
||||||
|
- [ ] `toggle_tool(user_id, tool_name, enabled)` → ذخیره تنظیم
|
||||||
|
- [ ] `bulk_toggle_by_scope(user_id, scope_name)` → فعال/غیرفعال دستهجمعی
|
||||||
|
|
||||||
|
#### مرحله ۳: Tool metadata enhancement
|
||||||
|
- [ ] اضافه کردن `sensitivity` و `category` به tool specs در handlerها:
|
||||||
|
- `sensitivity`: "normal" | "sensitive" (لاگ، env، بکاپ)
|
||||||
|
- `category`: "read" | "lifecycle" | "crud" | "env" | "backup" | "system"
|
||||||
|
- [ ] شروع از Coolify (آخرین و تمیزترین) سپس سایر پلاگینها
|
||||||
|
- [ ] ToolDefinition در tool_registry.py آپدیت شود
|
||||||
|
|
||||||
|
#### مرحله ۴: فیلتر در user_endpoints.py
|
||||||
|
- [ ] `_get_tools_for_plugin()` از ToolAccessManager استفاده کند
|
||||||
|
- [ ] Pipeline فیلتر:
|
||||||
|
1. plugin_visibility (موجود)
|
||||||
|
2. scope-to-tool mapping (جدید)
|
||||||
|
3. user toggles (جدید)
|
||||||
|
- [ ] Scope enforcement در middleware آپدیت شود (server.py)
|
||||||
|
|
||||||
|
#### مرحله ۵: تست
|
||||||
|
- [ ] `tests/test_tool_access.py` — unit tests
|
||||||
|
- [ ] تستهای scope mapping: key با scope "read" → فقط ابزارهای read
|
||||||
|
- [ ] تستهای toggle: کاربر disable کرده → ابزار در tools/list نیست
|
||||||
|
- [ ] تستهای integration: API key scope → فیلتر واقعی
|
||||||
|
|
||||||
|
### بخش دوم: API — روتهای مدیریت toggle
|
||||||
|
|
||||||
|
- [ ] `GET /api/user/tools` — لیست ابزارها با وضعیت toggle
|
||||||
|
- [ ] `PATCH /api/user/tools/{tool_name}` — تغییر toggle
|
||||||
|
- [ ] `POST /api/user/tools/bulk-toggle` — toggle دستهجمعی بر اساس scope
|
||||||
|
- [ ] `GET /api/user/scope-presets` — لیست presetها
|
||||||
|
- [ ] تست: روتها کار کنند
|
||||||
|
|
||||||
|
### بخش سوم: Prerequisites (وردپرس/ووکامرس)
|
||||||
|
|
||||||
|
- [ ] `check_prerequisites(tools, site_config)` در tool_access.py
|
||||||
|
- [ ] تشخیص SEO Bridge: `wp-json/airano-mcp-seo-bridge/v1/status`
|
||||||
|
- [ ] تشخیص WP-CLI: بررسی `container` field در credentials
|
||||||
|
- [ ] تشخیص WooCommerce: `wp-json/wc/v3/system_status`
|
||||||
|
- [ ] ابزارهای وابسته علامتگذاری شوند (نه حذف — فقط annotation)
|
||||||
|
|
||||||
|
### بخش چهارم: UI — صفحه مدیریت ابزار
|
||||||
|
|
||||||
|
- [ ] `core/templates/dashboard/tool-preferences.html`
|
||||||
|
- [ ] لیست ابزارها گروهبندی شده بر اساس category
|
||||||
|
- [ ] Toggle switch برای هر ابزار
|
||||||
|
- [ ] Badge برای prerequisite (نصب نشده / نیاز به Docker)
|
||||||
|
- [ ] Dropdown برای اعمال scope preset
|
||||||
|
- [ ] در صفحه Connect: پیشنمایش ابزارها هنگام ساخت API key
|
||||||
|
|
||||||
|
### بخش پنجم: ثبت و تست نهایی
|
||||||
|
|
||||||
|
- [ ] `uvx --python 3.12 black .`
|
||||||
|
- [ ] `uvx ruff check --fix .`
|
||||||
|
- [ ] `pytest` — همه تستها سبز
|
||||||
|
- [ ] Commit: `feat(F.7): add smart tool visibility and scope-based access control`
|
||||||
|
- [ ] Push و درخواست redeploy
|
||||||
|
- [ ] تست live: ساخت API key با scope "read" → بررسی tools/list
|
||||||
|
- [ ] Sync به نسخه عمومی
|
||||||
|
- [ ] آپدیت ورژن به v3.9.0 (اگر تأیید شد)
|
||||||
|
- [ ] حافظه آپدیت شود
|
||||||
|
- [ ] پلن آپدیت شود (F.7 complete)
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- فیلتر scope باید backward-compatible باشد — keyهای موجود بدون تغییر کار کنند
|
||||||
|
- Default: همه ابزارها فعال — فقط explicit disable ذخیره شود
|
||||||
|
- `user_tool_toggles` فقط overrideها رو ذخیره میکنه، نه همه ابزارها
|
||||||
|
- Prerequisite check باید non-blocking باشه — ابزار حذف نشه، فقط annotate بشه
|
||||||
|
- server.py نیازی به تغییر زیاد ندارد — فقط middleware scope check آپدیت شود
|
||||||
|
- ایمیل git داخلی: mcphub.dev@gmail.com
|
||||||
|
- بخش اول و دوم اولویت اصلی هستند — بخش سوم و چهارم اگر وقت شد
|
||||||
98
docs/prompts/next-session-f7b-ui.md
Normal file
98
docs/prompts/next-session-f7b-ui.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
|
||||||
|
|
||||||
|
## فایلهای مرجع
|
||||||
|
- docs/plans/2026-04-04-f7b-site-scoped-tool-access.md ← پلن کامل F.7b
|
||||||
|
- core/tool_access.py ← ToolAccessManager (سایتمحور — session 1)
|
||||||
|
- core/dashboard/routes.py ← روتهای API site tools (بخش F.7b)
|
||||||
|
- core/templates/dashboard/sites/edit.html ← صفحه edit سایت (بدون بخش tools)
|
||||||
|
- core/templates/dashboard/connect.html ← صفحه connect فعلی (config snippets + keys)
|
||||||
|
- core/templates/dashboard/api-keys/list.html ← صفحه admin کلیدها (UI بهتر)
|
||||||
|
- core/dashboard/routes.py::dashboard_connect_page / dashboard_api_keys_list
|
||||||
|
- CLAUDE.md (mcphub-internal)
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
- MCPHub: v3.8.0 + F.7b session 1 (commit روی Phase-1)
|
||||||
|
- Tests: 813 passed، CI سبز
|
||||||
|
- Backend F.7b کامل است: per-site tool_scope + site_tool_toggles + 4 روت جدید تحت `/api/sites/{site_id}/...` + `/api/scope-presets`
|
||||||
|
- فقط UI باقی مانده — هدف این session
|
||||||
|
|
||||||
|
## ریپازیتوری
|
||||||
|
- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
|
||||||
|
|
||||||
|
## هدف session: F.7b — UI + page merge
|
||||||
|
|
||||||
|
### ۱. بخش "Tool Access" در صفحه edit سایت
|
||||||
|
فایل: `core/templates/dashboard/sites/edit.html`
|
||||||
|
|
||||||
|
- [ ] اضافه کردن یک کارت جدید "Tool Access" بعد از فرم credentials
|
||||||
|
- [ ] Dropdown برای `tool_scope` (values: read / read:sensitive / deploy / write / admin / custom)
|
||||||
|
- PATCH روی `/api/sites/{site_id}/tool-scope` با body `{scope: "..."}`
|
||||||
|
- توضیح کوتاه کنار هر گزینه: "Read (X tools)" — شمارش زنده از `/api/sites/{site_id}/tools`
|
||||||
|
- [ ] Collapsible "Advanced — per-tool overrides":
|
||||||
|
- گرید/لیست گروهبندی شده بر اساس `category` (read / read_sensitive / lifecycle / crud / env / backup / system)
|
||||||
|
- Toggle switch برای هر ابزار → PATCH `/api/sites/{site_id}/tools/{tool_name}` با `{enabled: bool}`
|
||||||
|
- Badge قرمز برای `sensitivity=sensitive`
|
||||||
|
- نام کوتاه از `name`، tooltip با `description`
|
||||||
|
- [ ] استفاده از HTMX (در پروژه موجود است) برای updates بدون full reload
|
||||||
|
- [ ] CSRF token از cookie `dashboard_csrf` به header `X-CSRF-Token`
|
||||||
|
|
||||||
|
### ۲. انتقال config snippets از connect به صفحه سایت
|
||||||
|
فایل: `core/templates/dashboard/sites/view.html` (یا ایجاد اگر وجود ندارد)
|
||||||
|
|
||||||
|
- [ ] هر سایت در `/dashboard/sites/{id}` نمایش دهد:
|
||||||
|
- URL MCP مخصوص آن سایت: `{PUBLIC_URL}/u/{user_id}/{alias}/mcp`
|
||||||
|
- Tabs یا accordion با snippets برای Claude Desktop / Cursor / Zed / کلاینتهای دیگر
|
||||||
|
- استفاده از `core/config_snippets.py::get_supported_clients` (موجود)
|
||||||
|
- [ ] از صفحه `/dashboard/sites` (list) دکمه "Connect" به این صفحه لینک بزند
|
||||||
|
|
||||||
|
### ۳. ادغام `/dashboard/connect` و `/dashboard/api-keys` → `/dashboard/keys` (گزینه A)
|
||||||
|
UI مبنا: `core/templates/dashboard/api-keys/list.html` (قشنگتر و کاملتر است طبق تأیید کاربر)
|
||||||
|
|
||||||
|
- [ ] ساخت handler `dashboard_keys_unified(request)` که بر اساس session type branch میزند:
|
||||||
|
- OAuth user → نمایش `user_api_keys` برای آن کاربر
|
||||||
|
- Admin/master → نمایش کامل `api_keys` (همان view فعلی)
|
||||||
|
- [ ] template جدید `core/templates/dashboard/keys/list.html` با ادغام design از `api-keys/list.html`
|
||||||
|
- User view: سادهتر، scope selector در create dialog، لیست کلیدهای خود کاربر
|
||||||
|
- Admin view: فیلترهای کامل (project, status, search, pagination) — بدون تغییر
|
||||||
|
- [ ] **Scope selector در create-key dialog** — این بخش حیاتی است:
|
||||||
|
- Radio/select: read / read:sensitive / deploy / write / admin
|
||||||
|
- Helper text: "Per-site tool filters are set in Site Settings"
|
||||||
|
- POST به `/api/keys` (همان endpoint فعلی) با `scopes: "<selected>"`
|
||||||
|
- [ ] Redirect های قدیمی:
|
||||||
|
- `/dashboard/connect` → `/dashboard/keys` (301)
|
||||||
|
- `/dashboard/api-keys` → `/dashboard/keys` (301)
|
||||||
|
- [ ] حذف handler های قدیمی `dashboard_connect_page` و `dashboard_api_keys_list` و یا تبدیل به thin wrapper redirect
|
||||||
|
- [ ] منوی navigation sidebar را update کن — فقط یک entry "API Keys"
|
||||||
|
|
||||||
|
### ۴. گزینه B (ادغام عمیق DB) — deferred
|
||||||
|
در پلن session 1 ذکر شده اما اجرا نمیکنیم مگر کاربر صراحتاً درخواست کند. کامنت در code اضافه کنید به `api_create_key` که "dual-table model is intentional — see F.7b plan".
|
||||||
|
|
||||||
|
### ۵. تست
|
||||||
|
- [ ] `tests/test_dashboard_keys_unified.py` — تست منوی unified، scope selector، 301 redirect از URL های قدیمی
|
||||||
|
- [ ] `tests/test_sites_tool_access_ui.py` — smoke test که edit page با tool_scope=read درست render شود (میتوان با TestClient چک کرد که template بدون 500 میآید)
|
||||||
|
- [ ] بهروزرسانی `tests/test_dashboard.py::test_dashboard_connect_page` → به `/dashboard/keys` منتقل شود یا به پذیرش redirect
|
||||||
|
- [ ] pytest کامل سبز
|
||||||
|
|
||||||
|
### ۶. ورژن، sync، commit
|
||||||
|
- [ ] `uvx --python 3.12 black . && uvx --python 3.12 ruff check --fix .`
|
||||||
|
- [ ] bump version به `v3.9.0` در pyproject.toml + `__version__` در server.py (اگر وجود دارد)
|
||||||
|
- [ ] Commit: `feat(F.7b): tool access UI + unified keys page (v3.9.0)`
|
||||||
|
- [ ] Push به Phase-1
|
||||||
|
- [ ] `python3.11 scripts/community-build/sync.py --output ../mcphub/` سپس در repo عمومی `black` + `ruff`
|
||||||
|
- [ ] Commit عمومی با ایمیل `hi.airano@gmail.com` و push
|
||||||
|
- [ ] درخواست deploy از کاربر
|
||||||
|
|
||||||
|
### ۷. تست live پس از deploy
|
||||||
|
- [ ] ورود به `/dashboard/sites/{id}/edit` → بخش Tool Access → تغییر scope به `read` → save
|
||||||
|
- [ ] بدون ساخت کلید جدید، MCP client (همان کلید admin موجود) روی آن alias → `tools/list` باید فقط ابزارهای read را نشان دهد
|
||||||
|
- [ ] تغییر به `custom` → Advanced → disable یک ابزار خاص (مثلاً `coolify_delete_server`) → تست
|
||||||
|
- [ ] ساخت کلید جدید از صفحه unified با scope=`read` → بررسی در لیست
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- **Backward compatibility:** سایتهای موجود `tool_scope='admin'` دارند (default migration v7) → هیچ تغییر رفتاری روی سایتهای قدیمی
|
||||||
|
- **فیلترها:** key scope و site scope **intersect** میشوند. admin key + site=read → فقط read. write key + site=deploy → فقط read + lifecycle.
|
||||||
|
- **CSRF:** middleware روی `/api/sites/*` فعال است. UI باید header `X-CSRF-Token` از cookie `dashboard_csrf` بفرستد. HTMX این را با `hx-headers` هندل میکند.
|
||||||
|
- **CSS:** پروژه Tailwind دارد. از همان کلاسهای موجود در `api-keys/list.html` استفاده کن برای consistency.
|
||||||
|
- **i18n:** پروژه EN/FA است. متنهای جدید را به `core/i18n.py` اضافه کن.
|
||||||
|
- **ایمیل git داخلی:** mcphub.dev@gmail.com | ایمیل عمومی: hi.airano@gmail.com
|
||||||
|
- **نباید:** توابع F.7 v1 (با `user_` prefix) را بازگردانی کنی. همه سایتمحور است.
|
||||||
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "list_applications",
|
"name": "list_applications",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_applications",
|
"method_name": "list_applications",
|
||||||
"description": "List all Coolify applications. Optionally filter by tag name.",
|
"description": "List all Coolify applications. Optionally filter by tag name.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -26,6 +27,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_application",
|
"name": "get_application",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_application",
|
"method_name": "get_application",
|
||||||
"description": "Get details of a specific Coolify application by UUID.",
|
"description": "Get details of a specific Coolify application by UUID.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -43,6 +45,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_application_public",
|
"name": "create_application_public",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_application_public",
|
"method_name": "create_application_public",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a Coolify application from a public Git repository. "
|
"Create a Coolify application from a public Git repository. "
|
||||||
@@ -113,6 +116,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_application_dockerfile",
|
"name": "create_application_dockerfile",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_application_dockerfile",
|
"method_name": "create_application_dockerfile",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a Coolify application from a Dockerfile (without git). "
|
"Create a Coolify application from a Dockerfile (without git). "
|
||||||
@@ -170,6 +174,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_application_docker_image",
|
"name": "create_application_docker_image",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_application_docker_image",
|
"method_name": "create_application_docker_image",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a Coolify application from a Docker image. "
|
"Create a Coolify application from a Docker image. "
|
||||||
@@ -233,6 +238,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_application_compose",
|
"name": "create_application_compose",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_application_compose",
|
"method_name": "create_application_compose",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a Coolify application from Docker Compose. "
|
"Create a Coolify application from Docker Compose. "
|
||||||
@@ -278,6 +284,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_application",
|
"name": "update_application",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "update_application",
|
"method_name": "update_application",
|
||||||
"description": (
|
"description": (
|
||||||
"Update a Coolify application settings. Supports name, description, "
|
"Update a Coolify application settings. Supports name, description, "
|
||||||
@@ -357,6 +364,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_application",
|
"name": "delete_application",
|
||||||
|
"category": "system",
|
||||||
"method_name": "delete_application",
|
"method_name": "delete_application",
|
||||||
"description": (
|
"description": (
|
||||||
"Delete a Coolify application permanently. "
|
"Delete a Coolify application permanently. "
|
||||||
@@ -397,6 +405,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "start_application",
|
"name": "start_application",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "start_application",
|
"method_name": "start_application",
|
||||||
"description": "Deploy/start a Coolify application. Triggers a new deployment.",
|
"description": "Deploy/start a Coolify application. Triggers a new deployment.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -424,6 +433,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "stop_application",
|
"name": "stop_application",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "stop_application",
|
"method_name": "stop_application",
|
||||||
"description": "Stop a running Coolify application.",
|
"description": "Stop a running Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -446,6 +456,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "restart_application",
|
"name": "restart_application",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "restart_application",
|
"method_name": "restart_application",
|
||||||
"description": "Restart a Coolify application.",
|
"description": "Restart a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -463,6 +474,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_application_logs",
|
"name": "get_application_logs",
|
||||||
|
"category": "read_sensitive",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "get_application_logs",
|
"method_name": "get_application_logs",
|
||||||
"description": "Get logs for a Coolify application.",
|
"description": "Get logs for a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -487,6 +500,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "list_application_envs",
|
"name": "list_application_envs",
|
||||||
|
"category": "read_sensitive",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "list_application_envs",
|
"method_name": "list_application_envs",
|
||||||
"description": "List environment variables for a Coolify application.",
|
"description": "List environment variables for a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -504,6 +519,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_application_env",
|
"name": "create_application_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "create_application_env",
|
"method_name": "create_application_env",
|
||||||
"description": "Create an environment variable for a Coolify application.",
|
"description": "Create an environment variable for a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -550,6 +567,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_application_env",
|
"name": "update_application_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "update_application_env",
|
"method_name": "update_application_env",
|
||||||
"description": "Update an environment variable for a Coolify application.",
|
"description": "Update an environment variable for a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -592,6 +611,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_application_envs_bulk",
|
"name": "update_application_envs_bulk",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "update_application_envs_bulk",
|
"method_name": "update_application_envs_bulk",
|
||||||
"description": "Bulk update environment variables for a Coolify application.",
|
"description": "Bulk update environment variables for a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -625,6 +646,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_application_env",
|
"name": "delete_application_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "delete_application_env",
|
"method_name": "delete_application_env",
|
||||||
"description": "Delete an environment variable from a Coolify application.",
|
"description": "Delete an environment variable from a Coolify application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ def _create_db_spec(db_type: str, description: str) -> dict[str, Any]:
|
|||||||
"""Generate a create_* tool spec for a database type."""
|
"""Generate a create_* tool spec for a database type."""
|
||||||
return {
|
return {
|
||||||
"name": f"create_{db_type}",
|
"name": f"create_{db_type}",
|
||||||
|
"category": "crud",
|
||||||
"method_name": f"create_{db_type}",
|
"method_name": f"create_{db_type}",
|
||||||
"description": description,
|
"description": description,
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -55,6 +56,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
specs = [
|
specs = [
|
||||||
{
|
{
|
||||||
"name": "list_databases",
|
"name": "list_databases",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_databases",
|
"method_name": "list_databases",
|
||||||
"description": "List all Coolify databases.",
|
"description": "List all Coolify databases.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -65,6 +67,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_database",
|
"name": "get_database",
|
||||||
|
"category": "read_sensitive",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "get_database",
|
"method_name": "get_database",
|
||||||
"description": "Get details of a specific Coolify database by UUID.",
|
"description": "Get details of a specific Coolify database by UUID.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -82,6 +86,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_database",
|
"name": "update_database",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "update_database",
|
"method_name": "update_database",
|
||||||
"description": "Update a Coolify database settings.",
|
"description": "Update a Coolify database settings.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -119,6 +124,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_database",
|
"name": "delete_database",
|
||||||
|
"category": "system",
|
||||||
"method_name": "delete_database",
|
"method_name": "delete_database",
|
||||||
"description": (
|
"description": (
|
||||||
"Delete a Coolify database permanently. "
|
"Delete a Coolify database permanently. "
|
||||||
@@ -154,6 +160,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "start_database",
|
"name": "start_database",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "start_database",
|
"method_name": "start_database",
|
||||||
"description": "Start a Coolify database.",
|
"description": "Start a Coolify database.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -171,6 +178,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "stop_database",
|
"name": "stop_database",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "stop_database",
|
"method_name": "stop_database",
|
||||||
"description": "Stop a running Coolify database.",
|
"description": "Stop a running Coolify database.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -188,6 +196,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "restart_database",
|
"name": "restart_database",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "restart_database",
|
"method_name": "restart_database",
|
||||||
"description": "Restart a Coolify database.",
|
"description": "Restart a Coolify database.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -222,6 +231,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "get_database_backups",
|
"name": "get_database_backups",
|
||||||
|
"category": "backup",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "get_database_backups",
|
"method_name": "get_database_backups",
|
||||||
"description": "Get backup configuration and history for a Coolify database.",
|
"description": "Get backup configuration and history for a Coolify database.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -239,6 +250,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_database_backup",
|
"name": "create_database_backup",
|
||||||
|
"category": "backup",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "create_database_backup",
|
"method_name": "create_database_backup",
|
||||||
"description": "Create a manual backup of a Coolify database.",
|
"description": "Create a manual backup of a Coolify database.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -256,6 +269,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "list_backup_executions",
|
"name": "list_backup_executions",
|
||||||
|
"category": "backup",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "list_backup_executions",
|
"method_name": "list_backup_executions",
|
||||||
"description": "List all backup executions across all databases.",
|
"description": "List all backup executions across all databases.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "list_deployments",
|
"name": "list_deployments",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_deployments",
|
"method_name": "list_deployments",
|
||||||
"description": "List all running deployments on the Coolify instance.",
|
"description": "List all running deployments on the Coolify instance.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_deployment",
|
"name": "get_deployment",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_deployment",
|
"method_name": "get_deployment",
|
||||||
"description": "Get details of a specific deployment by UUID.",
|
"description": "Get details of a specific deployment by UUID.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "cancel_deployment",
|
"name": "cancel_deployment",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "cancel_deployment",
|
"method_name": "cancel_deployment",
|
||||||
"description": "Cancel a running deployment.",
|
"description": "Cancel a running deployment.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -55,6 +58,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deploy",
|
"name": "deploy",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "deploy",
|
"method_name": "deploy",
|
||||||
"description": (
|
"description": (
|
||||||
"Trigger deployment by tag name or resource UUID. "
|
"Trigger deployment by tag name or resource UUID. "
|
||||||
@@ -82,6 +86,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "list_app_deployments",
|
"name": "list_app_deployments",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_app_deployments",
|
"method_name": "list_app_deployments",
|
||||||
"description": "List deployment history for a specific application.",
|
"description": "List deployment history for a specific application.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "list_projects",
|
"name": "list_projects",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_projects",
|
"method_name": "list_projects",
|
||||||
"description": "List all Coolify projects.",
|
"description": "List all Coolify projects.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_project",
|
"name": "get_project",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_project",
|
"method_name": "get_project",
|
||||||
"description": "Get details of a specific Coolify project by UUID.",
|
"description": "Get details of a specific Coolify project by UUID.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_project",
|
"name": "create_project",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_project",
|
"method_name": "create_project",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a new Coolify project. "
|
"Create a new Coolify project. "
|
||||||
@@ -62,6 +65,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_project",
|
"name": "update_project",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "update_project",
|
"method_name": "update_project",
|
||||||
"description": "Update a Coolify project name or description.",
|
"description": "Update a Coolify project name or description.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -87,6 +91,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_project",
|
"name": "delete_project",
|
||||||
|
"category": "system",
|
||||||
"method_name": "delete_project",
|
"method_name": "delete_project",
|
||||||
"description": (
|
"description": (
|
||||||
"Delete a Coolify project permanently. "
|
"Delete a Coolify project permanently. "
|
||||||
@@ -107,6 +112,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "list_environments",
|
"name": "list_environments",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_environments",
|
"method_name": "list_environments",
|
||||||
"description": "List all environments in a Coolify project.",
|
"description": "List all environments in a Coolify project.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -124,6 +130,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_environment",
|
"name": "get_environment",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_environment",
|
"method_name": "get_environment",
|
||||||
"description": ("Get details of a specific environment in a Coolify project by name."),
|
"description": ("Get details of a specific environment in a Coolify project by name."),
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -146,6 +153,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_environment",
|
"name": "create_environment",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_environment",
|
"method_name": "create_environment",
|
||||||
"description": "Create a new environment in a Coolify project.",
|
"description": "Create a new environment in a Coolify project.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "list_servers",
|
"name": "list_servers",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_servers",
|
"method_name": "list_servers",
|
||||||
"description": "List all servers registered in the Coolify instance.",
|
"description": "List all servers registered in the Coolify instance.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_server",
|
"name": "get_server",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_server",
|
"method_name": "get_server",
|
||||||
"description": "Get details of a specific server by UUID, including settings.",
|
"description": "Get details of a specific server by UUID, including settings.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_server",
|
"name": "create_server",
|
||||||
|
"category": "system",
|
||||||
"method_name": "create_server",
|
"method_name": "create_server",
|
||||||
"description": (
|
"description": (
|
||||||
"Register a new server in Coolify. "
|
"Register a new server in Coolify. "
|
||||||
@@ -98,6 +101,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_server",
|
"name": "update_server",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "update_server",
|
"method_name": "update_server",
|
||||||
"description": "Update server configuration.",
|
"description": "Update server configuration.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -142,6 +146,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_server",
|
"name": "delete_server",
|
||||||
|
"category": "system",
|
||||||
"method_name": "delete_server",
|
"method_name": "delete_server",
|
||||||
"description": "Delete a server from Coolify. This cannot be undone!",
|
"description": "Delete a server from Coolify. This cannot be undone!",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -159,6 +164,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_server_resources",
|
"name": "get_server_resources",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_server_resources",
|
"method_name": "get_server_resources",
|
||||||
"description": (
|
"description": (
|
||||||
"Get all resources (applications, databases, services) "
|
"Get all resources (applications, databases, services) "
|
||||||
@@ -179,6 +185,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_server_domains",
|
"name": "get_server_domains",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_server_domains",
|
"method_name": "get_server_domains",
|
||||||
"description": "Get all domains configured on a specific server.",
|
"description": "Get all domains configured on a specific server.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -196,6 +203,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "validate_server",
|
"name": "validate_server",
|
||||||
|
"category": "read",
|
||||||
"method_name": "validate_server",
|
"method_name": "validate_server",
|
||||||
"description": "Validate server connectivity and configuration.",
|
"description": "Validate server connectivity and configuration.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "list_services",
|
"name": "list_services",
|
||||||
|
"category": "read",
|
||||||
"method_name": "list_services",
|
"method_name": "list_services",
|
||||||
"description": "List all Coolify services.",
|
"description": "List all Coolify services.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_service",
|
"name": "get_service",
|
||||||
|
"category": "read",
|
||||||
"method_name": "get_service",
|
"method_name": "get_service",
|
||||||
"description": "Get details of a specific Coolify service by UUID.",
|
"description": "Get details of a specific Coolify service by UUID.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_service",
|
"name": "create_service",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "create_service",
|
"method_name": "create_service",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a Coolify service from a predefined template. "
|
"Create a Coolify service from a predefined template. "
|
||||||
@@ -94,6 +97,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_service",
|
"name": "update_service",
|
||||||
|
"category": "crud",
|
||||||
"method_name": "update_service",
|
"method_name": "update_service",
|
||||||
"description": "Update a Coolify service settings.",
|
"description": "Update a Coolify service settings.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -123,6 +127,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_service",
|
"name": "delete_service",
|
||||||
|
"category": "system",
|
||||||
"method_name": "delete_service",
|
"method_name": "delete_service",
|
||||||
"description": (
|
"description": (
|
||||||
"Delete a Coolify service permanently. "
|
"Delete a Coolify service permanently. "
|
||||||
@@ -158,6 +163,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "start_service",
|
"name": "start_service",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "start_service",
|
"method_name": "start_service",
|
||||||
"description": "Start a Coolify service.",
|
"description": "Start a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -175,6 +181,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "stop_service",
|
"name": "stop_service",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "stop_service",
|
"method_name": "stop_service",
|
||||||
"description": "Stop a running Coolify service.",
|
"description": "Stop a running Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -192,6 +199,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "restart_service",
|
"name": "restart_service",
|
||||||
|
"category": "lifecycle",
|
||||||
"method_name": "restart_service",
|
"method_name": "restart_service",
|
||||||
"description": "Restart a Coolify service.",
|
"description": "Restart a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -209,6 +217,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "list_service_envs",
|
"name": "list_service_envs",
|
||||||
|
"category": "read_sensitive",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "list_service_envs",
|
"method_name": "list_service_envs",
|
||||||
"description": "List environment variables for a Coolify service.",
|
"description": "List environment variables for a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -226,6 +236,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "create_service_env",
|
"name": "create_service_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "create_service_env",
|
"method_name": "create_service_env",
|
||||||
"description": "Create an environment variable for a Coolify service.",
|
"description": "Create an environment variable for a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -257,6 +269,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_service_env",
|
"name": "update_service_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "update_service_env",
|
"method_name": "update_service_env",
|
||||||
"description": "Update an environment variable for a Coolify service.",
|
"description": "Update an environment variable for a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -287,6 +301,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_service_envs_bulk",
|
"name": "update_service_envs_bulk",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "update_service_envs_bulk",
|
"method_name": "update_service_envs_bulk",
|
||||||
"description": "Bulk update environment variables for a Coolify service.",
|
"description": "Bulk update environment variables for a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -317,6 +333,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "delete_service_env",
|
"name": "delete_service_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
"method_name": "delete_service_env",
|
"method_name": "delete_service_env",
|
||||||
"description": "Delete an environment variable from a Coolify service.",
|
"description": "Delete an environment variable from a Coolify service.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcphub-server"
|
name = "mcphub-server"
|
||||||
version = "3.8.0"
|
version = "3.11.0"
|
||||||
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
|
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
||||||
|
|||||||
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
|
||||||
50
server.py
50
server.py
@@ -58,16 +58,22 @@ from core import (
|
|||||||
set_api_key_context,
|
set_api_key_context,
|
||||||
)
|
)
|
||||||
from core.dashboard.routes import (
|
from core.dashboard.routes import (
|
||||||
# E.3: Site Management routes
|
# F.7b: Per-site tool visibility
|
||||||
|
api_bulk_toggle_site_tools,
|
||||||
api_create_key,
|
api_create_key,
|
||||||
|
# E.3: Site Management routes
|
||||||
api_create_site,
|
api_create_site,
|
||||||
api_delete_key,
|
api_delete_key,
|
||||||
api_delete_site,
|
api_delete_site,
|
||||||
api_get_config,
|
api_get_config,
|
||||||
api_list_keys,
|
api_list_keys,
|
||||||
|
api_list_site_tools,
|
||||||
api_list_sites,
|
api_list_sites,
|
||||||
|
api_patch_site_tool,
|
||||||
# K.5: Settings routes
|
# K.5: Settings routes
|
||||||
api_save_setting,
|
api_save_setting,
|
||||||
|
api_scope_presets,
|
||||||
|
api_set_site_tool_scope,
|
||||||
api_test_site,
|
api_test_site,
|
||||||
api_update_site,
|
api_update_site,
|
||||||
# E.2: OAuth Social Login routes
|
# E.2: OAuth Social Login routes
|
||||||
@@ -80,7 +86,6 @@ from core.dashboard.routes import (
|
|||||||
dashboard_api_keys_create,
|
dashboard_api_keys_create,
|
||||||
dashboard_api_keys_delete,
|
dashboard_api_keys_delete,
|
||||||
# K.3: API Keys routes
|
# K.3: API Keys routes
|
||||||
dashboard_api_keys_list,
|
|
||||||
dashboard_api_keys_revoke,
|
dashboard_api_keys_revoke,
|
||||||
dashboard_api_project_detail,
|
dashboard_api_project_detail,
|
||||||
dashboard_api_projects,
|
dashboard_api_projects,
|
||||||
@@ -88,11 +93,11 @@ from core.dashboard.routes import (
|
|||||||
# K.4: Audit Logs routes
|
# K.4: Audit Logs routes
|
||||||
dashboard_audit_logs_list,
|
dashboard_audit_logs_list,
|
||||||
# E.3: Dashboard pages
|
# E.3: Dashboard pages
|
||||||
dashboard_connect_page,
|
|
||||||
# K.5: Health Monitoring routes
|
|
||||||
dashboard_health_page,
|
dashboard_health_page,
|
||||||
dashboard_health_projects_partial,
|
dashboard_health_projects_partial,
|
||||||
dashboard_home,
|
dashboard_home,
|
||||||
|
# F.7b session 2: Unified keys page
|
||||||
|
dashboard_keys_unified,
|
||||||
dashboard_login_page,
|
dashboard_login_page,
|
||||||
dashboard_login_submit,
|
dashboard_login_submit,
|
||||||
dashboard_logout,
|
dashboard_logout,
|
||||||
@@ -114,6 +119,7 @@ from core.dashboard.routes import (
|
|||||||
dashboard_sites_add,
|
dashboard_sites_add,
|
||||||
dashboard_sites_edit,
|
dashboard_sites_edit,
|
||||||
dashboard_sites_list,
|
dashboard_sites_list,
|
||||||
|
dashboard_sites_view,
|
||||||
# Bug C: User OAuth client routes
|
# Bug C: User OAuth client routes
|
||||||
dashboard_user_oauth_clients_create,
|
dashboard_user_oauth_clients_create,
|
||||||
dashboard_user_oauth_clients_delete,
|
dashboard_user_oauth_clients_delete,
|
||||||
@@ -4783,6 +4789,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
|
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
|
||||||
Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]),
|
Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]),
|
||||||
Route("/dashboard/sites/{id}/edit", dashboard_sites_edit, methods=["GET"]),
|
Route("/dashboard/sites/{id}/edit", dashboard_sites_edit, methods=["GET"]),
|
||||||
|
Route("/dashboard/sites/{id}", dashboard_sites_view, methods=["GET"]),
|
||||||
Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]),
|
Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]),
|
||||||
# Bug C: User OAuth client routes (must be before /dashboard/connect)
|
# Bug C: User OAuth client routes (must be before /dashboard/connect)
|
||||||
Route(
|
Route(
|
||||||
@@ -4790,7 +4797,12 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
dashboard_user_oauth_clients_list,
|
dashboard_user_oauth_clients_list,
|
||||||
methods=["GET"],
|
methods=["GET"],
|
||||||
),
|
),
|
||||||
Route("/dashboard/connect", dashboard_connect_page, methods=["GET"]),
|
# F.7b session 2: /dashboard/connect → /dashboard/keys (301)
|
||||||
|
Route(
|
||||||
|
"/dashboard/connect",
|
||||||
|
lambda r: RedirectResponse("/dashboard/keys", status_code=301),
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
# F.3: Service pages (must be before /dashboard catch-all)
|
# F.3: Service pages (must be before /dashboard catch-all)
|
||||||
Route("/dashboard/services", dashboard_services_list, methods=["GET"]),
|
Route("/dashboard/services", dashboard_services_list, methods=["GET"]),
|
||||||
Route("/dashboard/services/{plugin_type}", dashboard_service_page, methods=["GET"]),
|
Route("/dashboard/services/{plugin_type}", dashboard_service_page, methods=["GET"]),
|
||||||
@@ -4812,8 +4824,14 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
dashboard_api_project_detail,
|
dashboard_api_project_detail,
|
||||||
methods=["GET"],
|
methods=["GET"],
|
||||||
),
|
),
|
||||||
# Dashboard API Keys routes (Phase K.3)
|
# Dashboard API Keys routes (Phase K.3 + F.7b session 2: unified /dashboard/keys)
|
||||||
Route("/dashboard/api-keys", dashboard_api_keys_list, methods=["GET"]),
|
Route("/dashboard/keys", dashboard_keys_unified, methods=["GET"]),
|
||||||
|
# /dashboard/api-keys → /dashboard/keys (301)
|
||||||
|
Route(
|
||||||
|
"/dashboard/api-keys",
|
||||||
|
lambda r: RedirectResponse("/dashboard/keys", status_code=301),
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
Route("/api/dashboard/api-keys/create", dashboard_api_keys_create, methods=["POST"]),
|
Route("/api/dashboard/api-keys/create", dashboard_api_keys_create, methods=["POST"]),
|
||||||
Route(
|
Route(
|
||||||
"/api/dashboard/api-keys/{key_id}/revoke", dashboard_api_keys_revoke, methods=["POST"]
|
"/api/dashboard/api-keys/{key_id}/revoke", dashboard_api_keys_revoke, methods=["POST"]
|
||||||
@@ -4862,6 +4880,24 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
Route("/api/keys/{id}", api_delete_key, methods=["DELETE"]),
|
Route("/api/keys/{id}", api_delete_key, methods=["DELETE"]),
|
||||||
# Config snippet API (E.3)
|
# Config snippet API (E.3)
|
||||||
Route("/api/config/{alias}", api_get_config, methods=["GET"]),
|
Route("/api/config/{alias}", api_get_config, methods=["GET"]),
|
||||||
|
# F.7b: Per-site tool visibility management
|
||||||
|
Route("/api/sites/{site_id}/tools", api_list_site_tools, methods=["GET"]),
|
||||||
|
Route(
|
||||||
|
"/api/sites/{site_id}/tools/bulk-toggle",
|
||||||
|
api_bulk_toggle_site_tools,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/api/sites/{site_id}/tools/{tool_name}",
|
||||||
|
api_patch_site_tool,
|
||||||
|
methods=["PATCH"],
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/api/sites/{site_id}/tool-scope",
|
||||||
|
api_set_site_tool_scope,
|
||||||
|
methods=["PATCH"],
|
||||||
|
),
|
||||||
|
Route("/api/scope-presets", api_scope_presets, methods=["GET"]),
|
||||||
# OAuth endpoints
|
# OAuth endpoints
|
||||||
Route("/.well-known/oauth-authorization-server", oauth_metadata, methods=["GET"]),
|
Route("/.well-known/oauth-authorization-server", oauth_metadata, methods=["GET"]),
|
||||||
# Path-specific OAuth protected resource metadata (must come before root)
|
# Path-specific OAuth protected resource metadata (must come before root)
|
||||||
|
|||||||
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]
|
||||||
@@ -374,37 +374,15 @@ class TestDashboardCookieManagement:
|
|||||||
|
|
||||||
|
|
||||||
def test_dashboard_connect_page(monkeypatch):
|
def test_dashboard_connect_page(monkeypatch):
|
||||||
"""Test that the /dashboard/connect page renders successfully without 500 errors."""
|
"""Test that /dashboard/connect redirects to /dashboard/keys (F.7b session 2)."""
|
||||||
from server import create_multi_endpoint_app
|
from server import create_multi_endpoint_app
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
import core.dashboard.routes
|
|
||||||
import core.site_api
|
|
||||||
import core.user_keys
|
|
||||||
|
|
||||||
app = create_multi_endpoint_app()
|
app = create_multi_endpoint_app()
|
||||||
client = TestClient(app)
|
client = TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
def mock_req(*args):
|
|
||||||
return {"user_id": "abc", "type": "user"}, None
|
|
||||||
|
|
||||||
monkeypatch.setattr(core.dashboard.routes, "_require_user_session", mock_req)
|
|
||||||
|
|
||||||
async def mock_sites(*args):
|
|
||||||
return [{"alias": "Test", "plugin_type": "dummy"}]
|
|
||||||
|
|
||||||
monkeypatch.setattr(core.site_api, "get_user_sites", mock_sites)
|
|
||||||
|
|
||||||
class MockKeyMgr:
|
|
||||||
async def list_keys(self, *a):
|
|
||||||
return [
|
|
||||||
{"id": "1", "name": "Key", "key_prefix": "prefix", "scopes": "all", "use_count": 0}
|
|
||||||
]
|
|
||||||
|
|
||||||
monkeypatch.setattr(core.user_keys, "get_user_key_manager", lambda: MockKeyMgr())
|
|
||||||
|
|
||||||
resp = client.get("/dashboard/connect")
|
resp = client.get("/dashboard/connect")
|
||||||
|
|
||||||
assert resp.status_code == 200
|
# /dashboard/connect now redirects 301 to /dashboard/keys
|
||||||
assert "Test" in resp.text
|
assert resp.status_code == 301
|
||||||
assert "Key" in resp.text
|
assert "/dashboard/keys" in resp.headers["location"]
|
||||||
|
|||||||
81
tests/test_dashboard_keys_unified.py
Normal file
81
tests/test_dashboard_keys_unified.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Tests for the unified /dashboard/keys page (F.7b session 2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import core.dashboard.routes as routes_module
|
||||||
|
import core.database as db_module
|
||||||
|
from core.database import Database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def patched_db(tmp_path, monkeypatch):
|
||||||
|
database = Database(str(tmp_path / "keys.db"))
|
||||||
|
await database.initialize()
|
||||||
|
monkeypatch.setattr(db_module, "_database", database)
|
||||||
|
yield database
|
||||||
|
await database.close()
|
||||||
|
monkeypatch.setattr(db_module, "_database", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_row(patched_db):
|
||||||
|
return await patched_db.create_user(
|
||||||
|
email="keys@example.com",
|
||||||
|
name="keysuser",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-keys-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_client(monkeypatch, user_row, patched_db):
|
||||||
|
from server import create_multi_endpoint_app
|
||||||
|
|
||||||
|
def fake_user_session(_request):
|
||||||
|
return {"user_id": user_row["id"], "type": "user"}, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes_module, "_require_user_session", fake_user_session)
|
||||||
|
|
||||||
|
# Also patch auth so dashboard_keys_unified finds the user session
|
||||||
|
class FakeAuth:
|
||||||
|
def get_session_from_request(self, _r):
|
||||||
|
return None # not admin
|
||||||
|
|
||||||
|
def get_user_session_from_request(self, _r):
|
||||||
|
return {"user_id": user_row["id"], "type": "user"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes_module, "get_dashboard_auth", lambda: FakeAuth())
|
||||||
|
|
||||||
|
app = create_multi_endpoint_app()
|
||||||
|
return TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnifiedKeysUserView:
|
||||||
|
def test_get_keys_page_returns_200(self, user_client):
|
||||||
|
r = user_client.get("/dashboard/keys")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "API Key" in r.text or "کلید" in r.text
|
||||||
|
|
||||||
|
def test_old_connect_redirects_301(self, user_client):
|
||||||
|
r = user_client.get("/dashboard/connect")
|
||||||
|
assert r.status_code == 301
|
||||||
|
assert "/dashboard/keys" in r.headers["location"]
|
||||||
|
|
||||||
|
def test_old_api_keys_redirects_301(self, user_client):
|
||||||
|
r = user_client.get("/dashboard/api-keys")
|
||||||
|
assert r.status_code == 301
|
||||||
|
assert "/dashboard/keys" in r.headers["location"]
|
||||||
|
|
||||||
|
def test_user_view_has_full_access_badge(self, user_client):
|
||||||
|
r = user_client.get("/dashboard/keys")
|
||||||
|
assert r.status_code == 200
|
||||||
|
# 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")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Create" in r.text or "ایجاد" in r.text
|
||||||
@@ -538,6 +538,72 @@ class TestEmptyResults:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteToolToggles:
|
||||||
|
"""F.7b: per-site tool toggle and tool_scope helpers."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_empty_toggles_by_default(self, db, site_row):
|
||||||
|
assert await db.get_site_tool_toggles(site_row["id"]) == {}
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_set_and_get_toggle(self, db, site_row):
|
||||||
|
await db.set_site_tool_toggle(
|
||||||
|
site_row["id"], "coolify_list_applications", False, reason="not needed"
|
||||||
|
)
|
||||||
|
toggles = await db.get_site_tool_toggles(site_row["id"])
|
||||||
|
assert toggles == {"coolify_list_applications": False}
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_toggle_is_upsert(self, db, site_row):
|
||||||
|
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
|
||||||
|
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", True)
|
||||||
|
toggles = await db.get_site_tool_toggles(site_row["id"])
|
||||||
|
assert toggles == {"coolify_get_server": True}
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_toggle(self, db, site_row):
|
||||||
|
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
|
||||||
|
removed = await db.delete_site_tool_toggle(site_row["id"], "coolify_get_server")
|
||||||
|
assert removed is True
|
||||||
|
assert await db.get_site_tool_toggles(site_row["id"]) == {}
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_bulk_set(self, db, site_row):
|
||||||
|
n = await db.bulk_set_site_tool_toggles(
|
||||||
|
site_row["id"],
|
||||||
|
[("coolify_list_applications", False), ("coolify_start_application", False)],
|
||||||
|
reason="bulk:deploy",
|
||||||
|
)
|
||||||
|
assert n == 2
|
||||||
|
toggles = await db.get_site_tool_toggles(site_row["id"])
|
||||||
|
assert toggles == {
|
||||||
|
"coolify_list_applications": False,
|
||||||
|
"coolify_start_application": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_toggle_cascades_on_site_delete(self, db, user_row, site_row):
|
||||||
|
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
|
||||||
|
await db.delete_site(site_row["id"], user_row["id"])
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM site_tool_toggles WHERE site_id = ?", (site_row["id"],)
|
||||||
|
)
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_default_tool_scope_is_admin(self, db, site_row):
|
||||||
|
assert await db.get_site_tool_scope(site_row["id"]) == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_set_tool_scope(self, db, site_row):
|
||||||
|
await db.set_site_tool_scope(site_row["id"], "read")
|
||||||
|
assert await db.get_site_tool_scope(site_row["id"]) == "read"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_unknown_site_tool_scope_defaults_admin(self, db):
|
||||||
|
assert await db.get_site_tool_scope("does-not-exist") == "admin"
|
||||||
|
|
||||||
|
|
||||||
class TestModuleHelpers:
|
class TestModuleHelpers:
|
||||||
"""Test get_database() and initialize_database() helpers."""
|
"""Test get_database() and initialize_database() helpers."""
|
||||||
|
|
||||||
|
|||||||
264
tests/test_site_tools_api.py
Normal file
264
tests/test_site_tools_api.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"""Integration tests for the per-site tool visibility API (F.7b).
|
||||||
|
|
||||||
|
Exercises the five routes registered in server.py:
|
||||||
|
|
||||||
|
* ``GET /api/sites/{site_id}/tools``
|
||||||
|
* ``PATCH /api/sites/{site_id}/tools/{tool_name}``
|
||||||
|
* ``POST /api/sites/{site_id}/tools/bulk-toggle``
|
||||||
|
* ``PATCH /api/sites/{site_id}/tool-scope``
|
||||||
|
* ``GET /api/scope-presets``
|
||||||
|
|
||||||
|
Uses the real ``create_multi_endpoint_app()`` so routes wire to the actual
|
||||||
|
Coolify tool registry. User session auth is stubbed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import core.dashboard.routes as routes_module
|
||||||
|
import core.database as db_module
|
||||||
|
import core.tool_access as tool_access_module
|
||||||
|
from core.database import Database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def patched_db(tmp_path, monkeypatch):
|
||||||
|
database = Database(str(tmp_path / "api.db"))
|
||||||
|
await database.initialize()
|
||||||
|
monkeypatch.setattr(db_module, "_database", database)
|
||||||
|
yield database
|
||||||
|
await database.close()
|
||||||
|
monkeypatch.setattr(db_module, "_database", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_access(monkeypatch):
|
||||||
|
monkeypatch.setattr(tool_access_module, "_manager", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_row(patched_db):
|
||||||
|
return await patched_db.create_user(
|
||||||
|
email="api@example.com",
|
||||||
|
name="api",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-api-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def coolify_site(patched_db, user_row):
|
||||||
|
return await patched_db.create_site(
|
||||||
|
user_id=user_row["id"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
alias="prod",
|
||||||
|
url="https://coolify.example.com",
|
||||||
|
credentials=b"x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def other_user_site(patched_db):
|
||||||
|
"""A site belonging to a different user — for ownership-check tests."""
|
||||||
|
other = await patched_db.create_user(
|
||||||
|
email="other@example.com",
|
||||||
|
name="other",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-other",
|
||||||
|
)
|
||||||
|
return await patched_db.create_site(
|
||||||
|
user_id=other["id"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
alias="theirs",
|
||||||
|
url="https://other.example.com",
|
||||||
|
credentials=b"x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch, user_row, patched_db, patched_access):
|
||||||
|
"""Build the Starlette app and patch user session auth.
|
||||||
|
|
||||||
|
Also pre-sets a matching CSRF cookie + default X-CSRF-Token header so that
|
||||||
|
mutating requests to ``/api/sites/*`` bypass the Double-Submit CSRF guard
|
||||||
|
in ``DashboardCSRFMiddleware``.
|
||||||
|
"""
|
||||||
|
from server import create_multi_endpoint_app
|
||||||
|
|
||||||
|
def fake_require_user_session(_request):
|
||||||
|
return {"user_id": user_row["id"], "type": "user"}, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes_module, "_require_user_session", fake_require_user_session)
|
||||||
|
|
||||||
|
app = create_multi_endpoint_app()
|
||||||
|
tc = TestClient(app)
|
||||||
|
tc.cookies.set("dashboard_csrf", "test-csrf-token")
|
||||||
|
tc.headers.update({"x-csrf-token": "test-csrf-token"})
|
||||||
|
return tc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/sites/{site_id}/tools
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestListSiteTools:
|
||||||
|
def test_returns_plugin_tools_all_enabled(self, client, coolify_site):
|
||||||
|
resp = client.get(f"/api/sites/{coolify_site['id']}/tools")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["site_id"] == coolify_site["id"]
|
||||||
|
assert body["plugin_type"] == "coolify"
|
||||||
|
assert body["tool_scope"] == "admin"
|
||||||
|
tools = body["tools"]
|
||||||
|
by_name = {t["name"]: t for t in tools}
|
||||||
|
assert "coolify_list_applications" in by_name
|
||||||
|
assert "coolify_delete_server" in by_name
|
||||||
|
assert all(t["enabled"] for t in tools)
|
||||||
|
|
||||||
|
def test_carries_category_and_sensitivity(self, client, coolify_site):
|
||||||
|
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
|
||||||
|
by_name = {t["name"]: t for t in tools}
|
||||||
|
assert by_name["coolify_list_applications"]["category"] == "read"
|
||||||
|
assert by_name["coolify_delete_server"]["category"] == "system"
|
||||||
|
assert by_name["coolify_get_application_logs"]["sensitivity"] == "sensitive"
|
||||||
|
|
||||||
|
def test_only_own_sites_visible(self, client, other_user_site):
|
||||||
|
"""A site owned by a different user must 404."""
|
||||||
|
resp = client.get(f"/api/sites/{other_user_site['id']}/tools")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /api/sites/{site_id}/tools/{tool_name}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPatchSiteTool:
|
||||||
|
def test_disable_reflected_in_list(self, client, coolify_site):
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications",
|
||||||
|
json={"enabled": False, "reason": "not needed"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["enabled"] is False
|
||||||
|
|
||||||
|
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
|
||||||
|
by_name = {t["name"]: t["enabled"] for t in tools}
|
||||||
|
assert by_name["coolify_list_applications"] is False
|
||||||
|
assert by_name["coolify_start_application"] is True
|
||||||
|
|
||||||
|
def test_reenable_round_trip(self, client, coolify_site):
|
||||||
|
base = f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications"
|
||||||
|
client.patch(base, json={"enabled": False})
|
||||||
|
client.patch(base, json={"enabled": True})
|
||||||
|
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
|
||||||
|
by_name = {t["name"]: t["enabled"] for t in tools}
|
||||||
|
assert by_name["coolify_list_applications"] is True
|
||||||
|
|
||||||
|
def test_unknown_tool_404(self, client, coolify_site):
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/coolify_nonsense",
|
||||||
|
json={"enabled": False},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
def test_wrong_plugin_400(self, client, coolify_site):
|
||||||
|
"""Tool from another plugin should be rejected."""
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/wordpress_list_posts",
|
||||||
|
json={"enabled": False},
|
||||||
|
)
|
||||||
|
assert r.status_code in (400, 404)
|
||||||
|
|
||||||
|
def test_missing_enabled_400(self, client, coolify_site):
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/sites/{site_id}/tools/bulk-toggle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulkToggle:
|
||||||
|
def test_bulk_disable_deploy_scope(self, client, coolify_site):
|
||||||
|
r = client.post(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
|
||||||
|
json={"scope": "deploy", "enabled": False},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["affected"] >= 5
|
||||||
|
|
||||||
|
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
|
||||||
|
by_name = {t["name"]: t["enabled"] for t in tools}
|
||||||
|
assert by_name["coolify_list_applications"] is False
|
||||||
|
assert by_name["coolify_start_application"] is False
|
||||||
|
assert by_name["coolify_create_application_public"] is True
|
||||||
|
assert by_name["coolify_delete_server"] is True
|
||||||
|
|
||||||
|
def test_unknown_scope_400(self, client, coolify_site):
|
||||||
|
r = client.post(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
|
||||||
|
json={"scope": "bogus", "enabled": False},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_bad_body_400(self, client, coolify_site):
|
||||||
|
r = client.post(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
|
||||||
|
json={"scope": "read"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /api/sites/{site_id}/tool-scope
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetSiteToolScope:
|
||||||
|
def test_set_read_scope(self, client, coolify_site):
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tool-scope",
|
||||||
|
json={"scope": "read"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["tool_scope"] == "read"
|
||||||
|
listing = client.get(f"/api/sites/{coolify_site['id']}/tools").json()
|
||||||
|
assert listing["tool_scope"] == "read"
|
||||||
|
|
||||||
|
def test_invalid_scope_400(self, client, coolify_site):
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tool-scope",
|
||||||
|
json={"scope": "superadmin"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_accepts_all_known_presets(self, client, coolify_site):
|
||||||
|
for scope in ("read", "read:sensitive", "deploy", "write", "admin", "custom"):
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/sites/{coolify_site['id']}/tool-scope",
|
||||||
|
json={"scope": scope},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, scope
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/scope-presets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestScopePresets:
|
||||||
|
def test_returns_all_scopes(self, client):
|
||||||
|
body = client.get("/api/scope-presets").json()
|
||||||
|
assert "presets" in body
|
||||||
|
presets = body["presets"]
|
||||||
|
assert set(presets.keys()) == {"read", "read:sensitive", "deploy", "write", "admin"}
|
||||||
|
assert "read" in presets["read"]
|
||||||
|
assert "system" in presets["admin"]
|
||||||
122
tests/test_sites_tool_access_ui.py
Normal file
122
tests/test_sites_tool_access_ui.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""Smoke tests for unified site management page (F.7c redesign)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import core.dashboard.routes as routes_module
|
||||||
|
import core.database as db_module
|
||||||
|
import core.tool_access as tool_access_module
|
||||||
|
from core.database import Database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def patched_db(tmp_path, monkeypatch):
|
||||||
|
database = Database(str(tmp_path / "ui.db"))
|
||||||
|
await database.initialize()
|
||||||
|
monkeypatch.setattr(db_module, "_database", database)
|
||||||
|
yield database
|
||||||
|
await database.close()
|
||||||
|
monkeypatch.setattr(db_module, "_database", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_access(monkeypatch):
|
||||||
|
monkeypatch.setattr(tool_access_module, "_manager", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_row(patched_db):
|
||||||
|
return await patched_db.create_user(
|
||||||
|
email="ui@example.com",
|
||||||
|
name="uitester",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-ui-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def coolify_site(patched_db, user_row):
|
||||||
|
return await patched_db.create_site(
|
||||||
|
user_id=user_row["id"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
alias="ui-prod",
|
||||||
|
url="https://coolify.example.com",
|
||||||
|
credentials=b"x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch, user_row, patched_db, patched_access):
|
||||||
|
from server import create_multi_endpoint_app
|
||||||
|
|
||||||
|
def fake_user_session(_request):
|
||||||
|
return {"user_id": user_row["id"], "type": "user"}, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes_module, "_require_user_session", fake_user_session)
|
||||||
|
|
||||||
|
# Patch auth for dashboard_keys_unified
|
||||||
|
class FakeAuth:
|
||||||
|
def get_session_from_request(self, _r):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user_session_from_request(self, _r):
|
||||||
|
return {"user_id": user_row["id"], "type": "user"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes_module, "get_dashboard_auth", lambda: FakeAuth())
|
||||||
|
|
||||||
|
app = create_multi_endpoint_app()
|
||||||
|
tc = TestClient(app, follow_redirects=False)
|
||||||
|
tc.cookies.set("dashboard_csrf", "test-csrf")
|
||||||
|
tc.headers.update({"x-csrf-token": "test-csrf"})
|
||||||
|
return tc
|
||||||
|
|
||||||
|
|
||||||
|
class 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 == 301
|
||||||
|
assert f"/dashboard/sites/{coolify_site['id']}" in r.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
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_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_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)
|
||||||
398
tests/test_tool_access.py
Normal file
398
tests/test_tool_access.py
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
"""Tests for site-scoped tool visibility & per-site toggles (F.7b).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
* ``ToolDefinition`` backward-compatible defaults
|
||||||
|
* ``ToolAccessManager.apply_scope_filter`` for each scope
|
||||||
|
* Per-site toggle filtering
|
||||||
|
* Site-level ``tool_scope`` preset as a second restrictive layer
|
||||||
|
* ``bulk_toggle_by_scope`` scoped to a single plugin
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator, Generator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import core.database as db_module
|
||||||
|
import core.tool_access as tool_access_module
|
||||||
|
import core.tool_registry as tool_registry_module
|
||||||
|
from core.database import Database
|
||||||
|
from core.tool_access import (
|
||||||
|
SCOPE_TO_CATEGORIES,
|
||||||
|
ToolAccessManager,
|
||||||
|
scopes_to_categories,
|
||||||
|
)
|
||||||
|
from core.tool_registry import ToolDefinition, ToolRegistry
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _noop_handler(**_kwargs):
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tool(name: str, category: str = "read", plugin_type: str = "coolify") -> ToolDefinition:
|
||||||
|
return ToolDefinition(
|
||||||
|
name=name,
|
||||||
|
description=f"desc {name}",
|
||||||
|
input_schema={"type": "object", "properties": {}},
|
||||||
|
handler=_noop_handler,
|
||||||
|
required_scope="read",
|
||||||
|
plugin_type=plugin_type,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_SAMPLE_TOOLS: list[ToolDefinition] = [
|
||||||
|
_make_tool("coolify_list_applications", "read"),
|
||||||
|
_make_tool("coolify_get_application_logs", "read_sensitive"),
|
||||||
|
_make_tool("coolify_start_application", "lifecycle"),
|
||||||
|
_make_tool("coolify_stop_application", "lifecycle"),
|
||||||
|
_make_tool("coolify_create_application_public", "crud"),
|
||||||
|
_make_tool("coolify_create_application_env", "env"),
|
||||||
|
_make_tool("coolify_get_database_backups", "backup"),
|
||||||
|
_make_tool("coolify_delete_server", "system"),
|
||||||
|
# Legacy plugin tool without a category annotation (defaults to "read").
|
||||||
|
_make_tool("wordpress_list_posts", "read", plugin_type="wordpress"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_registry(monkeypatch) -> Generator[ToolRegistry, None, None]:
|
||||||
|
"""Install a clean ToolRegistry populated with _SAMPLE_TOOLS."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
for tool in _SAMPLE_TOOLS:
|
||||||
|
registry.register(tool)
|
||||||
|
monkeypatch.setattr(tool_registry_module, "_tool_registry", registry)
|
||||||
|
yield registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db(tmp_path, monkeypatch) -> AsyncGenerator[Database, None]:
|
||||||
|
path = str(tmp_path / "toolacc.db")
|
||||||
|
database = Database(path)
|
||||||
|
await database.initialize()
|
||||||
|
monkeypatch.setattr(db_module, "_database", database)
|
||||||
|
yield database
|
||||||
|
await database.close()
|
||||||
|
monkeypatch.setattr(db_module, "_database", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def access_mgr(monkeypatch) -> ToolAccessManager:
|
||||||
|
monkeypatch.setattr(tool_access_module, "_manager", None)
|
||||||
|
return ToolAccessManager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def coolify_site(db):
|
||||||
|
user = await db.create_user(
|
||||||
|
email="toolacc@example.com",
|
||||||
|
name="ToolAcc",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-toolacc-1",
|
||||||
|
)
|
||||||
|
return await db.create_site(
|
||||||
|
user_id=user["id"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
alias="prod",
|
||||||
|
url="https://coolify.example.com",
|
||||||
|
credentials=b"x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def wordpress_site(db):
|
||||||
|
user = await db.create_user(
|
||||||
|
email="wp@example.com",
|
||||||
|
name="wp",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-wp-1",
|
||||||
|
)
|
||||||
|
return await db.create_site(
|
||||||
|
user_id=user["id"],
|
||||||
|
plugin_type="wordpress",
|
||||||
|
alias="blog",
|
||||||
|
url="https://blog.example.com",
|
||||||
|
credentials=b"x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ToolDefinition defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolDefinitionDefaults:
|
||||||
|
def test_default_category_and_sensitivity(self):
|
||||||
|
t = ToolDefinition(
|
||||||
|
name="legacy_tool",
|
||||||
|
description="legacy",
|
||||||
|
handler=_noop_handler,
|
||||||
|
plugin_type="wordpress",
|
||||||
|
)
|
||||||
|
assert t.category == "read"
|
||||||
|
assert t.sensitivity == "normal"
|
||||||
|
|
||||||
|
def test_explicit_category(self):
|
||||||
|
t = ToolDefinition(
|
||||||
|
name="new_tool",
|
||||||
|
description="x",
|
||||||
|
handler=_noop_handler,
|
||||||
|
plugin_type="coolify",
|
||||||
|
category="crud",
|
||||||
|
sensitivity="sensitive",
|
||||||
|
)
|
||||||
|
assert t.category == "crud"
|
||||||
|
assert t.sensitivity == "sensitive"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scope → category mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestScopesToCategories:
|
||||||
|
def test_read_only(self):
|
||||||
|
assert scopes_to_categories(["read"]) == {"read"}
|
||||||
|
|
||||||
|
def test_write_is_superset_of_read(self):
|
||||||
|
cats = scopes_to_categories(["write"])
|
||||||
|
assert "read" in cats and "crud" in cats and "lifecycle" in cats
|
||||||
|
assert "system" not in cats
|
||||||
|
|
||||||
|
def test_admin_includes_everything(self):
|
||||||
|
assert scopes_to_categories(["admin"]) == SCOPE_TO_CATEGORIES["admin"]
|
||||||
|
|
||||||
|
def test_additive_scopes(self):
|
||||||
|
cats = scopes_to_categories(["read", "deploy"])
|
||||||
|
assert cats == {"read", "lifecycle"}
|
||||||
|
|
||||||
|
def test_unknown_scope_ignored(self):
|
||||||
|
assert scopes_to_categories(["bogus"]) == set()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# apply_scope_filter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestScopeFilter:
|
||||||
|
"""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):
|
||||||
|
# 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 "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):
|
||||||
|
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):
|
||||||
|
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
|
||||||
|
assert "coolify_create_application_public" not in out
|
||||||
|
assert "coolify_delete_server" not in out
|
||||||
|
|
||||||
|
def test_write_scope_excludes_system(self, access_mgr):
|
||||||
|
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
|
||||||
|
assert "coolify_delete_server" not in out
|
||||||
|
assert "coolify_get_application_logs" not in out
|
||||||
|
|
||||||
|
def test_admin_keeps_everything(self, access_mgr):
|
||||||
|
out = {
|
||||||
|
t.name
|
||||||
|
for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["admin"], 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
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteToggles:
|
||||||
|
async def test_disable_hides_tool(self, db, coolify_site, access_mgr, fresh_registry):
|
||||||
|
await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
names = {t.name for t in tools}
|
||||||
|
assert "coolify_list_applications" not in names
|
||||||
|
assert "coolify_start_application" in names
|
||||||
|
|
||||||
|
async def test_toggles_are_per_site(
|
||||||
|
self, db, coolify_site, wordpress_site, access_mgr, fresh_registry
|
||||||
|
):
|
||||||
|
# Disabling a tool on one site must not affect another site.
|
||||||
|
await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
|
||||||
|
# Second coolify site inherits nothing.
|
||||||
|
other_site = await db.create_site(
|
||||||
|
user_id=coolify_site["user_id"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
alias="staging",
|
||||||
|
url="https://staging.example.com",
|
||||||
|
credentials=b"x",
|
||||||
|
)
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=other_site["id"], key_scopes=["admin"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
assert "coolify_list_applications" in {t.name for t in tools}
|
||||||
|
|
||||||
|
async def test_toggle_independent_of_scope(self, db, coolify_site, access_mgr, fresh_registry):
|
||||||
|
await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["read"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
assert "coolify_list_applications" not in {t.name for t in tools}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Site-level tool_scope preset
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteToolScope:
|
||||||
|
async def test_default_admin_shows_all(self, db, coolify_site, access_mgr, fresh_registry):
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
assert {t.name for t in tools} == {
|
||||||
|
t.name for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_read_preset_restricts_even_admin_key(
|
||||||
|
self, db, coolify_site, access_mgr, fresh_registry
|
||||||
|
):
|
||||||
|
"""Site scope is restrictive — admin key but site=read → only read tools."""
|
||||||
|
await db.set_site_tool_scope(coolify_site["id"], "read")
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
assert {t.name for t in tools} == {"coolify_list_applications"}
|
||||||
|
|
||||||
|
async def test_key_scope_and_site_scope_intersect(
|
||||||
|
self, db, coolify_site, access_mgr, fresh_registry
|
||||||
|
):
|
||||||
|
"""Key=write + site=deploy → intersection = lifecycle + read."""
|
||||||
|
await db.set_site_tool_scope(coolify_site["id"], "deploy")
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["write"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
names = {t.name for t in tools}
|
||||||
|
assert "coolify_list_applications" in names # read
|
||||||
|
assert "coolify_start_application" in names # lifecycle
|
||||||
|
assert "coolify_stop_application" in names # lifecycle
|
||||||
|
assert "coolify_create_application_public" not in names # crud (not in deploy)
|
||||||
|
assert "coolify_create_application_env" not in names # env (not in deploy)
|
||||||
|
|
||||||
|
async def test_custom_preset_skips_site_filter(
|
||||||
|
self, db, coolify_site, access_mgr, fresh_registry
|
||||||
|
):
|
||||||
|
"""tool_scope='custom' means per-tool toggles only — no category gate."""
|
||||||
|
await db.set_site_tool_scope(coolify_site["id"], "custom")
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
# All coolify tools visible because no site-scope filter applied.
|
||||||
|
assert "coolify_delete_server" in {t.name for t in tools}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bulk toggle (scoped to a plugin)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulkToggle:
|
||||||
|
async def test_bulk_disable_by_scope_affects_only_plugin(
|
||||||
|
self, db, coolify_site, access_mgr, fresh_registry
|
||||||
|
):
|
||||||
|
n = await access_mgr.bulk_toggle_by_scope(
|
||||||
|
coolify_site["id"], "deploy", enabled=False, plugin_type="coolify"
|
||||||
|
)
|
||||||
|
# deploy → read + lifecycle. In sample: list + 2 lifecycle = 3
|
||||||
|
assert n == 3
|
||||||
|
|
||||||
|
tools = await access_mgr.get_visible_tools(
|
||||||
|
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
|
||||||
|
)
|
||||||
|
names = {t.name for t in tools}
|
||||||
|
assert "coolify_start_application" not in names
|
||||||
|
assert "coolify_stop_application" not in names
|
||||||
|
assert "coolify_list_applications" not in names
|
||||||
|
assert "coolify_create_application_public" in names
|
||||||
|
assert "coolify_delete_server" in names
|
||||||
|
|
||||||
|
async def test_unknown_scope_raises(self, db, coolify_site, access_mgr, fresh_registry):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await access_mgr.bulk_toggle_by_scope(
|
||||||
|
coolify_site["id"], "does_not_exist", enabled=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# list_tools_for_site end-to-end
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestListToolsForSite:
|
||||||
|
async def test_returns_plugin_tools_with_enabled_flag(
|
||||||
|
self, db, coolify_site, access_mgr, fresh_registry
|
||||||
|
):
|
||||||
|
tools = await access_mgr.list_tools_for_site(coolify_site["id"], "coolify")
|
||||||
|
by_name = {t["name"]: t for t in tools}
|
||||||
|
assert "coolify_list_applications" in by_name
|
||||||
|
assert by_name["coolify_list_applications"]["enabled"] is True
|
||||||
|
assert by_name["coolify_delete_server"]["category"] == "system"
|
||||||
|
|
||||||
|
async def test_respects_toggles(self, db, coolify_site, access_mgr, fresh_registry):
|
||||||
|
await access_mgr.toggle_tool(coolify_site["id"], "coolify_delete_server", enabled=False)
|
||||||
|
tools = await access_mgr.list_tools_for_site(coolify_site["id"], "coolify")
|
||||||
|
by_name = {t["name"]: t for t in tools}
|
||||||
|
assert by_name["coolify_delete_server"]["enabled"] is False
|
||||||
@@ -85,12 +85,12 @@ def _clear_rate_limits():
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _clear_tool_cache():
|
def _clear_tool_cache():
|
||||||
"""Clear the tool schema cache between tests."""
|
"""No-op: the per-plugin tool schema cache was removed in F.7.
|
||||||
import core.user_endpoints as mod
|
|
||||||
|
|
||||||
mod._tool_schema_cache.clear()
|
Retained so the rest of the test fixtures stay unchanged; the scope-filter
|
||||||
|
pipeline runs on every ``tools/list`` call so there is nothing to clear.
|
||||||
|
"""
|
||||||
yield
|
yield
|
||||||
mod._tool_schema_cache.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -112,6 +112,8 @@ def mock_key_mgr():
|
|||||||
def mock_db():
|
def mock_db():
|
||||||
"""Patch get_database to return a mock."""
|
"""Patch get_database to return a mock."""
|
||||||
db = AsyncMock()
|
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(
|
db.get_site_by_alias = AsyncMock(
|
||||||
return_value={
|
return_value={
|
||||||
"id": "site-uuid-001",
|
"id": "site-uuid-001",
|
||||||
@@ -148,6 +150,10 @@ def mock_tool_registry():
|
|||||||
tool_def = MagicMock()
|
tool_def = MagicMock()
|
||||||
tool_def.name = "wordpress_list_posts"
|
tool_def.name = "wordpress_list_posts"
|
||||||
tool_def.description = "List WordPress 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 = {
|
tool_def.input_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -221,6 +227,62 @@ class TestAuthentication:
|
|||||||
body = json.loads(response.body)
|
body = json.loads(response.body)
|
||||||
assert "does not match" in body["error"]["message"]
|
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 ────────────────────────────────────────
|
# ── Site Lookup Tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user