diff --git a/.gitignore b/.gitignore index d48fc22..83ae5c9 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,8 @@ pytest-cache-files-*/ worktrees/ /.agents uv.lock + +# planning-with-files skill artifacts (session-local) +task_plan.md +findings.md +progress.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dcb916..e809aa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,100 @@ All notable changes to MCP Hub will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.11.0] — 2026-04-14 + +### Plugin-specific access levels, regression tests, UX polish (Track F.7d) + +Polish pass on top of v3.10.0 tool-access hardening. Access levels and credential guidance are now tailored per plugin; UX signals have been tightened. + +#### Added +- **Plugin-aware scope presets** (`core/tool_access.py::get_scope_presets_for_plugin`): WordPress / WordPress Advanced / WooCommerce / Gitea / Coolify (5 tiers) / OpenPanel — each with tailored EN+FA labels and one-line hints. Custom preset always available. +- **Admin Tools card** on per-service dashboard page (4-col grid when `admin` tools > 0). +- **Credential requirement notice** on Tool Access: describes exactly what the saved Credential must grant, per plugin/tier (WP Application Password, WooCommerce REST key scope, Gitea scoped tokens, OpenPanel client mode, Coolify API token permissions). +- **Auto-scoped quick key** on the Connect section (site_id injected → key capped to this site's tier). + +#### Changed +- Scope buttons rendered dynamically from `scope_presets_json` (was: hardcoded 4 tiers). +- WordPress / WP Advanced / WooCommerce: dropped redundant `write` tier (admin-scope tool count = 0) — now **Read Only + Full Access + Custom**. +- Coolify tool list visually filtered by category for all 5 tiers in JS (mirrors `SCOPE_TO_CATEGORIES`). +- OpenPanel Client ID hint clarifies the correct dashboard path (`{org}/{project-id}/settings/clients`). + +--- + +## [3.10.0] — 2026-04-12 + +### Tool access hardening + per-site API keys + Tailwind build (Track F.7c) + +#### Added +- **Per-site API keys** (DB migration v8 adds `site_id` column): UI site selector in create modal; endpoint enforces site-scoped key access. +- **Configuration Snippet section** on keys page (transport / WP / bearer notes). +- **Pre-built Tailwind CSS** via `scripts/build-css.sh` (replaces CDN). +- **Unified site manage page**: collapsible Connection / Tool Access / Connect sections. + +#### Fixed +- **CSRF bug**: removed conflicting `getCsrf()` that tried to read an `httponly` cookie; now relies on the global meta-tag CSRF interceptor in `head_assets.html`. +- **Tool scope UI**: tools outside the selected scope are visually dimmed; summary shows in-scope count. +- Sidebar logo hidden when collapsed. + +#### Changed +- **828 tests passing** (was: 766). +- `/dashboard/connect` and `/dashboard/edit` redirect to unified site page. + +--- + +## [3.9.0] — 2026-04-09 + +### Tool access UI + unified keys page (Track F.7b) + +#### Added +- `sites/edit`: Tool Access card with scope dropdown + per-tool toggle grid. +- `sites/view`: new `/dashboard/sites/{id}` page with MCP URL + config snippets. +- `/dashboard/keys`: unified page (user view with scope selector, admin view). +- Sidebar: "Connect" replaced with "API Keys" → `/dashboard/keys`. +- `sites/list`: added "Connect" link per site row. +- 12 new tests (keys unified + tool access UI smoke). + +#### Changed +- **Site-scoped tool access** (schema v7): dropped `user_tool_toggles` in favour of `site_tool_toggles(site_id)`; added `sites.tool_scope` column (default `admin`). Resolves the limitation that a user with multiple sites of the same plugin (e.g. Coolify production + staging) needed independent tool filters. +- `ToolAccessManager` now takes `site_id`; `get_visible_tools` intersects key-scope categories with the site's `tool_scope` preset, then applies `site_tool_toggles` overrides. +- Dashboard API routes moved under `/api/sites/{site_id}/...`. +- `/dashboard/connect` → `/dashboard/keys` (301); `/dashboard/api-keys` → `/dashboard/keys` (301). + +--- + +## [3.8.0] — 2026-04-07 + +### Coolify plugin Phase 2+3 — projects, databases, services (Track F.17) + +#### Added +- **Coolify projects handler** (8 tools): CRUD projects + environments. +- **Coolify databases handler** (16 tools): CRUD, lifecycle, 6 DB types, backups. +- **Coolify services handler** (13 tools): CRUD, lifecycle, env vars. +- **Scope-based tool visibility + per-user toggles** (Track F.7 core): every `ToolDefinition` now carries `category` + `sensitivity` fields. Extends the scope model with `deploy` and `read:sensitive`. `tools/list` filters by the API key's scopes via `SCOPE_TO_CATEGORIES` in `core/tool_access.py`. Coolify handlers are annotated; other plugins keep the default `read` category (backward compatible). + +#### Changed +- **Coolify plugin total: 67 tools** (was 30). Platform total: **633 tools**. +- **766 tests passing** (48 new). + +--- + +## [3.7.0] — 2026-04-04 + +### Coolify MCP plugin — Phase 1 MVP (Track F.17) + +#### Added +- **Coolify plugin** (30 tools, admin-only): + - **Applications** (17): CRUD, lifecycle (start/stop/restart), logs, env vars. + - **Deployments** (5): list, get, cancel, deploy by tag/UUID, app history. + - **Servers** (8): CRUD, resources, domains, validation. +- Coolify credential fields added to `site_api` (URL + API token), with health check. +- Registered in `server.py` startup so Coolify tools show up in `/coolify/mcp`. + +#### Changed +- Platform plugin count: **10** (added Coolify). Tool count: **597**. + +--- + ## [3.6.0] — 2026-04-02 ### Gitea Plugin — Public Release (Track F.16) diff --git a/CLAUDE.md b/CLAUDE.md index be9cedc..429fc1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify) with 597 tools total. The tool count stays constant regardless of how many sites are configured. +**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify) with 633 tools total. The tool count stays constant regardless of how many sites are configured. ## Quick Setup @@ -70,7 +70,7 @@ All configured in `pyproject.toml`: ``` ├── server.py # Primary entry point ├── core/ # Layer 1: Core system modules -├── plugins/ # Layer 2: Plugin system (9 plugins) +├── plugins/ # Layer 2: Plugin system (10 plugins) ├── core/templates/ # Jinja2 templates (dashboard + OAuth) ├── tests/ # Organized test suite ├── scripts/ # Setup & deployment scripts @@ -85,7 +85,7 @@ All configured in `pyproject.toml`: ``` Layer 1: Core System (core/) — Auth, site discovery, tool registry, health, rate limiting -Layer 2: Plugin System (plugins/) — 9 plugin types, each with handlers + schemas +Layer 2: Plugin System (plugins/) — 10 plugin types, each with handlers + schemas Layer 3: API & Web UI (server.py + core/dashboard/) — FastMCP server, Starlette routes, dashboard ``` diff --git a/DOCKER_README.md b/DOCKER_README.md index a3d5b81..479efb5 100644 --- a/DOCKER_README.md +++ b/DOCKER_README.md @@ -2,7 +2,7 @@ **The AI-native management hub for WordPress, WooCommerce, and self-hosted services.** -565 tools across 9 plugins. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client. +633 tools across 10 plugins (incl. Coolify). Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client. > **Don't want to self-host?** Try the hosted instance at **[mcp.palebluedot.live](https://mcp.palebluedot.live)** — log in with GitHub or Google, add your sites, and connect your AI client in minutes. @@ -92,7 +92,7 @@ Use the most specific endpoint for your use case: | `/u/{user_id}/{alias}/mcp` | 22-100 | No (pre-scoped) | Hosted/OAuth users | | `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow | | `/{plugin}/mcp` | 23-101 | Yes | Multi-site management | -| `/mcp` | 565 | Yes | Admin & discovery only | +| `/mcp` | 633 | Yes | Admin & discovery only | Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-advanced/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/appwrite/mcp`, `/directus/mcp`, `/system/mcp` diff --git a/README.md b/README.md index f9de079..21c3df8 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ Connect your sites, stores, repos, and databases — manage them all through Cla [![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776ab.svg)](https://www.python.org/) [![PyPI](https://img.shields.io/pypi/v/mcphub-server.svg)](https://pypi.org/project/mcphub-server/) [![Docker](https://img.shields.io/docker/v/airano/mcphub?label=docker)](https://hub.docker.com/r/airano/mcphub) -[![Tests: 481 passing](https://img.shields.io/badge/tests-481%20passing-brightgreen.svg)]() -[![Tools: 565](https://img.shields.io/badge/tools-565-orange.svg)]() +[![Tests: 828 passing](https://img.shields.io/badge/tests-828%20passing-brightgreen.svg)]() +[![Tools: 633](https://img.shields.io/badge/tools-633-orange.svg)]() [![CI](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml/badge.svg)](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 | |--------|-------|-----------------| | **WordPress** | 67 | Posts, pages, media, users, menus, taxonomies, SEO (Rank Math/Yoast) | | **WooCommerce** | 28 | Products, orders, customers, coupons, reports, shipping | | **WordPress Advanced** | 22 | Database ops, bulk operations, WP-CLI, system management | -| **Gitea** | 56 | Repos, issues, pull requests, releases, webhooks, organizations | +| **Gitea** | 58 | Repos, issues, pull requests, releases, webhooks, organizations, labels | | **n8n** | 56 | Workflows, executions, credentials, variables, audit | | **Supabase** | 70 | Database, auth, storage, edge functions, realtime | | **OpenPanel** | 42 | Events, export, insights, profiles, projects, system | | **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging | | **Directus** | 100 | Collections, items, users, files, flows, permissions | -| **System** | 24 | Health monitoring, API keys, OAuth management, audit | -| **Total** | **565** | Constant count — scales to unlimited sites | +| **Coolify** | 67 | Applications, deployments, servers, projects, databases, services | +| **System** | 23 | Health monitoring, API keys, OAuth management, audit | +| **Total** | **633** | Constant count — scales to unlimited sites | --- @@ -265,29 +266,30 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au ## Architecture ``` -/mcp → Admin endpoint (all 565 tools) -/system/mcp → System tools only (24 tools) +/mcp → Admin endpoint (all 633 tools) +/system/mcp → System tools only (23 tools) /wordpress/mcp → WordPress tools (67 tools) /woocommerce/mcp → WooCommerce tools (28 tools) /wordpress-advanced/mcp → WordPress Advanced tools (22 tools) -/gitea/mcp → Gitea tools (56 tools) +/gitea/mcp → Gitea tools (58 tools) /n8n/mcp → n8n tools (56 tools) /supabase/mcp → Supabase tools (70 tools) /openpanel/mcp → OpenPanel tools (42 tools) /appwrite/mcp → Appwrite tools (100 tools) /directus/mcp → Directus tools (100 tools) +/coolify/mcp → Coolify tools (67 tools) /project/{alias}/mcp → Per-project endpoint (auto-injects site) /u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users) ``` -**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (565 tools) to minimize token usage. +**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (633 tools) to minimize token usage. | Endpoint | Use Case | Tools | |----------|----------|------:| | `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 | | `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 | | `/{plugin}/mcp` | Multi-site management | 23-101 | -| `/mcp` | Admin & discovery only | 565 | +| `/mcp` | Admin & discovery only | 633 | ### Security @@ -297,7 +299,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au - **GDPR-compliant audit logging** with automatic sensitive data filtering - **Web dashboard** with real-time health monitoring (8 pages, EN/FA i18n) -> **Compatibility Note**: MCP Hub requires FastMCP 2.x (`>=2.14.0,<3.0.0`). FastMCP 3.0 introduced breaking changes and is not yet supported. If you install dependencies manually, ensure you don't upgrade to FastMCP 3.x. +> **Compatibility Note**: MCP Hub requires FastMCP 3.x (`>=3.0.0,<4.0.0`). The legacy multi-endpoint server and ProjectManager have been removed in v3.5.0. ### WordPress Plugin Requirements diff --git a/core/dashboard/routes.py b/core/dashboard/routes.py index 7ad1879..f4f2104 100644 --- a/core/dashboard/routes.py +++ b/core/dashboard/routes.py @@ -2913,16 +2913,22 @@ async def dashboard_connect_page(request: Request) -> Response: async def dashboard_sites_view(request: Request) -> Response: - """GET /dashboard/sites/{id} — Show site connect page with config snippets.""" + """GET /dashboard/sites/{id} — Unified site management page (F.7c). + + Combines connection settings, tool access, and connect/config snippets + into a single page with 3 sections. + """ user_session, redirect = _require_user_session(request) if redirect: return redirect site_id = request.path_params.get("id", "") + import json + from core.config_snippets import get_supported_clients - from core.site_api import PLUGIN_DISPLAY_NAMES as SITE_PLUGIN_NAMES - from core.site_api import get_user_site + from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site + from core.tool_access import get_scope_presets_for_plugin site = await get_user_site(site_id, user_session["user_id"]) if site is None: @@ -2936,15 +2942,23 @@ async def dashboard_sites_view(request: Request) -> Response: public_url = os.environ.get("PUBLIC_URL", "http://localhost:8000").rstrip("/") mcp_url = f"{public_url}/u/{user_session['user_id']}/{site['alias']}/mcp" + plugin_fields = get_user_credential_fields() + plugin_names = get_user_plugin_names() + scope_presets = get_scope_presets_for_plugin(site["plugin_type"]) + return templates.TemplateResponse( request, - "dashboard/sites/view.html", + "dashboard/sites/manage.html", { "lang": lang, "t": t, "session": user_session, "site": site, - "plugin_names": SITE_PLUGIN_NAMES, + "plugin_names": plugin_names, + "plugin_fields": plugin_fields, + "plugin_fields_json": json.dumps(plugin_fields), + "scope_presets": scope_presets, + "scope_presets_json": json.dumps(scope_presets), "mcp_url": mcp_url, "clients": get_supported_clients(), "current_page": "my_sites", @@ -3009,7 +3023,9 @@ async def dashboard_keys_unified(request: Request) -> Response: ) if user_session: - # User view — personal keys + # User view — personal keys + config snippets + from core.config_snippets import get_supported_clients + from core.site_api import get_user_sites from core.user_keys import get_user_key_manager user_keys = [] @@ -3019,6 +3035,8 @@ async def dashboard_keys_unified(request: Request) -> Response: except RuntimeError: pass + sites = await get_user_sites(user_session["user_id"]) + return templates.TemplateResponse( request, "dashboard/keys/list.html", @@ -3028,6 +3046,8 @@ async def dashboard_keys_unified(request: Request) -> Response: "session": user_session, "is_admin": False, "user_keys": user_keys, + "sites": sites, + "clients": get_supported_clients(), "current_page": "keys", }, ) @@ -3153,43 +3173,13 @@ async def api_test_site(request: Request) -> Response: async def dashboard_sites_edit(request: Request) -> Response: - """GET /dashboard/sites/{id}/edit — Render the Edit Site form.""" - user_session, redirect = _require_user_session(request) - if redirect: - return redirect - + """GET /dashboard/sites/{id}/edit — Redirect to unified site page (F.7c).""" site_id = request.path_params.get("id", "") - - from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site - - site = await get_user_site(site_id, user_session["user_id"]) - if site is None: - return RedirectResponse("/dashboard/sites?error=site_not_found", status_code=302) - - accept_language = request.headers.get("accept-language") query_lang = request.query_params.get("lang") - lang = detect_language(accept_language, query_lang) - t = get_translations(lang) - - import json - - plugin_fields = get_user_credential_fields() - plugin_names = get_user_plugin_names() - - return templates.TemplateResponse( - request, - "dashboard/sites/edit.html", - { - "lang": lang, - "t": t, - "session": user_session, - "site": site, - "plugin_fields": plugin_fields, - "plugin_fields_json": json.dumps(plugin_fields), - "plugin_names": plugin_names, - "current_page": "my_sites", - }, - ) + url = f"/dashboard/sites/{site_id}" + if query_lang: + url += f"?lang={query_lang}" + return RedirectResponse(url, status_code=301) async def api_update_site(request: Request) -> Response: @@ -3243,11 +3233,25 @@ async def api_create_key(request: Request) -> Response: try: key_mgr = get_user_key_manager() + + # Validate site_id if provided (must belong to user) + site_id = body.get("site_id") + if site_id: + from core.database import get_database + + db = get_database() + site = await db.get_site(site_id, user_session["user_id"]) + if site is None: + return JSONResponse({"error": "Site not found"}, status_code=404) + + # F.7c: All user keys get full access — tool visibility is controlled + # per-site via tool_scope and per-tool toggles. result = await key_mgr.create_key( user_id=user_session["user_id"], name=body.get("name", "Default"), - scopes=body.get("scopes", "read write admin"), + scopes="read write admin", expires_in_days=body.get("expires_in_days"), + site_id=site_id, ) return JSONResponse({"key": result}) except RuntimeError as e: @@ -3828,9 +3832,9 @@ def register_dashboard_routes(mcp): mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add) mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit) mcp.custom_route("/dashboard/sites/{id}", methods=["GET"])(dashboard_sites_view) - # /dashboard/connect → /dashboard/keys (301) + # /dashboard/connect → /dashboard/sites (F.7c: sites page has connect info now) mcp.custom_route("/dashboard/connect", methods=["GET"])( - lambda r: RedirectResponse("/dashboard/keys", status_code=301) + lambda r: RedirectResponse("/dashboard/sites", status_code=301) ) # Service pages (F.3) diff --git a/core/database.py b/core/database.py index 69573c0..1ce3b89 100644 --- a/core/database.py +++ b/core/database.py @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) _DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data" # Schema version — increment when adding migrations -SCHEMA_VERSION = 7 +SCHEMA_VERSION = 8 # Initial schema DDL _SCHEMA_SQL = """\ @@ -83,7 +83,8 @@ CREATE TABLE IF NOT EXISTS user_api_keys ( last_used TEXT, use_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, - expires_at TEXT + expires_at TEXT, + site_id TEXT ); -- WP plugin connection tokens (short-lived, for MCP Connect plugin) @@ -165,6 +166,10 @@ _MIGRATIONS: dict[int, str] = { "ON site_tool_toggles(site_id);\n" "ALTER TABLE sites ADD COLUMN tool_scope TEXT NOT NULL DEFAULT 'admin';\n" ), + 8: ( + # F.7c: per-site API keys — allow keys scoped to a single site + "ALTER TABLE user_api_keys ADD COLUMN site_id TEXT;\n" + ), } @@ -682,6 +687,7 @@ class Database: name: str, scopes: str = "read write", expires_at: str | None = None, + site_id: str | None = None, ) -> dict[str, Any]: """Create a new user API key. @@ -692,6 +698,7 @@ class Database: name: Human label (e.g. "Claude Desktop"). scopes: Space-separated scopes. expires_at: Optional ISO 8601 expiry timestamp. + site_id: Optional site UUID to scope key to a single site. Returns: The created API key row as a dict. @@ -701,14 +708,14 @@ class Database: await self.execute( "INSERT INTO user_api_keys " - "(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at), + "(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at, site_id) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at, site_id), ) result = await self.fetchone( "SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, " - "created_at, expires_at FROM user_api_keys WHERE id = ?", + "created_at, expires_at, site_id FROM user_api_keys WHERE id = ?", (key_id,), ) if result is None: @@ -726,7 +733,7 @@ class Database: """ return await self.fetchall( "SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, " - "created_at, expires_at FROM user_api_keys WHERE user_id = ? ORDER BY created_at", + "created_at, expires_at, site_id FROM user_api_keys WHERE user_id = ? ORDER BY created_at", (user_id,), ) diff --git a/core/site_api.py b/core/site_api.py index cf8c581..f1eb2a4 100644 --- a/core/site_api.py +++ b/core/site_api.py @@ -157,7 +157,7 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = { "label": "Client ID", "type": "text", "required": True, - "hint": "OpenPanel Dashboard → Settings → Clients → Create client with 'root' mode for full access", + "hint": "Open your project on dashboard.openpanel.dev → Settings → Clients (URL: dashboard.openpanel.dev/{org}/{project-id}/settings/clients). Create a client in 'root' mode for full access.", }, { "name": "client_secret", diff --git a/core/templates/dashboard/base.html b/core/templates/dashboard/base.html index 8fcd15a..47bb74e 100644 --- a/core/templates/dashboard/base.html +++ b/core/templates/dashboard/base.html @@ -111,9 +111,6 @@ MCP Hub MCP Hub -
- MCP Hub -
+ + + +
+

+ {% if lang == 'fa' %} + نکته: تنظیمات بالا شامل نوع اتصال (streamableHttp برای Claude Desktop و http برای VS Code/Claude Code) می‌باشد. از sse استفاده نکنید — باعث خطای 400 Bad Request می‌شود. + {% else %} + Note: Config above includes the correct transport type (streamableHttp for Claude Desktop, http for VS Code/Claude Code). Do not use sse — it will cause 400 Bad Request errors. + {% endif %} +

+
+ + + + + + + + +
+

+ {% if lang == 'fa' %} + API Key: از بخش API Keys بالا بسازید و مقدار آن را در تنظیمات وارد کنید: + {% else %} + API Key: Create one above and use it in your config: + {% endif %} +

+ "Authorization": "Bearer mhu_YOUR_API_KEY_HERE" +
+ + {% else %} +

+ {% if lang == 'fa' %}ابتدا یک سایت اضافه کنید.{% else %}Add a site first to see configuration snippets.{% endif %} + {{ t.add_site }} +

+ {% endif %} + + {% endif %} {% endif %} @@ -267,16 +362,26 @@ class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500">
- - + + {% if sites %} + {% for site in sites %} + + {% endfor %} + {% endif %} -

- {% if lang == 'fa' %}فیلتر ابزار هر سایت در تنظیمات سایت انجام می‌شود.{% else %}Per-site tool filters are set in Site Settings.{% endif %} +

+ {% if lang == 'fa' %}کلید اختصاصی فقط برای سرویس انتخاب‌شده قابل استفاده است.{% else %}A site-scoped key only works for the selected service.{% endif %} +

+
+
+ + {% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %} + +

+ {% if lang == 'fa' %}فیلتر ابزار هر سایت در تنظیمات سایت انجام می‌شود.{% else %}Tool access is controlled per-site in Site Settings.{% endif %}

@@ -367,6 +472,16 @@ function openCreateModal() { document.getElementById('createModal').classList.remove('hidden'); document.getElementById('createModal').classList.add('flex'); + // F.7d: pre-select site from ?site= query param when present + const params = new URLSearchParams(window.location.search); + const siteParam = params.get('site'); + if (siteParam) { + const siteSelect = document.getElementById('new-key-site'); + if (siteSelect) { + const match = Array.from(siteSelect.options).find(o => o.value === siteParam); + if (match) siteSelect.value = siteParam; + } + } } window.openCreateModal = openCreateModal; @@ -421,11 +536,13 @@ } else { // User: POST to /api/keys const name = document.getElementById('new-key-name').value.trim(); - const scope = document.getElementById('new-key-scope').value; if (!name) { alert(lang === 'fa' ? 'نام کلید الزامی است' : 'Key name is required'); return; } + const siteId = document.getElementById('new-key-site')?.value || null; + const payload = { name, scopes: 'read write admin' }; + if (siteId) payload.site_id = siteId; const r = await fetch('/api/keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, scopes: scope }), + body: JSON.stringify(payload), }); const res = await r.json(); if (!r.ok) { alert(res.error || 'Failed'); return; } @@ -488,6 +605,72 @@ else { const d = await r.json(); alert(d.error || 'Failed'); } }; {% endif %} + + // ── Config Snippets (user view) ──────────────────────────────── + {% if not is_admin %} + const WEB_CLIENTS = ['claude_connectors', 'chatgpt']; + const WP_TYPES = ['wordpress', 'woocommerce']; + + async function updateConfig() { + const siteSelect = document.getElementById('config-site'); + const alias = siteSelect?.value; + const client = document.getElementById('config-client')?.value; + if (!alias || !client) return; + + const isWebClient = WEB_CLIENTS.includes(client); + + const transportNote = document.getElementById('transport-note'); + if (transportNote) transportNote.style.display = isWebClient ? 'none' : ''; + + const connTip = document.getElementById('connection-tip'); + if (connTip) connTip.style.display = isWebClient ? '' : 'none'; + + const bearerHint = document.getElementById('bearer-hint'); + if (bearerHint) bearerHint.style.display = isWebClient ? 'none' : ''; + + const selectedOption = siteSelect.options[siteSelect.selectedIndex]; + const pluginType = selectedOption?.dataset?.plugin || ''; + const wpNote = document.getElementById('wp-seo-note'); + if (wpNote) wpNote.style.display = WP_TYPES.includes(pluginType) ? '' : 'none'; + + try { + const resp = await fetch('/api/config/' + alias + '?client=' + client); + const data = await resp.json(); + if (resp.ok) { + document.getElementById('config-output').textContent = data.config; + } + } catch (e) { + document.getElementById('config-output').textContent = 'Error loading config'; + } + } + window.updateConfig = updateConfig; + + function copyConfig() { + const text = document.getElementById('config-output').textContent; + navigator.clipboard.writeText(text); + const btn = document.getElementById('copy-config-btn'); + btn.textContent = lang === 'fa' ? 'کپی شد!' : 'Copied!'; + setTimeout(() => btn.textContent = lang === 'fa' ? 'کپی' : 'Copy', 2000); + } + window.copyConfig = copyConfig; + + document.addEventListener('DOMContentLoaded', () => { + // F.7d: support ?site= 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 %} })(); {% endblock %} diff --git a/core/templates/dashboard/oauth-clients/list.html b/core/templates/dashboard/oauth-clients/list.html index 9f4093c..66336dd 100644 --- a/core/templates/dashboard/oauth-clients/list.html +++ b/core/templates/dashboard/oauth-clients/list.html @@ -220,24 +220,14 @@ https://claude.com/api/mcp/auth_callback
- -
- - - +
+ + {% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %} + +

+ {% if lang == 'fa' %}دسترسی ابزار در تنظیمات هر سایت کنترل می‌شود.{% else %}Tool access is controlled per-site in Site Settings.{% endif %} +

-

- {% if lang == 'fa' %}admin: دسترسی کامل به همه عملیات{% else %}admin: Full access to all operations{% endif %} -

@@ -426,10 +416,8 @@ https://claude.com/api/mcp/auth_callback e.preventDefault(); var formData = new FormData(this); - var scopes = []; - document.querySelectorAll('input[name="scopes"]:checked').forEach(function(cb) { - scopes.push(cb.value); - }); + // F.7c: All clients get full scope — tool access controlled per-site + var scopes = ['read', 'write', 'admin']; // Parse redirect URIs (one per line) var redirectUrisText = formData.get('redirect_uris') || ''; diff --git a/core/templates/dashboard/partials/head_assets.html b/core/templates/dashboard/partials/head_assets.html index 49518a8..4a968f6 100644 --- a/core/templates/dashboard/partials/head_assets.html +++ b/core/templates/dashboard/partials/head_assets.html @@ -4,8 +4,8 @@ - - + + @@ -13,34 +13,6 @@ - - - +{% endblock %} diff --git a/core/templates/static/src/input.css b/core/templates/static/src/input.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/core/templates/static/src/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/core/templates/static/tailwind.min.css b/core/templates/static/tailwind.min.css new file mode 100644 index 0000000..30ac808 --- /dev/null +++ b/core/templates/static/tailwind.min.css @@ -0,0 +1 @@ +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.-bottom-40{bottom:-10rem}.-left-40{left:-10rem}.-right-40{right:-10rem}.-top-40{top:-10rem}.bottom-4{bottom:1rem}.left-2{left:.5rem}.right-2{right:.5rem}.right-4{right:1rem}.top-0{top:0}.top-2{top:.5rem}.z-10{z-index:10}.z-50{z-index:50}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-20{height:5rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-80{height:20rem}.h-screen{height:100vh}.max-h-80{max-height:20rem}.max-h-96{max-height:24rem}.min-h-\[100px\]{min-height:100px}.min-h-\[120px\]{min-height:120px}.min-h-\[80px\]{min-height:80px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-20{width:5rem}.w-3{width:.75rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[200px\]{min-width:200px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-\[200px\]{max-width:200px}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.rotate-180{--tw-rotate:180deg}.rotate-180,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row-reverse{flex-direction:row-reverse}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-y-1{row-gap:.25rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.space-x-reverse>:not([hidden])~:not([hidden]){--tw-space-x-reverse:1}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-blue-500\/30{border-color:rgba(59,130,246,.3)}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-indigo-200{--tw-border-opacity:1;border-color:rgb(199 210 254/var(--tw-border-opacity,1))}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-500\/50{border-color:rgba(239,68,68,.5)}.border-red-700{--tw-border-opacity:1;border-color:rgb(185 28 28/var(--tw-border-opacity,1))}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.border-yellow-500\/30{border-color:rgba(234,179,8,.3)}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/10{background-color:rgba(59,130,246,.1)}.bg-blue-500\/20{background-color:rgba(59,130,246,.2)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-cyan-500{--tw-bg-opacity:1;background-color:rgb(6 182 212/var(--tw-bg-opacity,1))}.bg-cyan-500\/20{background-color:rgba(6,182,212,.2)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-500\/20{background-color:hsla(220,9%,46%,.2)}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-gray-900\/50{background-color:rgba(17,24,39,.5)}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/20{background-color:rgba(34,197,94,.2)}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-500{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity,1))}.bg-orange-100{--tw-bg-opacity:1;background-color:rgb(255 237 213/var(--tw-bg-opacity,1))}.bg-orange-500{--tw-bg-opacity:1;background-color:rgb(249 115 22/var(--tw-bg-opacity,1))}.bg-orange-500\/20{background-color:rgba(249,115,22,.2)}.bg-pink-500{--tw-bg-opacity:1;background-color:rgb(236 72 153/var(--tw-bg-opacity,1))}.bg-primary-500\/20{background-color:rgba(139,92,246,.2)}.bg-primary-600{--tw-bg-opacity:1;background-color:rgb(124 58 237/var(--tw-bg-opacity,1))}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-purple-50{--tw-bg-opacity:1;background-color:rgb(250 245 255/var(--tw-bg-opacity,1))}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-purple-500\/20{background-color:rgba(168,85,247,.2)}.bg-purple-600{--tw-bg-opacity:1;background-color:rgb(147 51 234/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/20{background-color:rgba(239,68,68,.2)}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-red-900\/30{background-color:rgba(127,29,29,.3)}.bg-teal-500\/20{background-color:rgba(20,184,166,.2)}.bg-violet-500{--tw-bg-opacity:1;background-color:rgb(139 92 246/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/95{background-color:hsla(0,0%,100%,.95)}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-yellow-500\/10{background-color:rgba(234,179,8,.1)}.bg-yellow-500\/20{background-color:rgba(234,179,8,.2)}.object-contain{-o-object-fit:contain;object-fit:contain}.p-12{padding:3rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem}.pb-4,.py-4{padding-bottom:1rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.leading-relaxed{line-height:1.625}.tracking-wider{letter-spacing:.05em}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-blue-300{--tw-text-opacity:1;color:rgb(147 197 253/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-cyan-400{--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-orange-400{--tw-text-opacity:1;color:rgb(251 146 60/var(--tw-text-opacity,1))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-orange-700{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.text-primary-400{--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.text-primary-500{--tw-text-opacity:1;color:rgb(139 92 246/var(--tw-text-opacity,1))}.text-primary-600{--tw-text-opacity:1;color:rgb(124 58 237/var(--tw-text-opacity,1))}.text-purple-400{--tw-text-opacity:1;color:rgb(192 132 252/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-purple-700{--tw-text-opacity:1;color:rgb(126 34 206/var(--tw-text-opacity,1))}.text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-red-900{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.text-teal-400{--tw-text-opacity:1;color:rgb(45 212 191/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.line-through{text-decoration-line:line-through}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.opacity-25{opacity:.25}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-purple-200{--tw-ring-opacity:1;--tw-ring-color:rgb(233 213 255/var(--tw-ring-opacity,1))}.blur{--tw-blur:blur(8px)}.blur,.blur-3xl{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-3xl{--tw-blur:blur(64px)}.drop-shadow-\[0_0_8px_rgba\(255\2c 255\2c 255\2c 0\.8\)\]{--tw-drop-shadow:drop-shadow(0 0 8px hsla(0,0%,100%,.8))}.drop-shadow-\[0_0_8px_rgba\(255\2c 255\2c 255\2c 0\.8\)\],.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-4:after{content:var(--tw-content);height:1rem}.after\:w-4:after{content:var(--tw-content);width:1rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\"\"\]:after{--tw-content:"";content:var(--tw-content)}.first\:mt-0:first-child{margin-top:0}.last\:border-0:last-child{border-width:0}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-300:hover{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.hover\:bg-primary-500:hover{--tw-bg-opacity:1;background-color:rgb(139 92 246/var(--tw-bg-opacity,1))}.hover\:bg-primary-700:hover{--tw-bg-opacity:1;background-color:rgb(109 40 217/var(--tw-bg-opacity,1))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:bg-red-500\/30:hover{background-color:rgba(239,68,68,.3)}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:text-blue-300:hover{--tw-text-opacity:1;color:rgb(147 197 253/var(--tw-text-opacity,1))}.hover\:text-blue-400:hover{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.hover\:text-blue-500:hover{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.hover\:text-gray-300:hover{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-green-300:hover{--tw-text-opacity:1;color:rgb(134 239 172/var(--tw-text-opacity,1))}.hover\:text-orange-300:hover{--tw-text-opacity:1;color:rgb(253 186 116/var(--tw-text-opacity,1))}.hover\:text-pink-400:hover{--tw-text-opacity:1;color:rgb(244 114 182/var(--tw-text-opacity,1))}.hover\:text-primary-300:hover{--tw-text-opacity:1;color:rgb(196 181 253/var(--tw-text-opacity,1))}.hover\:text-purple-300:hover{--tw-text-opacity:1;color:rgb(216 180 254/var(--tw-text-opacity,1))}.hover\:text-red-300:hover{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.hover\:text-red-500:hover{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:text-yellow-600:hover{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:border-primary-500:focus{--tw-border-opacity:1;border-color:rgb(139 92 246/var(--tw-border-opacity,1))}.focus\:border-purple-500:focus{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.focus\:border-transparent:focus{border-color:transparent}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.focus\:ring-primary-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(139 92 246/var(--tw-ring-opacity,1))}.focus\:ring-purple-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(168 85 247/var(--tw-ring-opacity,1))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus\:ring-offset-gray-800:focus{--tw-ring-offset-color:#1f2937}.disabled\:opacity-50:disabled{opacity:.5}.peer:checked~.peer-checked\:bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.peer:checked~.peer-checked\:after\:translate-x-4:after{content:var(--tw-content);--tw-translate-x:1rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:focus~.peer-focus\:ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.peer:focus~.peer-focus\:ring-blue-300{--tw-ring-opacity:1;--tw-ring-color:rgb(147 197 253/var(--tw-ring-opacity,1))}.dark\:divide-gray-700:is(.dark *)>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(55 65 81/var(--tw-divide-opacity,1))}.dark\:border-amber-500\/30:is(.dark *){border-color:rgba(245,158,11,.3)}.dark\:border-blue-500\/30:is(.dark *){border-color:rgba(59,130,246,.3)}.dark\:border-blue-800:is(.dark *){--tw-border-opacity:1;border-color:rgb(30 64 175/var(--tw-border-opacity,1))}.dark\:border-gray-600:is(.dark *){--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.dark\:border-gray-700:is(.dark *){--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.dark\:border-green-500\/30:is(.dark *){border-color:rgba(34,197,94,.3)}.dark\:border-green-500\/50:is(.dark *){border-color:rgba(34,197,94,.5)}.dark\:border-indigo-500\/30:is(.dark *){border-color:rgba(99,102,241,.3)}.dark\:border-red-500\/30:is(.dark *){border-color:rgba(239,68,68,.3)}.dark\:border-red-500\/50:is(.dark *){border-color:rgba(239,68,68,.5)}.dark\:border-red-800:is(.dark *){--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity,1))}.dark\:border-yellow-500\/30:is(.dark *){border-color:rgba(234,179,8,.3)}.dark\:border-yellow-500\/50:is(.dark *){border-color:rgba(234,179,8,.5)}.dark\:bg-amber-500\/10:is(.dark *){background-color:rgba(245,158,11,.1)}.dark\:bg-amber-500\/20:is(.dark *){background-color:rgba(245,158,11,.2)}.dark\:bg-blue-500\/10:is(.dark *){background-color:rgba(59,130,246,.1)}.dark\:bg-blue-500\/20:is(.dark *){background-color:rgba(59,130,246,.2)}.dark\:bg-blue-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.dark\:bg-blue-900\/20:is(.dark *){background-color:rgba(30,58,138,.2)}.dark\:bg-gray-500\/20:is(.dark *){background-color:hsla(220,9%,46%,.2)}.dark\:bg-gray-600:is(.dark *){--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.dark\:bg-gray-700:is(.dark *){--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.dark\:bg-gray-700\/30:is(.dark *){background-color:rgba(55,65,81,.3)}.dark\:bg-gray-700\/50:is(.dark *){background-color:rgba(55,65,81,.5)}.dark\:bg-gray-800:is(.dark *){--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.dark\:bg-gray-800\/95:is(.dark *){background-color:rgba(31,41,55,.95)}.dark\:bg-gray-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.dark\:bg-green-500\/10:is(.dark *){background-color:rgba(34,197,94,.1)}.dark\:bg-green-500\/20:is(.dark *){background-color:rgba(34,197,94,.2)}.dark\:bg-indigo-500\/10:is(.dark *){background-color:rgba(99,102,241,.1)}.dark\:bg-orange-500\/20:is(.dark *){background-color:rgba(249,115,22,.2)}.dark\:bg-purple-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(88 28 135/var(--tw-bg-opacity,1))}.dark\:bg-purple-900\/20:is(.dark *){background-color:rgba(88,28,135,.2)}.dark\:bg-red-500\/20:is(.dark *){background-color:rgba(239,68,68,.2)}.dark\:bg-red-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(127 29 29/var(--tw-bg-opacity,1))}.dark\:bg-red-900\/20:is(.dark *){background-color:rgba(127,29,29,.2)}.dark\:bg-yellow-500\/10:is(.dark *){background-color:rgba(234,179,8,.1)}.dark\:bg-yellow-500\/20:is(.dark *){background-color:rgba(234,179,8,.2)}.dark\:text-amber-300:is(.dark *){--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.dark\:text-amber-400:is(.dark *){--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-blue-300:is(.dark *){--tw-text-opacity:1;color:rgb(147 197 253/var(--tw-text-opacity,1))}.dark\:text-blue-400:is(.dark *){--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.dark\:text-gray-100:is(.dark *){--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.dark\:text-gray-200:is(.dark *){--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:text-gray-300:is(.dark *){--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.dark\:text-gray-500:is(.dark *){--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.dark\:text-gray-600:is(.dark *){--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.dark\:text-green-300:is(.dark *){--tw-text-opacity:1;color:rgb(134 239 172/var(--tw-text-opacity,1))}.dark\:text-green-400:is(.dark *){--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.dark\:text-green-500:is(.dark *){--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.dark\:text-indigo-300:is(.dark *){--tw-text-opacity:1;color:rgb(165 180 252/var(--tw-text-opacity,1))}.dark\:text-orange-300:is(.dark *){--tw-text-opacity:1;color:rgb(253 186 116/var(--tw-text-opacity,1))}.dark\:text-orange-400:is(.dark *){--tw-text-opacity:1;color:rgb(251 146 60/var(--tw-text-opacity,1))}.dark\:text-primary-400:is(.dark *){--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.dark\:text-purple-300:is(.dark *){--tw-text-opacity:1;color:rgb(216 180 254/var(--tw-text-opacity,1))}.dark\:text-purple-400:is(.dark *){--tw-text-opacity:1;color:rgb(192 132 252/var(--tw-text-opacity,1))}.dark\:text-red-200:is(.dark *){--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity,1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400:is(.dark *){--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:text-white:is(.dark *){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:text-yellow-300:is(.dark *){--tw-text-opacity:1;color:rgb(253 224 71/var(--tw-text-opacity,1))}.dark\:text-yellow-400:is(.dark *){--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.dark\:placeholder-gray-500:is(.dark *)::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.dark\:placeholder-gray-500:is(.dark *)::placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.dark\:hover\:border-gray-500:hover:is(.dark *){--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.dark\:hover\:bg-gray-500:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-600:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-700:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-700\/30:hover:is(.dark *){background-color:rgba(55,65,81,.3)}.dark\:hover\:bg-gray-700\/50:hover:is(.dark *){background-color:rgba(55,65,81,.5)}.dark\:hover\:bg-red-500\/10:hover:is(.dark *){background-color:rgba(239,68,68,.1)}.dark\:hover\:text-blue-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(147 197 253/var(--tw-text-opacity,1))}.dark\:hover\:text-gray-200:hover:is(.dark *){--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:hover\:text-pink-400:hover:is(.dark *){--tw-text-opacity:1;color:rgb(244 114 182/var(--tw-text-opacity,1))}.dark\:hover\:text-red-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:hover\:text-white:hover:is(.dark *){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:hover\:text-yellow-200:hover:is(.dark *){--tw-text-opacity:1;color:rgb(254 240 138/var(--tw-text-opacity,1))}.peer:focus~.dark\:peer-focus\:ring-blue-800:is(.dark *){--tw-ring-opacity:1;--tw-ring-color:rgb(30 64 175/var(--tw-ring-opacity,1))}@media (min-width:640px){.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}}@media (min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file diff --git a/core/tool_access.py b/core/tool_access.py index e67f13a..f4e9f2e 100644 --- a/core/tool_access.py +++ b/core/tool_access.py @@ -1,23 +1,17 @@ -"""Tool access manager — site-scoped visibility and per-site toggles (F.7b). +"""Tool access manager — site-scoped visibility and per-site toggles (F.7c). Provides a central pipeline that filters the set of MCP tools presented for a user endpoint based on: -1. **Scope → category mapping.** Every ``ToolDefinition`` carries a - ``category`` field (e.g. ``read``, ``lifecycle``, ``crud``, ``system``). - An API key's declared scopes **and** the site's stored ``tool_scope`` - preset each map to a set of allowed categories via - :data:`SCOPE_TO_CATEGORIES`. A tool is visible only if its category is in - the intersection — the narrower of the two layers wins. +1. **Universal scope tiers.** A 3-level system (``read`` / ``write`` / + ``admin``) that works across ALL plugins using the tool's + ``required_scope`` field. For Coolify (which has fine-grained + ``category`` annotations) the legacy category mapping is kept as an + overlay for the ``custom`` preset. 2. **Per-site tool toggles.** Site owners may explicitly disable specific - tools via the ``site_tool_toggles`` table. Only overrides are stored — + tools via the ``site_tool_toggles`` table. Only overrides are stored — tools without an entry are enabled by default. -Tools whose ``category`` is not in :data:`KNOWN_CATEGORIES` are **always -visible** (backward compatibility — legacy plugins that have not been -annotated yet default to ``category="read"``, which belongs to the ``read`` -scope set anyway, but an unknown value would be preserved). - The ``tool_scope`` value ``"custom"`` is a sentinel meaning "do not apply a site-level preset filter" — in that case only the per-tool toggles and the key scope are considered. @@ -44,9 +38,17 @@ from core.tool_registry import ToolDefinition logger = logging.getLogger(__name__) +# ── Universal 3-tier scope system (F.7c) ───────────────────────────── +# Maps a scope tier to the set of ``required_scope`` values it may access. +# Works for ALL plugins because every tool has ``required_scope``. +UNIVERSAL_SCOPE_TIERS: dict[str, set[str]] = { + "read": {"read"}, + "write": {"read", "write"}, + "admin": {"read", "write", "admin"}, +} + +# ── Legacy Coolify category mapping (kept for ``custom`` overlay) ───── # Mapping from scope → set of tool categories that scope may see. -# Used for BOTH API-key scopes and per-site ``tool_scope`` presets. -# Scopes are additive: presenting multiple scopes yields the union. SCOPE_TO_CATEGORIES: dict[str, set[str]] = { "read": {"read"}, "read:sensitive": {"read", "read_sensitive", "backup"}, @@ -63,8 +65,7 @@ SCOPE_TO_CATEGORIES: dict[str, set[str]] = { }, } -# All known categories — any tool whose category is outside this set is -# treated as "always visible" for backward compatibility. +# All known Coolify categories. KNOWN_CATEGORIES: set[str] = { "read", "read_sensitive", @@ -78,6 +79,9 @@ KNOWN_CATEGORIES: set[str] = { # Sentinel meaning "no site-level preset filter — use per-tool toggles only". SCOPE_CUSTOM = "custom" +# Plugins that have fine-grained category annotations. +_CATEGORY_PLUGINS: set[str] = {"coolify"} + def scopes_to_categories(scopes: list[str]) -> set[str]: """Return the union of categories allowed by the given scope list. @@ -94,6 +98,197 @@ def scopes_to_categories(scopes: list[str]) -> set[str]: return allowed +def _scopes_to_required(scopes: list[str]) -> set[str]: + """Return the union of ``required_scope`` values allowed by universal tiers.""" + allowed: set[str] = set() + for scope in scopes: + allowed |= UNIVERSAL_SCOPE_TIERS.get(scope.strip(), set()) + return allowed + + +def get_scope_presets_for_plugin(plugin_type: str) -> list[dict[str, str]]: + """Return the appropriate scope presets for a plugin type (F.7d). + + Each preset is a dict with ``value`` (the canonical scope key persisted + to ``sites.tool_scope``), ``label`` / ``label_fa`` (button title), and + ``hint`` / ``hint_fa`` (one-line description shown under the title). + + The valid scope values are constrained by ``_VALID_TOOL_SCOPES`` in + ``core.dashboard.routes`` — currently: + ``{"read", "read:sensitive", "deploy", "write", "admin", "custom"}``. + """ + custom = { + "value": "custom", + "label": "Custom", + "label_fa": "سفارشی", + "hint": "Per-tool toggles", + "hint_fa": "هر ابزار جداگانه", + } + + if plugin_type == "coolify": + # 5 fine-grained Coolify tiers + custom (matches SCOPE_TO_CATEGORIES). + return [ + { + "value": "read", + "label": "Read", + "label_fa": "خواندن", + "hint": "List/inspect resources", + "hint_fa": "مشاهده و فهرست منابع", + }, + { + "value": "read:sensitive", + "label": "Read + Secrets", + "label_fa": "خواندن + اسرار", + "hint": "Includes env vars and backups", + "hint_fa": "شامل متغیرهای محیطی و بکاپ", + }, + { + "value": "deploy", + "label": "Deploy", + "label_fa": "استقرار", + "hint": "Read + lifecycle (start/stop/restart)", + "hint_fa": "مشاهده + راه‌اندازی/توقف/ریستارت", + }, + { + "value": "write", + "label": "Write", + "label_fa": "نوشتن", + "hint": "Read + lifecycle + CRUD + env", + "hint_fa": "مشاهده + لایفسایکل + CRUD + env", + }, + { + "value": "admin", + "label": "Root", + "label_fa": "روت", + "hint": "Everything including system commands", + "hint_fa": "همه چیز شامل دستورات سیستم", + }, + custom, + ] + + if plugin_type == "openpanel": + return [ + { + "value": "read", + "label": "Read", + "label_fa": "خواندن", + "hint": "Export current project only", + "hint_fa": "خروجی پروژه فعلی", + }, + { + "value": "write", + "label": "Write", + "label_fa": "نوشتن", + "hint": "Default ingestion (track events)", + "hint_fa": "ارسال رویداد (ingestion)", + }, + { + "value": "admin", + "label": "Root", + "label_fa": "روت", + "hint": "Export any project", + "hint_fa": "خروجی هر پروژه", + }, + custom, + ] + + if plugin_type == "woocommerce": + # WooCommerce has no admin-scope tools (read=14, write=14, admin=0), + # so Write and "Read + Write" tiers are identical. Present a single + # full-access tier instead of two duplicates. + return [ + { + "value": "read", + "label": "Read Only", + "label_fa": "فقط خواندن", + "hint": "Browse products, orders, customers", + "hint_fa": "مشاهده محصولات، سفارش‌ها و مشتریان", + }, + { + "value": "admin", + "label": "Read + Write", + "label_fa": "خواندن + نوشتن", + "hint": "Full store management (all 28 tools)", + "hint_fa": "مدیریت کامل فروشگاه (همه ۲۸ ابزار)", + }, + custom, + ] + + if plugin_type in {"wordpress", "wordpress_advanced"}: + # WordPress has no admin-scope tools (read=27, write=40, admin=0). + # SEO + plugin/theme tools require the Airano MCP SEO Bridge plugin + # to be installed on the WP site itself. Present 2 tiers + custom. + return [ + { + "value": "read", + "label": "Read Only", + "label_fa": "فقط خواندن", + "hint": "View posts, pages, media", + "hint_fa": "مشاهده نوشته‌ها، صفحات و رسانه", + }, + { + "value": "admin", + "label": "Full Access", + "label_fa": "دسترسی کامل", + "hint": "All tools (CRUD + SEO via add-on)", + "hint_fa": "همه ابزارها (CRUD و SEO با افزونه)", + }, + custom, + ] + + if plugin_type == "gitea": + return [ + { + "value": "read", + "label": "Read", + "label_fa": "خواندن", + "hint": "Browse repos, issues, users", + "hint_fa": "مشاهده مخازن، ایشوها، کاربران", + }, + { + "value": "write", + "label": "Read + Write", + "label_fa": "خواندن + نوشتن", + "hint": "Create issues, PRs, branches", + "hint_fa": "ایجاد ایشو، PR و شاخه", + }, + { + "value": "admin", + "label": "Admin", + "label_fa": "مدیر", + "hint": "Repo + org + user admin", + "hint_fa": "مدیریت مخزن، سازمان و کاربر", + }, + custom, + ] + + # Universal default for all other plugins. + return [ + { + "value": "read", + "label": "Read", + "label_fa": "فقط خواندن", + "hint": "View only", + "hint_fa": "فقط مشاهده", + }, + { + "value": "write", + "label": "Read + Write", + "label_fa": "خواندن + نوشتن", + "hint": "CRUD ops", + "hint_fa": "عملیات CRUD", + }, + { + "value": "admin", + "label": "Full Access", + "label_fa": "دسترسی کامل", + "hint": "All tools", + "hint_fa": "همه ابزارها", + }, + custom, + ] + + class ToolAccessManager: """Central manager for scope-based visibility and per-site tool toggles.""" @@ -101,24 +296,35 @@ class ToolAccessManager: self, tools: list[ToolDefinition], scopes: list[str], + plugin_type: str | None = None, ) -> list[ToolDefinition]: - """Drop tools whose category is not allowed by the presented scopes. + """Drop tools not allowed by the presented scopes. - Tools with an unknown category (e.g. legacy plugins not yet annotated) - are always kept — backward compatibility. + For plugins with category annotations (Coolify) the legacy + category-based filter is used. For all other plugins the universal + 3-tier filter based on ``required_scope`` is applied. Args: tools: Candidate tool list. scopes: Scopes presented on the API key (or a single-element list containing a site's ``tool_scope`` preset). + plugin_type: Plugin type hint. When provided and the plugin is + NOT in ``_CATEGORY_PLUGINS``, the universal tier filter is + used. Returns: Filtered tool list. """ + # Try universal tiers first (works for all plugins) + allowed_scopes = _scopes_to_required(scopes) + + if allowed_scopes and (plugin_type is None or plugin_type not in _CATEGORY_PLUGINS): + # Universal filter: match tool.required_scope against allowed tiers + return [t for t in tools if t.required_scope in allowed_scopes] + + # Fallback: legacy category-based filter for Coolify / custom scopes allowed = scopes_to_categories(scopes) if not allowed: - # No recognised scopes — preserve legacy behaviour and return - # only tools with unknown categories. return [t for t in tools if t.category not in KNOWN_CATEGORIES] result: list[ToolDefinition] = [] @@ -185,7 +391,7 @@ class ToolAccessManager: registry = get_tool_registry() tools = registry.get_by_plugin_type(plugin_type) - tools = self.apply_scope_filter(tools, key_scopes) + tools = self.apply_scope_filter(tools, key_scopes, plugin_type=plugin_type) try: db = get_database() @@ -194,7 +400,7 @@ class ToolAccessManager: site_scope = "admin" if site_scope and site_scope != SCOPE_CUSTOM: - tools = self.apply_scope_filter(tools, [site_scope]) + tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type) tools = await self.apply_site_toggles(tools, site_id) return tools diff --git a/core/user_endpoints.py b/core/user_endpoints.py index d6ab8ca..f1ae4df 100644 --- a/core/user_endpoints.py +++ b/core/user_endpoints.py @@ -240,6 +240,28 @@ async def user_mcp_handler(request: Request) -> Response: _jsonrpc_error(None, -32600, "API key does not match user"), status_code=403, ) + + # Check site-scoped key: if key is scoped to a site, it can only + # be used for that specific site's alias. + key_site_id = key_info.get("site_id") + if key_site_id: + try: + from core.database import get_database + + db = get_database() + site = await db.get_site(key_site_id, user_id) + if site is None or site.get("alias") != alias: + return JSONResponse( + _jsonrpc_error( + None, + -32600, + "API key is scoped to a different site", + ), + status_code=403, + ) + except RuntimeError: + pass # DB unavailable — allow through + key_scopes = key_info.get("scopes", "read").split() else: # Try OAuth JWT token (issued after consent flow via GitHub/Google login) diff --git a/core/user_keys.py b/core/user_keys.py index 2d0903b..f3beac8 100644 --- a/core/user_keys.py +++ b/core/user_keys.py @@ -49,8 +49,8 @@ class UserKeyManager: """ def __init__(self) -> None: - # Cache: raw_key -> (key_id, user_id, scopes, cached_at) - self._cache: dict[str, tuple[str, str, str, float]] = {} + # Cache: raw_key -> (key_id, user_id, scopes, site_id, cached_at) + self._cache: dict[str, tuple[str, str, str, str | None, float]] = {} async def create_key( self, @@ -58,6 +58,7 @@ class UserKeyManager: name: str, scopes: str = "read write admin", expires_in_days: int | None = None, + site_id: str | None = None, ) -> dict[str, Any]: """Create a new API key for a user. @@ -66,10 +67,11 @@ class UserKeyManager: name: Human label (e.g. "Claude Desktop"). scopes: Access scopes (default: "read write admin" for full access). expires_in_days: Optional expiry in days from now. None = never. + site_id: Optional site UUID to scope key to a single site. Returns: Dict with ``key`` (plaintext, shown once), ``key_id``, ``name``, - ``scopes``, ``created_at``, ``expires_at``. + ``scopes``, ``created_at``, ``expires_at``, ``site_id``. """ from core.database import get_database @@ -89,9 +91,10 @@ class UserKeyManager: name=name, scopes=scopes, expires_at=expires_at, + site_id=site_id, ) - logger.info("Created user API key %s for user %s", row["id"], user_id) + logger.info("Created user API key %s for user %s (site=%s)", row["id"], user_id, site_id) return { "key": raw_key, # shown once "key_id": row["id"], @@ -99,6 +102,7 @@ class UserKeyManager: "scopes": row["scopes"], "created_at": row["created_at"], "expires_at": row["expires_at"], + "site_id": row.get("site_id"), } async def validate_key(self, api_key: str) -> dict[str, Any] | None: @@ -118,7 +122,7 @@ class UserKeyManager: # Check cache first cached = self._cache.get(api_key) if cached is not None: - key_id, user_id, scopes, cached_at = cached + key_id, user_id, scopes, site_id, cached_at = cached if time.time() - cached_at < _CACHE_TTL_SECONDS: # Update usage in background (fire-and-forget via DB) try: @@ -130,7 +134,7 @@ class UserKeyManager: asyncio.create_task(db.update_api_key_usage(key_id)) except Exception: pass # Non-critical - return {"key_id": key_id, "user_id": user_id, "scopes": scopes} + return {"key_id": key_id, "user_id": user_id, "scopes": scopes, "site_id": site_id} else: del self._cache[api_key] @@ -159,10 +163,12 @@ class UserKeyManager: await db.update_api_key_usage(row["id"]) # Cache the result + site_id = row.get("site_id") self._cache[api_key] = ( row["id"], row["user_id"], row["scopes"], + site_id, time.time(), ) @@ -170,6 +176,7 @@ class UserKeyManager: "key_id": row["id"], "user_id": row["user_id"], "scopes": row["scopes"], + "site_id": site_id, } async def list_keys(self, user_id: str) -> list[dict[str, Any]]: diff --git a/pyproject.toml b/pyproject.toml index d53cc16..f630cca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcphub-server" -version = "3.9.0" +version = "3.11.0" description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)" authors = [ {name = "MCP Hub", email = "contact@mcphub.dev"} diff --git a/scripts/build-css.sh b/scripts/build-css.sh new file mode 100755 index 0000000..c020d40 --- /dev/null +++ b/scripts/build-css.sh @@ -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" diff --git a/scripts/dev-css.sh b/scripts/dev-css.sh new file mode 100755 index 0000000..c9e64cb --- /dev/null +++ b/scripts/dev-css.sh @@ -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 diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..06dfc7d --- /dev/null +++ b/tailwind.config.js @@ -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: [], +} diff --git a/tests/test_csrf_interceptor.py b/tests/test_csrf_interceptor.py new file mode 100644 index 0000000..7922d72 --- /dev/null +++ b/tests/test_csrf_interceptor.py @@ -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 ```` 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 ```` 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 '", 1)[0] diff --git a/tests/test_dashboard_keys_unified.py b/tests/test_dashboard_keys_unified.py index 49725de..0cc8bb9 100644 --- a/tests/test_dashboard_keys_unified.py +++ b/tests/test_dashboard_keys_unified.py @@ -69,11 +69,11 @@ class TestUnifiedKeysUserView: assert r.status_code == 301 assert "/dashboard/keys" in r.headers["location"] - def test_user_view_has_scope_selector(self, user_client): + def test_user_view_has_full_access_badge(self, user_client): r = user_client.get("/dashboard/keys") assert r.status_code == 200 - # Scope dropdown options should be present - assert "read:sensitive" in r.text or "Read" in r.text + # F.7c: No scope selector — shows "Full Access" badge instead + assert "Full Access" in r.text or "دسترسی کامل" in r.text def test_user_view_shows_create_button(self, user_client): r = user_client.get("/dashboard/keys") diff --git a/tests/test_sites_tool_access_ui.py b/tests/test_sites_tool_access_ui.py index 231d17d..d657744 100644 --- a/tests/test_sites_tool_access_ui.py +++ b/tests/test_sites_tool_access_ui.py @@ -1,4 +1,4 @@ -"""Smoke tests for sites edit page Tool Access section and sites view page (F.7b session 2).""" +"""Smoke tests for unified site management page (F.7c redesign).""" from __future__ import annotations @@ -73,38 +73,50 @@ def client(monkeypatch, user_row, patched_db, patched_access): return tc -class TestSitesEditToolAccess: - def test_edit_page_renders_without_500(self, client, coolify_site): +class TestSitesEditRedirect: + def test_edit_page_redirects_to_manage(self, client, coolify_site): + """F.7c: /sites/{id}/edit now redirects to unified /sites/{id}.""" r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit") - assert r.status_code == 200 - - def test_edit_page_contains_tool_access_card(self, client, coolify_site): - r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit") - assert r.status_code == 200 - assert "tool-access-card" in r.text or "Tool Access" in r.text - - def test_edit_page_has_scope_select(self, client, coolify_site): - r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit") - assert r.status_code == 200 - assert "tool-scope-select" in r.text + assert r.status_code == 301 + assert f"/dashboard/sites/{coolify_site['id']}" in r.headers["location"] -class TestSitesViewPage: - def test_view_page_renders_without_500(self, client, coolify_site): +class TestUnifiedSiteManagePage: + def test_manage_page_renders_without_500(self, client, coolify_site): r = client.get(f"/dashboard/sites/{coolify_site['id']}") assert r.status_code == 200 - def test_view_page_shows_mcp_url(self, client, coolify_site): + def test_manage_page_has_connection_section(self, client, coolify_site): + r = client.get(f"/dashboard/sites/{coolify_site['id']}") + assert r.status_code == 200 + assert "connection-section" in r.text + + def test_manage_page_has_tool_access_section(self, client, coolify_site): + r = client.get(f"/dashboard/sites/{coolify_site['id']}") + assert r.status_code == 200 + assert "tool-access-content" in r.text or "Tool Access" in r.text + + def test_manage_page_has_scope_tiers(self, client, coolify_site): + r = client.get(f"/dashboard/sites/{coolify_site['id']}") + assert r.status_code == 200 + assert "scope-tiers" in r.text + + def test_manage_page_shows_mcp_url(self, client, coolify_site): r = client.get(f"/dashboard/sites/{coolify_site['id']}") assert r.status_code == 200 assert "mcp-url" in r.text assert coolify_site["alias"] in r.text - def test_view_page_has_client_selector(self, client, coolify_site): + def test_manage_page_has_config_snippets(self, client, coolify_site): r = client.get(f"/dashboard/sites/{coolify_site['id']}") assert r.status_code == 200 assert "config-client" in r.text + def test_manage_page_has_quick_key_create(self, client, coolify_site): + r = client.get(f"/dashboard/sites/{coolify_site['id']}") + assert r.status_code == 200 + assert "quick-key-btn" in r.text + def test_nonexistent_site_redirects(self, client): r = client.get("/dashboard/sites/nonexistent-id") assert r.status_code in (302, 303) diff --git a/tests/test_tool_access.py b/tests/test_tool_access.py index 67f04a5..8966485 100644 --- a/tests/test_tool_access.py +++ b/tests/test_tool_access.py @@ -181,24 +181,41 @@ class TestScopesToCategories: class TestScopeFilter: + """F.7c: Coolify uses legacy category filter (plugin_type='coolify'), + other plugins use universal required_scope filter.""" + def test_read_scope_drops_lifecycle_crud_system(self, access_mgr): - out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["read"])} + # Coolify tools filtered by category + coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"] + out = { + t.name + for t in access_mgr.apply_scope_filter(coolify_tools, ["read"], plugin_type="coolify") + } assert "coolify_list_applications" in out - assert "wordpress_list_posts" in out # legacy default category assert "coolify_start_application" not in out assert "coolify_create_application_public" not in out assert "coolify_delete_server" not in out assert "coolify_get_application_logs" not in out def test_read_sensitive_includes_logs_and_backups(self, access_mgr): - out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["read:sensitive"])} + coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"] + out = { + t.name + for t in access_mgr.apply_scope_filter( + coolify_tools, ["read:sensitive"], plugin_type="coolify" + ) + } assert "coolify_get_application_logs" in out assert "coolify_get_database_backups" in out assert "coolify_start_application" not in out assert "coolify_create_application_public" not in out def test_deploy_scope_includes_lifecycle_only(self, access_mgr): - out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["deploy"])} + coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"] + out = { + t.name + for t in access_mgr.apply_scope_filter(coolify_tools, ["deploy"], plugin_type="coolify") + } assert "coolify_start_application" in out assert "coolify_stop_application" in out assert "coolify_list_applications" in out @@ -206,7 +223,11 @@ class TestScopeFilter: assert "coolify_delete_server" not in out def test_write_scope_excludes_system(self, access_mgr): - out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["write"])} + coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"] + out = { + t.name + for t in access_mgr.apply_scope_filter(coolify_tools, ["write"], plugin_type="coolify") + } assert "coolify_create_application_public" in out assert "coolify_start_application" in out assert "coolify_create_application_env" in out @@ -214,9 +235,22 @@ class TestScopeFilter: assert "coolify_get_application_logs" not in out def test_admin_keeps_everything(self, access_mgr): - out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["admin"])} + out = { + t.name + for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["admin"], plugin_type="coolify") + } assert out == {t.name for t in _SAMPLE_TOOLS} + def test_universal_read_filters_by_required_scope(self, access_mgr): + """Non-Coolify plugins use universal required_scope filter.""" + wp_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "wordpress"] + out = { + t.name + for t in access_mgr.apply_scope_filter(wp_tools, ["read"], plugin_type="wordpress") + } + # All wordpress sample tools have required_scope="read" + assert "wordpress_list_posts" in out + # --------------------------------------------------------------------------- # Per-site toggles diff --git a/tests/test_user_endpoints.py b/tests/test_user_endpoints.py index 0f1f8cf..e797903 100644 --- a/tests/test_user_endpoints.py +++ b/tests/test_user_endpoints.py @@ -112,6 +112,8 @@ def mock_key_mgr(): def mock_db(): """Patch get_database to return a mock.""" db = AsyncMock() + db.get_site_tool_scope = AsyncMock(return_value="admin") + db.get_site_tool_toggles = AsyncMock(return_value={}) db.get_site_by_alias = AsyncMock( return_value={ "id": "site-uuid-001", @@ -148,6 +150,10 @@ def mock_tool_registry(): tool_def = MagicMock() tool_def.name = "wordpress_list_posts" tool_def.description = "List WordPress posts" + tool_def.plugin_type = "wordpress" + tool_def.required_scope = "read" + tool_def.category = "read" + tool_def.sensitivity = "normal" tool_def.input_schema = { "type": "object", "properties": { @@ -221,6 +227,62 @@ class TestAuthentication: body = json.loads(response.body) assert "does not match" in body["error"]["message"] + @pytest.mark.unit + async def test_site_scoped_key_wrong_site(self, mock_key_mgr, mock_db): + """Site-scoped key (site_id=A) used for site B should return 403.""" + # Key is scoped to site-A + mock_key_mgr.validate_key.return_value = { + "key_id": "key-uuid-001", + "user_id": "user-uuid-001", + "scopes": "read write", + "site_id": "site-uuid-A", + } + # The site looked up by site_id (A) has alias "blog-a", but the request + # is for alias "myblog" (which is site-B in get_site_by_alias). + mock_db.get_site = AsyncMock( + return_value={ + "id": "site-uuid-A", + "alias": "blog-a", + "user_id": "user-uuid-001", + "plugin_type": "wordpress", + "url": "https://blog-a.example.com", + "credentials": b"x", + "status": "active", + } + ) + request = _make_request(alias="myblog") + response = await user_mcp_handler(request) + assert response.status_code == 403 + body = json.loads(response.body) + assert "scoped to a different site" in body["error"]["message"] + + @pytest.mark.unit + async def test_site_scoped_key_matching_site(self, mock_key_mgr, mock_db, mock_tool_registry): + """Site-scoped key used for the matching alias should pass auth.""" + mock_key_mgr.validate_key.return_value = { + "key_id": "key-uuid-001", + "user_id": "user-uuid-001", + "scopes": "read write", + "site_id": "site-uuid-001", + } + mock_db.get_site = AsyncMock( + return_value={ + "id": "site-uuid-001", + "alias": "myblog", + "user_id": "user-uuid-001", + "plugin_type": "wordpress", + "url": "https://myblog.example.com", + "credentials": b"x", + "status": "active", + } + ) + request = _make_request(alias="myblog", method_name="tools/list") + response = await user_mcp_handler(request) + assert response.status_code == 200 + body = json.loads(response.body) + assert "result" in body + assert "tools" in body["result"] + # ── Site Lookup Tests ────────────────────────────────────────