release: v3.3.0 — platform hardening & admin unification (Track F.1–F.8)

Plugin visibility control, UI/UX fixes, unified admin panel,
database-backed settings, and security hardening.

Highlights:
- ENABLED_PLUGINS env var for plugin visibility (F.1)
- Admin designation via ADMIN_EMAILS (F.4a)
- Master key scope control (F.4b)
- Panel unification + settings from UI (F.4c)
- exec() removal, shell injection fix, bcrypt migration (F.8)
- 5 new test suites

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 09:55:49 +02:00
parent f6dbbeaab0
commit 9b70b259bb
47 changed files with 2122 additions and 366 deletions

1
.gitignore vendored
View File

@@ -164,3 +164,4 @@ pytest-cache-files-*/
.worktrees/ .worktrees/
worktrees/ worktrees/
/.agents /.agents
uv.lock

View File

@@ -5,6 +5,41 @@ All notable changes to MCP Hub will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.3.0] — 2026-03-31
### Platform Hardening & Admin Unification (Track F.1F.8)
Major quality release: plugin visibility control, UI/UX polish, unified admin panel, database-backed settings, and security hardening. No breaking API changes.
#### Added
- **Plugin Visibility Control** (F.1): `ENABLED_PLUGINS` env var controls which plugins public users see. Default: `wordpress,woocommerce,supabase`. Admin sees all. New module: `core/plugin_visibility.py`
- **MCP Service Pages** (F.3): Dedicated `/dashboard/services/{type}` pages showing plugin capabilities, tool list, and setup requirements. Services list page with grid of plugin cards
- **Admin by Email** (F.4a): `ADMIN_EMAILS` env var for designating OAuth users as admin (supports multiple emails). OAuth admin users see full admin sidebar
- **Master Key Scope Control** (F.4b): `DISABLE_MASTER_KEY_LOGIN` to block dashboard login via master key. `MASTER_KEY_SCOPE` (`all`/`admin`) to restrict master key to admin endpoints only
- **Panel Unification** (F.4c): OAuth admin sees both "My Tools" and "Administration" sidebar sections. Master key admin auto-creates user record on first login for unified site management
- **Settings from UI** (F.4c.3): Database-backed settings with DB > ENV > Default priority. Admin can edit `ENABLED_PLUGINS`, `MAX_SITES_PER_USER`, rate limits, registration toggle from dashboard. New module: `core/settings.py`
- **Dashboard Stats** (F.3): Admin home page shows Total Users and User Sites stat cards
- **Pre-configured OAuth** (F.2): Default OAuth redirect URIs for Claude.ai; green tip about optional OAuth
#### Fixed
- **Connect Page** (F.2): WordPress/SEO amber info box now shown only for WordPress/WooCommerce sites (was shown for all plugin types)
- **Sidebar Version** (F.2): Fixed "v" displaying when version string is empty
- **Auth Page Language** (F.2): Auth page defaults to English regardless of Accept-Language header
- **Donation Link** (F.2): Moved from home page to sidebar for consistent visibility
- **Test Connection** (F.3): Fixed 504/HTML error handling; status badge auto-updates without page refresh; added `last_tested_at` timestamp (DB schema v4)
- **Admin Auth** (F.4): Fixed `_require_admin_session()` to recognize OAuth admin sessions; hide "Admin Login with API Key" button when `DISABLE_MASTER_KEY_LOGIN=true`
- **Starlette API** (F.2): Updated `TemplateResponse` to new API (request as first arg)
#### Security
- **exec() Removal** (F.8): Replaced `exec()` in `core/tool_generator.py` with closure-based tool generation
- **Shell Injection Fix** (F.8): Replaced `create_subprocess_shell` with `create_subprocess_exec` in WP-CLI handler
- **bcrypt Migration** (F.8): Admin API key hashing upgraded from unsalted SHA-256 to bcrypt
#### Tests
- New test suites: `test_plugin_visibility.py`, `test_admin_system.py`, `test_f3_admin_stats.py`, `test_f3_last_tested.py`, `test_f3_service_pages.py`
---
## [3.2.0] — 2026-02-25 ## [3.2.0] — 2026-02-25
### Fixed ### Fixed

View File

@@ -121,6 +121,8 @@ plugins/{name}/
**Registered plugins**: wordpress, woocommerce, wordpress_advanced, gitea, n8n, supabase, openpanel, appwrite, directus **Registered plugins**: wordpress, woocommerce, wordpress_advanced, gitea, n8n, supabase, openpanel, appwrite, directus
**Plugin visibility** (Track F.1): Public users only see plugins listed in `ENABLED_PLUGINS` env var (default: `wordpress,woocommerce,supabase`). Admin sees all. Controlled by `core/plugin_visibility.py`.
### Tool Generation ### Tool Generation
Tools are dynamically generated at startup: Tools are dynamically generated at startup:
@@ -159,10 +161,24 @@ Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are a
| `core/oauth/` | OAuth 2.1 with PKCE (RFC 8414, 7591, 7636) | | `core/oauth/` | OAuth 2.1 with PKCE (RFC 8414, 7591, 7636) |
| `core/dashboard/routes.py` | Web UI dashboard (login, projects, API keys, health, audit) | | `core/dashboard/routes.py` | Web UI dashboard (login, projects, API keys, health, audit) |
| `core/endpoints/` | Multi-endpoint architecture (factory, registry, config) | | `core/endpoints/` | Multi-endpoint architecture (factory, registry, config) |
| `core/plugin_visibility.py` | Plugin enable/disable for public vs admin users |
| `core/user_auth.py` | OAuth Social Login (GitHub + Google) |
| `core/user_endpoints.py` | Per-user MCP endpoints (`/u/{user_id}/{alias}/mcp`) |
| `core/site_api.py` | User site CRUD, connection testing, credential encryption |
| `core/user_keys.py` | User API key management (bcrypt, `mhu_` prefix) |
| `core/database.py` | SQLite backend (aiosqlite, WAL mode, migrations) |
| `core/encryption.py` | AES-256-GCM credential encryption |
### User System (Track E)
OAuth Social Login (GitHub + Google) via `core/user_auth.py`. Users register, add sites, get personal MCP endpoints at `/u/{user_id}/{alias}/mcp`. Per-user API keys with `mhu_` prefix (bcrypt-hashed). Credentials encrypted with AES-256-GCM in SQLite.
### Dashboard ### Dashboard
Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS. Supports EN/FA i18n (`core/i18n.py`). 8 pages: Login, Home, Projects, API Keys, OAuth Clients, Audit Logs, Health, Settings. Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS. Supports EN/FA i18n (`core/i18n.py`).
**Admin pages**: Login, Home, Projects, API Keys, OAuth Clients, Audit Logs, Health, Settings.
**User pages**: Login (OAuth), Home, My Sites (list/add/edit/test/delete), Connect (API keys + config snippets + OAuth clients), Profile.
### Legacy Modules (Deprecated) ### Legacy Modules (Deprecated)
@@ -175,12 +191,27 @@ Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS.
``` ```
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
## Live Instances
- **Platform**: `mcp.palebluedot.live` — Live MCP Hub with OAuth login
- **Blog**: `blog.palebluedot.live` — WordPress test site + project blog
- **Deployment**: Coolify / Docker Compose, port 8000
## Current Development (Track F)
v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claude/plans/structured-popping-adleman.md` for full plan.
**Active plugins for public users**: WordPress, WooCommerce, Supabase (configurable via `ENABLED_PLUGINS` env var)
## Gotchas ## Gotchas
- Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only. - Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only.
- `server_multi.py` is the alternative multi-endpoint entry point; `server.py` is the primary - `server_multi.py` is the alternative multi-endpoint entry point; `server.py` is the primary
- `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-seo-bridge) — these are PHP, not Python - `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-seo-bridge) — these are PHP, not Python
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented - `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
- Only 3 plugins are tested for public use: WordPress, WooCommerce, Supabase. Others are admin-only or disabled.
- OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login
- User sites stored in SQLite (`core/database.py`), admin sites still from env vars
- Dashboard templates live in `core/templates/` (included in pip package as `package_data`) - Dashboard templates live in `core/templates/` (included in pip package as `package_data`)
- `ruff` config uses top-level `select` key in pyproject.toml (not `[tool.ruff.lint]` nested format) - `ruff` config uses top-level `select` key in pyproject.toml (not `[tool.ruff.lint]` nested format)
- The `scripts/` directory has platform-specific setup scripts: `setup.sh` (Linux/Mac), `setup.ps1` (Windows) - The `scripts/` directory has platform-specific setup scripts: `setup.sh` (Linux/Mac), `setup.ps1` (Windows)
@@ -192,3 +223,6 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
- Docker socket mount required for WP-CLI tools: `/var/run/docker.sock:/var/run/docker.sock:ro` - Docker socket mount required for WP-CLI tools: `/var/run/docker.sock:/var/run/docker.sock:ro`
- Persistent volumes: `mcp-data` (API keys, OAuth), `mcp-logs` (audit, health) - Persistent volumes: `mcp-data` (API keys, OAuth), `mcp-logs` (audit, health)
- Required env vars: `MASTER_API_KEY`, `OAUTH_JWT_SECRET_KEY`, `OAUTH_BASE_URL` - Required env vars: `MASTER_API_KEY`, `OAUTH_JWT_SECRET_KEY`, `OAUTH_BASE_URL`
- OAuth env vars: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `PUBLIC_URL`
- User limits: `MAX_SITES_PER_USER=10`, `USER_RATE_LIMIT_PER_MIN=30`, `USER_RATE_LIMIT_PER_HR=500`
- Plugin visibility: `ENABLED_PLUGINS=wordpress,woocommerce,supabase` (default)

View File

@@ -112,14 +112,14 @@ You should see the login page. Log in with your `MASTER_API_KEY` or via **GitHub
### Try It Now (No Setup Required) ### Try It Now (No Setup Required)
**Don't want to self-host?** Use the hosted instance at **[mcp.example.com](https://mcp.example.com)**: **Don't want to self-host?** Try the demo instance at **[mcp.palebluedot.live](https://mcp.palebluedot.live)**:
1. Log in with **GitHub** or **Google** 1. Log in with **GitHub** or **Google**
2. Add your sites via the dashboard (My Sites → Add Service) 2. Add your sites via the dashboard (My Sites → Add Service)
3. Go to **Connect** page — generate config for your AI client 3. Go to **Connect** page — generate config for your AI client
4. Copy-paste the config into Claude Desktop, VS Code, or Claude Code 4. Copy-paste the config into Claude Desktop, VS Code, or Claude Code
Your personal MCP endpoint: `https://mcp.example.com/u/{your-user-id}/{alias}/mcp` Your personal MCP endpoint: `https://mcp.palebluedot.live/u/{your-user-id}/{alias}/mcp`
--- ---

27
core/admin_utils.py Normal file
View File

@@ -0,0 +1,27 @@
"""Admin utility functions for role determination.
Centralizes admin email checking so it can be used in OAuth callback,
dashboard auth, and future admin/user panel unification (F.5+).
"""
import os
def is_admin_email(email: str | None) -> bool:
"""Check if an email is in the ADMIN_EMAILS env var list.
Args:
email: Email address to check.
Returns:
True if email matches an admin email (case-insensitive).
"""
if not email:
return False
admin_emails_raw = os.environ.get("ADMIN_EMAILS", "")
if not admin_emails_raw.strip():
return False
admin_emails = {e.strip().lower() for e in admin_emails_raw.split(",") if e.strip()}
return email.strip().lower() in admin_emails

View File

@@ -14,6 +14,8 @@ from dataclasses import asdict, dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
import bcrypt
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -79,7 +81,7 @@ class APIKey:
""" """
key_id: str key_id: str
key_hash: str key_hash: str # bcrypt hash (new keys) or SHA-256 hex (legacy)
project_id: str project_id: str
scope: Scope scope: Scope
created_at: str created_at: str
@@ -180,8 +182,16 @@ class APIKeyManager:
logger.error(f"Failed to save keys: {e}") logger.error(f"Failed to save keys: {e}")
def _hash_key(self, api_key: str) -> str: def _hash_key(self, api_key: str) -> str:
"""Hash API key for storage.""" """Hash API key for storage using bcrypt."""
return hashlib.sha256(api_key.encode()).hexdigest() return bcrypt.hashpw(api_key.encode(), bcrypt.gensalt()).decode()
def _verify_key(self, api_key: str, key_hash: str) -> bool:
"""Verify API key against stored hash (supports bcrypt and legacy SHA-256)."""
if key_hash.startswith("$2"):
# bcrypt hash
return bcrypt.checkpw(api_key.encode(), key_hash.encode())
# Legacy SHA-256 fallback
return hashlib.sha256(api_key.encode()).hexdigest() == key_hash
def create_key( def create_key(
self, self,
@@ -269,11 +279,9 @@ class APIKeyManager:
Returns: Returns:
Optional[str]: key_id if valid, None otherwise Optional[str]: key_id if valid, None otherwise
""" """
key_hash = self._hash_key(api_key) # Find key by verifying against stored hash (bcrypt or legacy SHA-256)
# Find key by hash
for key_id, key in self.keys.items(): for key_id, key in self.keys.items():
if key.key_hash != key_hash: if not self._verify_key(api_key, key.key_hash):
continue continue
# Check if valid (not revoked, not expired) # Check if valid (not revoked, not expired)
@@ -341,10 +349,8 @@ class APIKeyManager:
Returns: Returns:
Optional[APIKey]: The APIKey object if found, None otherwise Optional[APIKey]: The APIKey object if found, None otherwise
""" """
key_hash = self._hash_key(api_key)
for key_id, key in self.keys.items(): for key_id, key in self.keys.items():
if key.key_hash == key_hash: if self._verify_key(api_key, key.key_hash):
logger.debug(f"Found API key {key_id} by token") logger.debug(f"Found API key {key_id} by token")
return key return key

View File

@@ -20,6 +20,11 @@ import json
# Supported MCP client types # Supported MCP client types
SUPPORTED_CLIENTS = [ SUPPORTED_CLIENTS = [
{
"id": "claude_connectors",
"label": "Claude.ai Connectors",
"description": "claude.ai/customize/connectors",
},
{ {
"id": "claude_desktop", "id": "claude_desktop",
"label": "Claude Desktop", "label": "Claude Desktop",
@@ -47,6 +52,9 @@ SUPPORTED_CLIENTS = [
}, },
] ]
# Clients that only need a URL (no JSON config snippet, no transport Note)
WEB_CLIENTS = {"claude_connectors", "chatgpt"}
def get_supported_clients() -> list[dict[str, str]]: def get_supported_clients() -> list[dict[str, str]]:
"""Return the list of supported MCP client types.""" """Return the list of supported MCP client types."""
@@ -112,7 +120,7 @@ def generate_config(
} }
return json.dumps(config, indent=2) return json.dumps(config, indent=2)
elif client_type == "chatgpt": elif client_type in ("claude_connectors", "chatgpt"):
return endpoint_url return endpoint_url
else: else:

View File

@@ -90,13 +90,21 @@ class DashboardAuth:
return False, "", None return False, "", None
api_key_clean = api_key.strip() api_key_clean = api_key.strip()
# Check if master key dashboard login is disabled
master_login_disabled = (
os.environ.get("DISABLE_MASTER_KEY_LOGIN", "false").lower() == "true"
)
# Check master API key (from env var) # Check master API key (from env var)
if self.master_api_key and secrets.compare_digest( if (
api_key_clean, self.master_api_key.strip() not master_login_disabled
and self.master_api_key
and secrets.compare_digest(api_key_clean, self.master_api_key.strip())
): ):
return True, "master", None return True, "master", None
# Check AuthManager's master key (covers auto-generated temp keys) # Check AuthManager's master key (covers auto-generated temp keys)
if not master_login_disabled:
try: try:
from core.auth import get_auth_manager from core.auth import get_auth_manager
@@ -353,9 +361,10 @@ def get_session_display_info(session) -> dict:
if isinstance(session, DashboardSession): if isinstance(session, DashboardSession):
return {"name": "Admin", "type": "admin", "email": None, "avatar": None} return {"name": "Admin", "type": "admin", "email": None, "avatar": None}
if isinstance(session, dict): if isinstance(session, dict):
session_type = "admin" if session.get("role") == "admin" else "user"
return { return {
"name": session.get("name") or session.get("email", "User"), "name": session.get("name") or session.get("email", "User"),
"type": "user", "type": session_type,
"email": session.get("email"), "email": session.get("email"),
"avatar": None, "avatar": None,
} }

View File

@@ -64,6 +64,7 @@ DASHBOARD_TRANSLATIONS = {
"oauth_clients": "OAuth Clients", "oauth_clients": "OAuth Clients",
"audit_logs": "Audit Logs", "audit_logs": "Audit Logs",
"health": "Health", "health": "Health",
"services": "Services",
"settings": "Settings", "settings": "Settings",
"logout": "Logout", "logout": "Logout",
# Login page # Login page
@@ -127,8 +128,12 @@ DASHBOARD_TRANSLATIONS = {
"edit_site": "Edit Site", "edit_site": "Edit Site",
"updating_site": "Updating site...", "updating_site": "Updating site...",
"keep_existing": "Leave blank to keep current value", "keep_existing": "Leave blank to keep current value",
"leave_blank_to_clear": "Leave blank to clear (optional field)",
"connection_ok": "Connection OK", "connection_ok": "Connection OK",
"connection_failed": "Connection failed", "connection_failed": "Connection failed",
"last_tested": "Last tested",
"never_tested": "Not tested yet",
"just_now": "just now",
"credentials": "Credentials", "credentials": "Credentials",
"select_plugin": "Select plugin type", "select_plugin": "Select plugin type",
"adding_site": "Adding site...", "adding_site": "Adding site...",
@@ -164,6 +169,7 @@ DASHBOARD_TRANSLATIONS = {
"oauth_clients": "کلاینت‌های OAuth", "oauth_clients": "کلاینت‌های OAuth",
"audit_logs": "لاگ‌های ممیزی", "audit_logs": "لاگ‌های ممیزی",
"health": "سلامت", "health": "سلامت",
"services": "سرویس‌ها",
"settings": "تنظیمات", "settings": "تنظیمات",
"logout": "خروج", "logout": "خروج",
# Login page # Login page
@@ -227,8 +233,12 @@ DASHBOARD_TRANSLATIONS = {
"edit_site": "ویرایش سایت", "edit_site": "ویرایش سایت",
"updating_site": "در حال بروزرسانی...", "updating_site": "در حال بروزرسانی...",
"keep_existing": "خالی بگذارید تا مقدار فعلی حفظ شود", "keep_existing": "خالی بگذارید تا مقدار فعلی حفظ شود",
"leave_blank_to_clear": "خالی بگذارید برای حذف مقدار (فیلد اختیاری)",
"connection_ok": "اتصال برقرار", "connection_ok": "اتصال برقرار",
"connection_failed": "اتصال ناموفق", "connection_failed": "اتصال ناموفق",
"last_tested": "آخرین تست",
"never_tested": "هنوز تست نشده",
"just_now": "همین الان",
"credentials": "مشخصات دسترسی", "credentials": "مشخصات دسترسی",
"select_plugin": "نوع پلاگین را انتخاب کنید", "select_plugin": "نوع پلاگین را انتخاب کنید",
"adding_site": "در حال افزودن سایت...", "adding_site": "در حال افزودن سایت...",
@@ -321,6 +331,20 @@ async def get_dashboard_stats() -> dict:
except Exception as e: except Exception as e:
logger.warning(f"Error getting dashboard stats: {e}") logger.warning(f"Error getting dashboard stats: {e}")
# Platform stats (user system)
try:
from core.database import get_database
db = get_database()
row = await db.fetchone("SELECT COUNT(*) AS c FROM users")
stats["users_count"] = row["c"] if row else 0
row = await db.fetchone("SELECT COUNT(*) AS c FROM sites")
stats["user_sites_count"] = row["c"] if row else 0
except Exception as e:
logger.debug(f"Error getting platform stats: {e}")
stats.setdefault("users_count", 0)
stats.setdefault("user_sites_count", 0)
return stats return stats
@@ -330,7 +354,6 @@ async def get_user_dashboard_stats(user_id: str) -> dict:
"sites_count": 0, "sites_count": 0,
"active_sites_count": 0, "active_sites_count": 0,
"api_keys_count": 0, "api_keys_count": 0,
"tools_count": 0,
} }
try: try:
from core.site_api import get_user_sites from core.site_api import get_user_sites
@@ -350,14 +373,6 @@ async def get_user_dashboard_stats(user_id: str) -> dict:
except Exception as e: except Exception as e:
logger.debug(f"Error getting user keys count: {e}") logger.debug(f"Error getting user keys count: {e}")
try:
from core.tool_registry import get_tool_registry
tool_registry = get_tool_registry()
stats["tools_count"] = len(tool_registry.get_all())
except Exception:
stats["tools_count"] = 596 # Fallback
return stats return stats
@@ -489,9 +504,9 @@ async def dashboard_login_page(request: Request) -> Response:
next_url = request.query_params.get("next", "/dashboard") next_url = request.query_params.get("next", "/dashboard")
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/login.html", "dashboard/login.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"error": error, "error": error,
@@ -501,6 +516,47 @@ async def dashboard_login_page(request: Request) -> Response:
) )
async def _ensure_master_key_user(auth) -> str | None:
"""Ensure a user record exists for master key admin.
Creates a user with provider='master_key' if none exists,
then returns a user session JWT so the admin can use My Sites, Connect, etc.
Returns:
User session JWT token, or None if creation failed.
"""
from core.database import get_database
from core.user_auth import get_user_auth
db = get_database()
user_auth = get_user_auth()
# Check if master key user already exists
user = await db.get_user_by_provider("master_key", "master")
if not user:
# Create user record for master key admin
user = await db.create_user(
email="admin@localhost",
name="Admin",
provider="master_key",
provider_id="master",
role="admin",
)
logger.info("Created user record for master key admin: %s", user["id"])
else:
# Update last login
await db.update_user_last_login(user["id"])
# Create user session JWT (same as OAuth users get)
token = user_auth.create_user_session(
user_id=user["id"],
email=user["email"],
name=user.get("name"),
role="admin",
)
return token
async def dashboard_login_submit(request: Request) -> Response: async def dashboard_login_submit(request: Request) -> Response:
"""Handle dashboard login form submission.""" """Handle dashboard login form submission."""
auth = get_dashboard_auth() auth = get_dashboard_auth()
@@ -557,6 +613,17 @@ async def dashboard_login_submit(request: Request) -> Response:
# Create session # Create session
token = auth.create_session(user_type, key_id) token = auth.create_session(user_type, key_id)
# For master key login: auto-create user record + user session
# so master key admin can access My Sites, Connect, etc.
if user_type == "master":
try:
user_token = await _ensure_master_key_user(auth)
if user_token:
token = user_token # Use user session instead of admin session
except Exception as e:
logger.warning("Failed to create master key user record: %s", e)
# Fall back to admin-only session (sites won't work, but dashboard will)
# Redirect to dashboard # Redirect to dashboard
response = RedirectResponse(url=next_url, status_code=303) response = RedirectResponse(url=next_url, status_code=303)
auth.set_session_cookie(response, token) auth.set_session_cookie(response, token)
@@ -623,7 +690,6 @@ async def dashboard_home(request: Request) -> Response:
t = get_translations(lang) t = get_translations(lang)
context = { context = {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": session, "session": session,
@@ -657,7 +723,7 @@ async def dashboard_home(request: Request) -> Response:
} }
) )
return templates.TemplateResponse("dashboard/index.html", context) return templates.TemplateResponse(request, "dashboard/index.html", context)
async def dashboard_api_stats(request: Request) -> Response: async def dashboard_api_stats(request: Request) -> Response:
@@ -781,18 +847,23 @@ async def get_all_projects(
site_manager = get_site_manager() site_manager = get_site_manager()
sites = site_manager.list_all_sites() sites = site_manager.list_all_sites()
is_master = False is_admin = False
current_user_id = None current_user_id = None
if user_session: if user_session:
if hasattr(user_session, "user_type") and user_session.user_type == "master": if (
is_master = True hasattr(user_session, "user_type")
and user_session.user_type == "master"
or isinstance(user_session, dict)
and user_session.get("role") == "admin"
):
is_admin = True
elif isinstance(user_session, dict) and "user_id" in user_session: elif isinstance(user_session, dict) and "user_id" in user_session:
current_user_id = user_session["user_id"] current_user_id = user_session["user_id"]
for site in sites: for site in sites:
# Tenant isolation checks # Tenant isolation checks
site_user_id = site.get("user_id") site_user_id = site.get("user_id")
if not is_master: if not is_admin:
if site_user_id != current_user_id: if site_user_id != current_user_id:
continue continue
@@ -1003,9 +1074,9 @@ async def dashboard_projects_list(request: Request) -> Response:
) )
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/projects/list.html", "dashboard/projects/list.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": session, "session": session,
@@ -1048,9 +1119,9 @@ async def dashboard_project_detail(request: Request) -> Response:
if not project: if not project:
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/projects/list.html", "dashboard/projects/list.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": session, "session": session,
@@ -1062,9 +1133,9 @@ async def dashboard_project_detail(request: Request) -> Response:
) )
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/projects/detail.html", "dashboard/projects/detail.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": session, "session": session,
@@ -1284,9 +1355,9 @@ async def dashboard_api_keys_list(request: Request) -> Response:
available_projects = site_manager.list_all_sites() available_projects = site_manager.list_all_sites()
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/api-keys/list.html", "dashboard/api-keys/list.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": session, "session": session,
@@ -1472,9 +1543,9 @@ async def dashboard_oauth_clients_list(request: Request) -> Response:
clients_data = await get_oauth_clients_data() clients_data = await get_oauth_clients_data()
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/oauth-clients/list.html", "dashboard/oauth-clients/list.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": session, "session": session,
@@ -1724,9 +1795,9 @@ async def dashboard_audit_logs_list(request: Request) -> Response:
available_projects = site_manager.list_all_sites() available_projects = site_manager.list_all_sites()
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/audit-logs/list.html", "dashboard/audit-logs/list.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": session, "session": session,
@@ -2040,9 +2111,9 @@ async def dashboard_health_page(request: Request) -> Response:
health_data = await get_health_data(live_check=refresh) health_data = await get_health_data(live_check=refresh)
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/health/index.html", "dashboard/health/index.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": session if session else {}, "session": session if session else {},
@@ -2091,9 +2162,9 @@ async def dashboard_health_projects_partial(request: Request) -> Response:
# Render partial HTML for projects health table # Render partial HTML for projects health table
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/health/projects-partial.html", "dashboard/health/projects-partial.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"system_status": health_data["system_status"], "system_status": health_data["system_status"],
@@ -2245,6 +2316,11 @@ async def dashboard_settings_page(request: Request) -> Response:
plugins = get_registered_plugins() plugins = get_registered_plugins()
about = get_about_info() about = get_about_info()
# Get managed settings (4C.3)
from core.settings import get_all_managed_settings
managed_settings = await get_all_managed_settings()
# Format session display info (for Session Information section) # Format session display info (for Session Information section)
if isinstance(session, dict): if isinstance(session, dict):
session_display = { session_display = {
@@ -2260,9 +2336,9 @@ async def dashboard_settings_page(request: Request) -> Response:
} }
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/settings/index.html", "dashboard/settings/index.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": session, # Original session for RBAC sidebar "session": session, # Original session for RBAC sidebar
@@ -2270,11 +2346,43 @@ async def dashboard_settings_page(request: Request) -> Response:
"config": config, "config": config,
"plugins": plugins, "plugins": plugins,
"about": about, "about": about,
"managed_settings": managed_settings,
"current_page": "settings", "current_page": "settings",
}, },
) )
async def api_save_setting(request: Request) -> Response:
"""POST /api/dashboard/settings — Save a managed setting."""
session, redirect = _require_admin_session(request)
if redirect:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
key = body.get("key", "")
value = body.get("value", "")
action = body.get("action", "save") # "save" or "reset"
from core.settings import SETTING_DEFAULTS, delete_setting_value, save_setting
if key not in SETTING_DEFAULTS:
return JSONResponse({"error": f"Unknown setting: {key}"}, status_code=400)
if action == "reset":
await delete_setting_value(key)
return JSONResponse({"message": f"Setting '{key}' reset to default"})
if not value.strip():
return JSONResponse({"error": "Value cannot be empty"}, status_code=400)
await save_setting(key, value.strip())
return JSONResponse({"message": f"Setting '{key}' saved"})
# ============================================================================= # =============================================================================
# E.2: OAuth Social Login Routes # E.2: OAuth Social Login Routes
# ============================================================================= # =============================================================================
@@ -2289,10 +2397,9 @@ async def auth_login_page(request: Request) -> Response:
if session: if session:
return RedirectResponse(url="/dashboard", status_code=303) return RedirectResponse(url="/dashboard", status_code=303)
# Get language # Get language — default to English for auth page, only override via ?lang=
accept_language = request.headers.get("accept-language")
query_lang = request.query_params.get("lang") query_lang = request.query_params.get("lang")
lang = detect_language(accept_language, query_lang) lang = detect_language(None, query_lang)
t = get_translations(lang) t = get_translations(lang)
error = request.query_params.get("error") error = request.query_params.get("error")
@@ -2308,14 +2415,16 @@ async def auth_login_page(request: Request) -> Response:
pass pass
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/auth-login.html", "dashboard/auth-login.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"error": error, "error": error,
"providers": providers, "providers": providers,
"version": _get_project_version(), "version": _get_project_version(),
"master_login_disabled": os.environ.get("DISABLE_MASTER_KEY_LOGIN", "false").lower()
== "true",
}, },
) )
@@ -2459,14 +2568,23 @@ async def auth_callback(request: Request) -> Response:
provider, provider,
) )
# Determine effective role: check ADMIN_EMAILS env var
from core.admin_utils import is_admin_email
db_role = user.get("role", "user")
effective_role = "admin" if is_admin_email(user.get("email")) else db_role
# Create session # Create session
token = user_auth.create_user_session( token = user_auth.create_user_session(
user_id=user["id"], user_id=user["id"],
email=user["email"], email=user["email"],
name=user.get("name"), name=user.get("name"),
role=user.get("role", "user"), role=effective_role,
) )
if effective_role == "admin":
logger.info("Admin role granted to %s via ADMIN_EMAILS", user["email"])
# Check for return URL (OAuth consent flow redirect-back) # Check for return URL (OAuth consent flow redirect-back)
next_url = request.cookies.get("mcp_auth_next", "") next_url = request.cookies.get("mcp_auth_next", "")
# Validate: must be relative URL or same-origin to prevent open redirect # Validate: must be relative URL or same-origin to prevent open redirect
@@ -2545,9 +2663,9 @@ async def dashboard_profile_page(request: Request) -> Response:
logger.warning("Failed to fetch user profile: %s", e) logger.warning("Failed to fetch user profile: %s", e)
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/profile.html", "dashboard/profile.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": user_session, "session": user_session,
@@ -2574,9 +2692,14 @@ def _require_user_session(request: Request):
def _require_admin_session(request: Request): def _require_admin_session(request: Request):
"""Get admin session or redirect to dashboard. Helper for admin-only routes.""" """Get admin session or redirect to dashboard. Helper for admin-only routes."""
auth = get_dashboard_auth() auth = get_dashboard_auth()
# Check DashboardSession (master key / API key)
session = auth.get_session_from_request(request) session = auth.get_session_from_request(request)
if session and is_admin_session(session): if session and is_admin_session(session):
return session, None return session, None
# Check OAuth user session (admin role)
user_session = auth.get_user_session_from_request(request)
if user_session and is_admin_session(user_session):
return user_session, None
# Not admin — redirect to dashboard home # Not admin — redirect to dashboard home
return None, RedirectResponse(url="/dashboard", status_code=303) return None, RedirectResponse(url="/dashboard", status_code=303)
@@ -2616,9 +2739,9 @@ async def dashboard_sites_list(request: Request) -> Response:
) )
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/sites/list.html", "dashboard/sites/list.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": user_session, "session": user_session,
@@ -2644,16 +2767,28 @@ async def dashboard_sites_add(request: Request) -> Response:
import json import json
from core.site_api import get_user_credential_fields, get_user_plugin_names from core.site_api import (
PLUGIN_CREDENTIAL_FIELDS,
PLUGIN_DISPLAY_NAMES,
get_user_credential_fields,
get_user_plugin_names,
)
# Non-admin users get a filtered list (no wordpress_advanced) # Admin sees all plugins; regular users get filtered list
if is_admin_session(user_session):
plugin_fields = dict(PLUGIN_CREDENTIAL_FIELDS)
plugin_names = dict(PLUGIN_DISPLAY_NAMES)
else:
plugin_fields = get_user_credential_fields() plugin_fields = get_user_credential_fields()
plugin_names = get_user_plugin_names() plugin_names = get_user_plugin_names()
# Pre-select plugin type from query param (e.g., from service page)
preselect_plugin = request.query_params.get("plugin_type", "")
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/sites/add.html", "dashboard/sites/add.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": user_session, "session": user_session,
@@ -2661,6 +2796,7 @@ async def dashboard_sites_add(request: Request) -> Response:
"plugin_fields_json": json.dumps(plugin_fields), "plugin_fields_json": json.dumps(plugin_fields),
"plugin_names": plugin_names, "plugin_names": plugin_names,
"current_page": "my_sites", "current_page": "my_sites",
"preselect_plugin": preselect_plugin,
}, },
) )
@@ -2693,9 +2829,9 @@ async def dashboard_connect_page(request: Request) -> Response:
new_key = request.query_params.get("new_key") new_key = request.query_params.get("new_key")
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/connect.html", "dashboard/connect.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": user_session, "session": user_session,
@@ -2811,7 +2947,14 @@ async def api_test_site(request: Request) -> Response:
try: try:
ok, msg = await test_site_connection(site_id, user_session["user_id"]) ok, msg = await test_site_connection(site_id, user_session["user_id"])
return JSONResponse({"ok": ok, "message": msg}) return JSONResponse(
{
"ok": ok,
"message": msg,
"status": "active" if ok else "error",
"last_tested_at": datetime.now(UTC).isoformat(),
}
)
except ValueError as e: except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=404) return JSONResponse({"error": str(e)}, status_code=404)
except Exception as e: except Exception as e:
@@ -2844,9 +2987,9 @@ async def dashboard_sites_edit(request: Request) -> Response:
plugin_names = get_user_plugin_names() plugin_names = get_user_plugin_names()
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/sites/edit.html", "dashboard/sites/edit.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": user_session, "session": user_session,
@@ -2913,7 +3056,7 @@ async def api_create_key(request: Request) -> Response:
result = await key_mgr.create_key( result = await key_mgr.create_key(
user_id=user_session["user_id"], user_id=user_session["user_id"],
name=body.get("name", "Default"), name=body.get("name", "Default"),
scopes=body.get("scopes", "read write"), scopes=body.get("scopes", "read write admin"),
expires_in_days=body.get("expires_in_days"), expires_in_days=body.get("expires_in_days"),
) )
return JSONResponse({"key": result}) return JSONResponse({"key": result})
@@ -3005,9 +3148,9 @@ async def dashboard_user_oauth_clients_list(request: Request) -> Response:
user_clients = [c for c in registry.list_clients() if c.owner_user_id == user_id] user_clients = [c for c in registry.list_clients() if c.owner_user_id == user_id]
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard/user-oauth-clients.html", "dashboard/user-oauth-clients.html",
{ {
"request": request,
"lang": lang, "lang": lang,
"t": t, "t": t,
"session": user_session, "session": user_session,
@@ -3091,6 +3234,195 @@ async def dashboard_user_oauth_clients_delete(request: Request) -> Response:
return JSONResponse({"success": True}) return JSONResponse({"success": True})
async def get_service_page_data(plugin_type: str) -> dict | None:
"""Get data for a plugin service page."""
from plugins import registry as plugin_registry
if not plugin_registry.is_registered(plugin_type):
return None
display_name = get_plugin_display_name(plugin_type)
# Get tools from registry
tools = []
try:
from core.tool_registry import get_tool_registry
tool_registry = get_tool_registry()
tool_defs = tool_registry.get_by_plugin_type(plugin_type)
for td in tool_defs:
tools.append(
{
"name": td.name,
"description": td.description,
"scope": td.required_scope,
}
)
except Exception:
pass
# Fallback: get from plugin specs directly if registry had no tools
if not tools:
try:
plugin_class = plugin_registry._plugin_classes[plugin_type]
specs = plugin_class.get_tool_specifications()
for spec in specs:
tools.append(
{
"name": spec.get("name", ""),
"description": spec.get("description", ""),
"scope": spec.get("scope", "read"),
}
)
except Exception:
pass
# Sort tools by scope then name
scope_order = {"read": 0, "write": 1, "admin": 2}
tools.sort(key=lambda t: (scope_order.get(t["scope"], 9), t["name"]))
# Get credential fields
credential_fields = []
try:
from core.site_api import PLUGIN_CREDENTIAL_FIELDS
credential_fields = PLUGIN_CREDENTIAL_FIELDS.get(plugin_type, [])
except Exception:
pass
from core.plugin_visibility import is_plugin_public
return {
"plugin_type": plugin_type,
"display_name": display_name,
"tools": tools,
"tools_count": len(tools),
"credential_fields": credential_fields,
"is_public": is_plugin_public(plugin_type),
}
async def dashboard_services_list(request: Request) -> Response:
"""GET /dashboard/services — List available MCP services."""
auth = get_dashboard_auth()
redirect = auth.require_auth(request)
if redirect:
return redirect
session = auth.get_session_from_request(request) or auth.get_user_session_from_request(request)
admin = is_admin_session(session)
display_info = get_session_display_info(session)
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)
# Get plugin list based on user role
if admin:
from plugins import registry as plugin_registry
plugin_types = plugin_registry.get_registered_types()
else:
from core.plugin_visibility import get_public_plugin_types
plugin_types = sorted(get_public_plugin_types())
services = []
for pt in plugin_types:
data = await get_service_page_data(pt)
if data:
services.append(data)
return templates.TemplateResponse(
request,
"dashboard/services_list.html",
{
"lang": lang,
"t": t,
"session": session,
"is_admin": admin,
"display_info": display_info,
"current_page": "services",
"services": services,
},
)
async def dashboard_service_page(request: Request) -> Response:
"""GET /dashboard/services/{plugin_type} — Show plugin info page."""
auth = get_dashboard_auth()
redirect = auth.require_auth(request)
if redirect:
return redirect
session = auth.get_session_from_request(request) or auth.get_user_session_from_request(request)
admin = is_admin_session(session)
display_info = get_session_display_info(session)
plugin_type = request.path_params.get("plugin_type", "")
# Non-admin users can only see public plugins
if not admin:
from core.plugin_visibility import is_plugin_public
if not is_plugin_public(plugin_type):
accept_language = request.headers.get("accept-language")
query_lang = request.query_params.get("lang")
lang = detect_language(accept_language, query_lang)
return templates.TemplateResponse(
request,
"dashboard/404.html",
{
"lang": lang,
"t": get_translations(lang),
"session": session,
"is_admin": admin,
"display_info": display_info,
"current_page": "services",
},
status_code=404,
)
data = await get_service_page_data(plugin_type)
if data is None:
accept_language = request.headers.get("accept-language")
query_lang = request.query_params.get("lang")
lang = detect_language(accept_language, query_lang)
return templates.TemplateResponse(
request,
"dashboard/404.html",
{
"lang": lang,
"t": get_translations(lang),
"session": session,
"is_admin": admin,
"display_info": display_info,
"current_page": "services",
},
status_code=404,
)
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)
return templates.TemplateResponse(
request,
"dashboard/service.html",
{
"lang": lang,
"t": t,
"session": session,
"is_admin": admin,
"display_info": display_info,
"current_page": "services",
"service": data,
},
)
def register_dashboard_routes(mcp): def register_dashboard_routes(mcp):
""" """
Register dashboard routes with the MCP server. Register dashboard routes with the MCP server.
@@ -3177,6 +3509,10 @@ def register_dashboard_routes(mcp):
mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit) mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit)
mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page) mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page)
# Service pages (F.3)
mcp.custom_route("/dashboard/services", methods=["GET"])(dashboard_services_list)
mcp.custom_route("/dashboard/services/{plugin_type}", methods=["GET"])(dashboard_service_page)
# Site Management API (E.3) # Site Management API (E.3)
mcp.custom_route("/api/sites", methods=["GET"])(api_list_sites) mcp.custom_route("/api/sites", methods=["GET"])(api_list_sites)
mcp.custom_route("/api/sites", methods=["POST"])(api_create_site) mcp.custom_route("/api/sites", methods=["POST"])(api_create_site)

View File

@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data" _DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
# Schema version — increment when adding migrations # Schema version — increment when adding migrations
SCHEMA_VERSION = 3 SCHEMA_VERSION = 5
# Initial schema DDL # Initial schema DDL
_SCHEMA_SQL = """\ _SCHEMA_SQL = """\
@@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS sites (
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
status_msg TEXT, status_msg TEXT,
last_health TEXT, last_health TEXT,
last_tested_at TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
UNIQUE(user_id, alias) UNIQUE(user_id, alias)
); );
@@ -108,6 +109,17 @@ _MIGRATIONS: dict[int, str] = {
"ALTER TABLE user_api_keys ADD COLUMN key_prefix TEXT;\n" "ALTER TABLE user_api_keys ADD COLUMN key_prefix TEXT;\n"
"CREATE INDEX IF NOT EXISTS idx_user_api_keys_prefix ON user_api_keys(key_prefix);\n" "CREATE INDEX IF NOT EXISTS idx_user_api_keys_prefix ON user_api_keys(key_prefix);\n"
), ),
4: "ALTER TABLE sites ADD COLUMN last_tested_at TEXT;\n",
5: (
"ALTER TABLE sites ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0;\n"
"CREATE UNIQUE INDEX IF NOT EXISTS idx_system_site_alias "
"ON sites(alias) WHERE is_system = 1;\n"
"CREATE TABLE IF NOT EXISTS settings (\n"
" key TEXT PRIMARY KEY,\n"
" value TEXT NOT NULL,\n"
" updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n"
");\n"
),
} }
@@ -519,15 +531,17 @@ class Database:
only updates if the site belongs to this user. When None, only updates if the site belongs to this user. When None,
performs system-level update (e.g., health checks). performs system-level update (e.g., health checks).
""" """
now = _utc_now()
if user_id is not None: if user_id is not None:
await self.execute( await self.execute(
"UPDATE sites SET status = ?, status_msg = ? WHERE id = ? AND user_id = ?", "UPDATE sites SET status = ?, status_msg = ?, last_tested_at = ?"
(status, status_msg, site_id, user_id), " WHERE id = ? AND user_id = ?",
(status, status_msg, now, site_id, user_id),
) )
else: else:
await self.execute( await self.execute(
"UPDATE sites SET status = ?, status_msg = ? WHERE id = ?", "UPDATE sites SET status = ?, status_msg = ?, last_tested_at = ?" " WHERE id = ?",
(status, status_msg, site_id), (status, status_msg, now, site_id),
) )
async def update_site_credentials( async def update_site_credentials(
@@ -584,6 +598,33 @@ class Database:
) )
return row["cnt"] if row else 0 return row["cnt"] if row else 0
# ------------------------------------------------------------------
# Settings CRUD (Phase 4C.3)
# ------------------------------------------------------------------
async def get_setting(self, key: str) -> str | None:
"""Get a setting value by key."""
row = await self.fetchone("SELECT value FROM settings WHERE key = ?", (key,))
return row["value"] if row else None
async def set_setting(self, key: str, value: str) -> None:
"""Set a setting value (upsert)."""
await self.execute(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)"
" ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = ?",
(key, value, _utc_now(), value, _utc_now()),
)
async def delete_setting(self, key: str) -> bool:
"""Delete a setting. Returns True if deleted."""
cursor = await self.execute("DELETE FROM settings WHERE key = ?", (key,))
return cursor.rowcount > 0
async def get_all_settings(self) -> dict[str, str]:
"""Get all settings as a dict."""
rows = await self.fetchall("SELECT key, value FROM settings")
return {r["key"]: r["value"] for r in rows}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# User API Key CRUD # User API Key CRUD
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -98,7 +98,7 @@ class ClientRegistry:
client_name=client_name, client_name=client_name,
redirect_uris=redirect_uris, redirect_uris=redirect_uris,
grant_types=grant_types or ["authorization_code", "refresh_token"], grant_types=grant_types or ["authorization_code", "refresh_token"],
allowed_scopes=allowed_scopes or ["read", "write"], allowed_scopes=allowed_scopes or ["read", "write", "admin"],
metadata=metadata or {}, metadata=metadata or {},
owner_user_id=owner_user_id, owner_user_id=owner_user_id,
) )

View File

@@ -18,9 +18,9 @@ class OAuthClient(BaseModel):
default=["authorization_code", "refresh_token"], description="Allowed grant types" default=["authorization_code", "refresh_token"], description="Allowed grant types"
) )
response_types: list[str] = Field(default=["code"], description="Allowed response types") response_types: list[str] = Field(default=["code"], description="Allowed response types")
scope: str = Field(default="read", description="Default scope for this client") scope: str = Field(default="read write admin", description="Default scope for this client")
allowed_scopes: list[str] = Field( allowed_scopes: list[str] = Field(
default=["read", "write"], description="All scopes this client can request" default=["read", "write", "admin"], description="All scopes this client can request"
) )
token_endpoint_auth_method: str = Field( token_endpoint_auth_method: str = Field(
default="client_secret_post", description="Token endpoint authentication method" default="client_secret_post", description="Token endpoint authentication method"

View File

@@ -115,7 +115,7 @@ class OAuthServer:
) )
# Validate scope # Validate scope
requested_scopes = scope.split() if scope else ["read"] requested_scopes = scope.split() if scope else ["read", "write", "admin"]
for s in requested_scopes: for s in requested_scopes:
if s not in client.allowed_scopes: if s not in client.allowed_scopes:
raise OAuthError( raise OAuthError(

59
core/plugin_visibility.py Normal file
View File

@@ -0,0 +1,59 @@
"""Plugin visibility control for public vs admin users (Track F.1).
Controls which plugins are visible to public (OAuth) users vs admin users.
Admin users (MASTER_API_KEY) always see all plugins. Public users only see
plugins listed in the ENABLED_PLUGINS setting (DB > ENV > default).
Usage:
from core.plugin_visibility import get_public_plugin_types, is_plugin_public
public_types = get_public_plugin_types() # {"wordpress", "woocommerce", "supabase"}
if is_plugin_public("gitea"): # False
...
"""
import os
# Default plugins available to public (OAuth) users
DEFAULT_PUBLIC_PLUGINS = {"wordpress", "woocommerce", "supabase"}
def _parse_plugins(val: str) -> set[str]:
"""Parse comma-separated plugin string into set."""
return {p.strip().lower() for p in val.split(",") if p.strip()}
def get_public_plugin_types() -> set[str]:
"""Return the set of plugin types visible to public users.
Checks DB settings first (sync-safe), then env var, then defaults.
Returns:
Set of lowercase plugin type strings.
"""
# Try DB setting (sync access via cached value)
try:
from core.settings import _cached_plugins
if _cached_plugins is not None:
return set(_cached_plugins)
except (ImportError, AttributeError):
pass
# Fallback to env var
env_val = os.getenv("ENABLED_PLUGINS", "").strip()
if not env_val:
return set(DEFAULT_PUBLIC_PLUGINS)
return _parse_plugins(env_val)
def is_plugin_public(plugin_type: str) -> bool:
"""Check if a plugin type is enabled for public users.
Args:
plugin_type: Plugin type string (e.g., "wordpress").
Returns:
True if the plugin is in the public set.
"""
return plugin_type.lower() in get_public_plugin_types()

162
core/settings.py Normal file
View File

@@ -0,0 +1,162 @@
"""Unified settings access with DB > ENV > Default priority (Phase 4C.3).
Usage:
from core.settings import get_setting
enabled = await get_setting("ENABLED_PLUGINS", "wordpress,woocommerce,supabase")
max_sites = int(await get_setting("MAX_SITES_PER_USER", "10"))
"""
import logging
import os
logger = logging.getLogger(__name__)
# Cached plugin set for sync access (updated when settings change)
_cached_plugins: set[str] | None = None
# Default values for all managed settings
SETTING_DEFAULTS: dict[str, str] = {
"ENABLED_PLUGINS": "wordpress,woocommerce,supabase",
"MAX_SITES_PER_USER": "10",
"USER_RATE_LIMIT_PER_MIN": "30",
"USER_RATE_LIMIT_PER_HR": "500",
}
# Human-readable labels for the settings UI
SETTING_LABELS: dict[str, dict[str, str]] = {
"ENABLED_PLUGINS": {
"label": "Enabled Plugins",
"label_fa": "پلاگین‌های فعال",
"hint": "Comma-separated plugin types visible to public users",
"hint_fa": "انواع پلاگین قابل مشاهده برای کاربران عمومی (با کاما جدا شوند)",
},
"MAX_SITES_PER_USER": {
"label": "Max Sites per User",
"label_fa": "حداکثر سایت هر کاربر",
"hint": "Maximum number of sites each user can create",
"hint_fa": "حداکثر تعداد سایت‌هایی که هر کاربر می‌تواند بسازد",
},
"USER_RATE_LIMIT_PER_MIN": {
"label": "User Rate Limit (per minute)",
"label_fa": "محدودیت نرخ کاربر (در دقیقه)",
"hint": "Maximum MCP requests per user per minute",
"hint_fa": "حداکثر درخواست MCP هر کاربر در دقیقه",
},
"USER_RATE_LIMIT_PER_HR": {
"label": "User Rate Limit (per hour)",
"label_fa": "محدودیت نرخ کاربر (در ساعت)",
"hint": "Maximum MCP requests per user per hour",
"hint_fa": "حداکثر درخواست MCP هر کاربر در ساعت",
},
}
async def get_setting(key: str, default: str | None = None) -> str | None:
"""Get a setting value with priority: Database > Environment > Default.
Args:
key: Setting key (e.g., "ENABLED_PLUGINS").
default: Fallback if not found anywhere. If None, uses SETTING_DEFAULTS.
Returns:
Setting value string, or None if not found anywhere.
"""
# 1. Try database
try:
from core.database import get_database
db = get_database()
db_val = await db.get_setting(key)
if db_val is not None:
return db_val
except Exception:
pass # DB not initialized yet (startup) — fall through
# 2. Try environment variable
env_val = os.environ.get(key)
if env_val is not None:
return env_val
# 3. Use provided default or SETTING_DEFAULTS
if default is not None:
return default
return SETTING_DEFAULTS.get(key)
async def refresh_plugin_cache() -> None:
"""Refresh the cached plugin set from DB/ENV/default."""
global _cached_plugins
val = await get_setting("ENABLED_PLUGINS")
if val:
_cached_plugins = {p.strip().lower() for p in val.split(",") if p.strip()}
else:
_cached_plugins = None
async def save_setting(key: str, value: str) -> None:
"""Save a setting to database and refresh caches."""
from core.database import get_database
db = get_database()
await db.set_setting(key, value)
if key == "ENABLED_PLUGINS":
await refresh_plugin_cache()
async def delete_setting_value(key: str) -> bool:
"""Delete a setting from database (revert to ENV/default)."""
from core.database import get_database
db = get_database()
deleted = await db.delete_setting(key)
if key == "ENABLED_PLUGINS":
await refresh_plugin_cache()
return deleted
async def get_all_managed_settings() -> list[dict[str, str]]:
"""Get all managed settings with their current values and sources.
Returns:
List of dicts with: key, value, source ("database"/"environment"/"default"),
label, hint.
"""
result = []
try:
from core.database import get_database
db = get_database()
db_settings = await db.get_all_settings()
except Exception:
db_settings = {}
for key, default_val in SETTING_DEFAULTS.items():
meta = SETTING_LABELS.get(key, {})
db_val = db_settings.get(key)
env_val = os.environ.get(key)
if db_val is not None:
value, source = db_val, "database"
elif env_val is not None:
value, source = env_val, "environment"
else:
value, source = default_val, "default"
result.append(
{
"key": key,
"value": value,
"source": source,
"default": default_val,
"label": meta.get("label", key),
"label_fa": meta.get("label_fa", key),
"hint": meta.get("hint", ""),
"hint_fa": meta.get("hint_fa", ""),
}
)
return result

View File

@@ -120,9 +120,11 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "Anon Key (Optional)", "label": "Anon Key (Optional)",
"type": "password", "type": "password",
"required": False, "required": False,
"advanced": True,
"hint": ( "hint": (
"Supabase Dashboard → Settings → API → anon key. " "Supabase Dashboard → Settings → API → anon key. "
"Optional — if omitted, service_role_key is used for all calls. " "Optional — if omitted, service_role_key is used for all calls. "
"Only useful for testing RLS policies as a regular user."
), ),
}, },
{ {
@@ -130,11 +132,22 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "postgres-meta URL (Optional)", "label": "postgres-meta URL (Optional)",
"type": "text", "type": "text",
"required": False, "required": False,
"advanced": True,
"hint": ( "hint": (
"Only needed when /pg/ is not exposed through Kong (common on Coolify). " "Only needed if your Supabase setup does not expose /pg/ through Kong. "
"Enter the direct postgres-meta container URL, e.g.: " "Most self-hosted installs (including Coolify) work without this. "
"http://supabase-meta-<id>:8080 " "Example: http://supabase-meta:8080 or https://your-meta.example.com"
"Leave blank to use the default Kong /pg/ route." ),
},
{
"name": "meta_auth",
"label": "postgres-meta Auth (Optional)",
"type": "password",
"required": False,
"advanced": True,
"hint": (
"Basic Auth for postgres-meta (format: username:password). "
"Only needed when postgres-meta is exposed via a public URL."
), ),
}, },
], ],
@@ -230,28 +243,31 @@ def get_credential_fields(plugin_type: str) -> list[dict[str, Any]]:
def get_user_credential_fields() -> dict[str, list[dict[str, Any]]]: def get_user_credential_fields() -> dict[str, list[dict[str, Any]]]:
"""Get credential fields for regular (non-admin) users. """Get credential fields for public (non-admin) users.
Excludes plugin types that require admin-level infrastructure Only includes plugins enabled via ENABLED_PLUGINS env var.
(e.g., wordpress_advanced needs Docker access).
Returns: Returns:
Filtered dict of plugin_type -> field definitions. Filtered dict of plugin_type -> field definitions.
""" """
excluded = {"wordpress_advanced"} from core.plugin_visibility import get_public_plugin_types
return {k: v for k, v in PLUGIN_CREDENTIAL_FIELDS.items() if k not in excluded}
public = get_public_plugin_types()
return {k: v for k, v in PLUGIN_CREDENTIAL_FIELDS.items() if k in public}
def get_user_plugin_names() -> dict[str, str]: def get_user_plugin_names() -> dict[str, str]:
"""Get plugin display names for regular (non-admin) users. """Get plugin display names for public (non-admin) users.
Excludes plugins that require admin-level infrastructure. Only includes plugins enabled via ENABLED_PLUGINS env var.
Returns: Returns:
Filtered dict of plugin_type -> display name. Filtered dict of plugin_type -> display name.
""" """
excluded = {"wordpress_advanced"} from core.plugin_visibility import get_public_plugin_types
return {k: v for k, v in PLUGIN_DISPLAY_NAMES.items() if k not in excluded}
public = get_public_plugin_types()
return {k: v for k, v in PLUGIN_DISPLAY_NAMES.items() if k in public}
def validate_credentials(plugin_type: str, credentials: dict[str, str]) -> tuple[bool, list[str]]: def validate_credentials(plugin_type: str, credentials: dict[str, str]) -> tuple[bool, list[str]]:

View File

@@ -173,7 +173,8 @@
{% endif %} {% endif %}
</div> </div>
<!-- Divider --> <!-- Divider + Admin Login Link (hidden when DISABLE_MASTER_KEY_LOGIN=true) -->
{% if not master_login_disabled %}
<div class="my-6 flex items-center"> <div class="my-6 flex items-center">
<div class="flex-1 border-t border-gray-600"></div> <div class="flex-1 border-t border-gray-600"></div>
<span class="px-4 text-sm text-gray-400"> <span class="px-4 text-sm text-gray-400">
@@ -182,7 +183,6 @@
<div class="flex-1 border-t border-gray-600"></div> <div class="flex-1 border-t border-gray-600"></div>
</div> </div>
<!-- Admin Login Link -->
<a href="/dashboard/login{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" <a href="/dashboard/login{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="btn-admin transition-all flex items-center justify-center w-full py-3 px-4 text-gray-300 font-medium rounded-lg text-sm"> class="btn-admin transition-all flex items-center justify-center w-full py-3 px-4 text-gray-300 font-medium rounded-lg text-sm">
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -191,6 +191,7 @@
</svg> </svg>
{% if lang == 'fa' %}ورود مدیر با کلید API{% else %}Admin Login with API Key{% endif %} {% if lang == 'fa' %}ورود مدیر با کلید API{% else %}Admin Login with API Key{% endif %}
</a> </a>
{% endif %}
<!-- Language Toggle --> <!-- Language Toggle -->
<div class="mt-6 text-center"> <div class="mt-6 text-center">

View File

@@ -92,6 +92,7 @@
{# ── Derive RBAC info from session ── #} {# ── Derive RBAC info from session ── #}
{% set _is_admin = is_admin_session(session) %} {% set _is_admin = is_admin_session(session) %}
{% set _is_oauth_admin = _is_admin and session is mapping %}
{% set _display = get_session_display_info(session) %} {% set _display = get_session_display_info(session) %}
<body class="bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200" <body class="bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200"
@@ -134,6 +135,7 @@
('my_sites', t.get('my_sites', 'My Sites'), 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 ('my_sites', t.get('my_sites', 'My Sites'), 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0
01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9', 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
'/dashboard/sites'), '/dashboard/sites'),
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
('connect', t.get('connect', 'Connect'), 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 ('connect', t.get('connect', 'Connect'), 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656
5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', '/dashboard/connect'), 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', '/dashboard/connect'),
] %} ] %}
@@ -172,8 +174,8 @@
</a> </a>
{% endfor %} {% endfor %}
{# Render user nav (non-admin only) #} {# Render user nav (non-admin users + OAuth admins) #}
{% if not _is_admin %} {% if not _is_admin or _is_oauth_admin %}
{% for item_id, label, icon_path, url in user_nav %} {% for item_id, label, icon_path, url in user_nav %}
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" <a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}"> class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
@@ -206,15 +208,28 @@
{% endif %} {% endif %}
</nav> </nav>
<!-- Version footer --> <!-- Support link -->
<div x-show="sidebarOpen" class="px-4 py-2 text-xs text-gray-400 dark:text-gray-600"> <div x-show="sidebarOpen" class="px-4 py-2">
v{{ project_version | default('') }} <a href="https://nowpayments.io/donation/airano" target="_blank" rel="noopener noreferrer"
class="flex items-center text-xs text-gray-400 dark:text-gray-500 hover:text-pink-400 dark:hover:text-pink-400 transition-colors">
<svg class="w-4 h-4 mr-1.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
{% if lang == 'fa' %}حمایت از MCP Hub{% else %}Support MCP Hub{% endif %}
</a>
</div> </div>
<!-- Version footer -->
{% if project_version %}
<div x-show="sidebarOpen" class="px-4 py-2 text-xs text-gray-400 dark:text-gray-600">
v{{ project_version }}
</div>
{% endif %}
<!-- User Section --> <!-- User Section -->
<div class="p-4 border-t border-gray-200 dark:border-gray-700"> <div class="p-4 border-t border-gray-200 dark:border-gray-700">
{# Profile link for OAuth users #} {# Profile link for OAuth users (including OAuth admins) #}
{% if not _is_admin %} {% if not _is_admin or _is_oauth_admin %}
<a href="/dashboard/profile{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" <a href="/dashboard/profile{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded-lg transition-colors mb-1 {% if current_page == 'profile' %}bg-primary-600 text-white{% endif %}"> class="flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded-lg transition-colors mb-1 {% if current_page == 'profile' %}bg-primary-600 text-white{% endif %}">
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor" <svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"

View File

@@ -97,7 +97,7 @@
{% endif %} {% endif %}
</div> </div>
<!-- Config Snippets Section --> <!-- Config Snippets + Connection Guide (unified) -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6"> <div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t.config_snippets }}</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t.config_snippets }}</h3>
@@ -108,7 +108,7 @@
<select id="config-site" onchange="updateConfig()" <select id="config-site" onchange="updateConfig()"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"> class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
{% for site in sites %} {% for site in sites %}
<option value="{{ site.alias }}">{{ site.alias }} ({{ site.plugin_type }})</option> <option value="{{ site.alias }}" data-plugin="{{ site.plugin_type }}">{{ site.alias }} ({{ site.plugin_type }})</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -128,8 +128,8 @@
<button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else 'right-2' }} text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors" id="copy-config-btn">{{ t['copy'] }}</button> <button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else 'right-2' }} text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors" id="copy-config-btn">{{ t['copy'] }}</button>
</div> </div>
<!-- Transport type info --> <!-- Transport type info (hidden for web-based clients via JS) -->
<div class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4"> <div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
<p class="text-sm text-blue-700 dark:text-blue-400"> <p class="text-sm text-blue-700 dark:text-blue-400">
{% if lang == 'fa' %} {% if lang == 'fa' %}
<strong>نکته:</strong> تنظیمات بالا شامل نوع اتصال (<code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code) می‌باشد. از <code>sse</code> استفاده نکنید — باعث خطای <code>400 Bad Request</code> می‌شود. <strong>نکته:</strong> تنظیمات بالا شامل نوع اتصال (<code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code) می‌باشد. از <code>sse</code> استفاده نکنید — باعث خطای <code>400 Bad Request</code> می‌شود.
@@ -139,76 +139,50 @@
</p> </p>
</div> </div>
<!-- WordPress plugin requirement --> <!-- WordPress SEO plugin note (shown/hidden dynamically by JS based on selected site) -->
<div class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4"> <div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
<p class="text-sm text-amber-700 dark:text-amber-400"> <p class="text-sm text-amber-700 dark:text-amber-400">
{% if lang == 'fa' %} {% if lang == 'fa' %}
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه <strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a> <a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
را روی سایت وردپرسی خود نصب کنید. همچنین یک <strong>Application Password</strong> در وردپرس (Users → Profile) ایجاد و هنگام افزودن سایت وارد کنید. را روی سایت وردپرسی خود نصب کنید.
{% else %} {% else %}
<strong>WordPress:</strong> For SEO tools, install the <strong>WordPress:</strong> For SEO tools, install the
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a> <a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
plugin on your WordPress site. Also create an <strong>Application Password</strong> in WordPress (Users → Profile) and enter it when adding your site. plugin on your WordPress site.
{% endif %} {% endif %}
</p> </p>
</div> </div>
<!-- Connection tip (shown only for web-based clients via JS) -->
<div id="connection-tip" style="display:none" class="mt-3 bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4">
<p class="text-sm text-green-700 dark:text-green-400">
{% if lang == 'fa' %}
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال می‌توانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
ساخت <a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
{% else %}
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
Creating an <a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
{% endif %}
</p>
</div>
<!-- API Key hint for desktop/CLI clients (hidden for web clients via JS) -->
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
{% if lang == 'fa' %}
<strong>API Key:</strong> از بخش API Keys بالا بسازید و مقدار آن را در تنظیمات وارد کنید:
{% else %}
<strong>API Key:</strong> Create one above and use it in your config:
{% endif %}
</p>
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
</div>
{% else %} {% else %}
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_sites }}. <a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a></p> <p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_sites }}. <a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a></p>
{% endif %} {% endif %}
</div> </div>
<!-- Claude.ai Connection Guide -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{% if lang == 'fa' %}اتصال به Claude.ai{% else %}Connect to Claude.ai{% endif %}
</h3>
<ol class="space-y-3 text-sm text-gray-700 dark:text-gray-300 {% if lang == 'fa' %}list-decimal list-inside{% else %}list-decimal list-inside{% endif %}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<li>
{% if lang == 'fa' %}
در صفحه <strong>OAuth Clients</strong> یک کلاینت جدید بسازید
{% else %}
Create an OAuth Client on the <strong>OAuth Clients</strong> page
{% endif %}
</li>
<li>
{% if lang == 'fa' %}
در Claude.ai → Settings → Connectors → Add → Custom
{% else %}
Go to Claude.ai → Settings → Connectors → Add → Custom
{% endif %}
</li>
<li>
{% if lang == 'fa' %}
آدرس MCP Endpoint خود را وارد کنید:
{% else %}
Enter your MCP Endpoint URL:
{% endif %}
{% if sites %}
<code class="block mt-1 bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">
{{ public_url }}/u/{{ session.user_id }}/{{ sites[0].alias }}/mcp
</code>
{% else %}
<code class="block mt-1 bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">
{{ public_url }}/u/{{ session.user_id }}/YOUR-ALIAS/mcp
</code>
{% endif %}
</li>
<li>
{% if lang == 'fa' %}Client ID و Client Secret را از OAuth Clients وارد کنید{% else %}Enter Client ID and Client Secret from your OAuth Client{% endif %}
</li>
<li>
{% if lang == 'fa' %}با GitHub یا Google وارد شوید{% else %}Log in with GitHub or Google when prompted{% endif %}
</li>
</ol>
<a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
{% if lang == 'fa' %}مدیریت OAuth Clients{% else %}Manage OAuth Clients{% endif %}
</a>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -254,11 +228,35 @@ async function deleteKey(keyId) {
} }
} }
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
const WP_TYPES = ['wordpress', 'woocommerce'];
async function updateConfig() { async function updateConfig() {
const alias = document.getElementById('config-site')?.value; const siteSelect = document.getElementById('config-site');
const alias = siteSelect?.value;
const client = document.getElementById('config-client')?.value; const client = document.getElementById('config-client')?.value;
if (!alias || !client) return; if (!alias || !client) return;
const isWebClient = WEB_CLIENTS.includes(client);
// Show/hide transport note
const transportNote = document.getElementById('transport-note');
if (transportNote) transportNote.style.display = isWebClient ? 'none' : '';
// Show/hide connection tip (only for web clients)
const connTip = document.getElementById('connection-tip');
if (connTip) connTip.style.display = isWebClient ? '' : 'none';
// Show/hide bearer hint (only for desktop/CLI clients)
const bearerHint = document.getElementById('bearer-hint');
if (bearerHint) bearerHint.style.display = isWebClient ? 'none' : '';
// Show/hide WordPress SEO note based on selected site's plugin type
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 { try {
const resp = await fetch('/api/config/' + alias + '?client=' + client); const resp = await fetch('/api/config/' + alias + '?client=' + client);
const data = await resp.json(); const data = await resp.json();

View File

@@ -12,7 +12,7 @@
{# ══════════════════════════════════════════════════════ #} {# ══════════════════════════════════════════════════════ #}
<!-- Admin Stats Cards --> <!-- Admin Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Projects Card --> <!-- Projects Card -->
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700"> <div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -94,6 +94,42 @@
</svg> </svg>
</a> </a>
</div> </div>
<!-- Users Card -->
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کاربران{% else %}Total Users{% endif %}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.users_count|default(0) }}</p>
</div>
<div class="w-12 h-12 bg-cyan-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
</div>
</div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}کاربران ثبت‌نام شده{% else %}Registered users{% endif %}
</p>
</div>
<!-- User Sites Card -->
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سایت‌های کاربران{% else %}User Sites{% endif %}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.user_sites_count|default(0) }}</p>
</div>
<div class="w-12 h-12 bg-teal-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
</svg>
</div>
</div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}سایت‌های متصل شده{% else %}Connected sites{% endif %}
</p>
</div>
</div> </div>
<!-- Admin Main Content Grid --> <!-- Admin Main Content Grid -->
@@ -252,33 +288,6 @@
</div> </div>
</div> </div>
<!-- Support / Donate -->
<div class="bg-gradient-to-br from-primary-900/40 to-gray-800 rounded-xl border border-primary-700/30">
<div class="p-6 text-center">
<div class="w-10 h-10 bg-primary-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
</div>
<h3 class="text-sm font-semibold text-white mb-1">
{% if lang == 'fa' %}حمایت از پروژه{% else %}Support MCP Hub{% endif %}
</h3>
<p class="text-xs text-gray-400 mb-4">
{% if lang == 'fa' %}با حمایت مالی به توسعه این پروژه کمک کنید{% else %}Help us keep this project alive and growing{% endif %}
</p>
<a
href="https://nowpayments.io/donation/airano"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors"
>
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% if lang == 'fa' %}حمایت با رمزارز{% else %}Donate with Crypto{% endif %}
</a>
</div>
</div>
</div> </div>
</div> </div>
@@ -288,7 +297,7 @@
{# ══════════════════════════════════════════════════════ #} {# ══════════════════════════════════════════════════════ #}
<!-- User Stats Cards --> <!-- User Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- My Sites Card --> <!-- My Sites Card -->
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700"> <div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -349,24 +358,6 @@
</a> </a>
</div> </div>
<!-- Available Tools Card -->
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('available_tools', 'Available Tools') }}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.tools_count|default(0) }}</p>
</div>
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
</div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}ابزارهای MCP قابل استفاده{% else %}MCP tools available{% endif %}
</p>
</div>
</div> </div>
<!-- User Site Status --> <!-- User Site Status -->
@@ -439,33 +430,6 @@
</div> </div>
</div> </div>
<!-- Support / Donate (User Dashboard) -->
<div class="bg-gradient-to-br from-primary-900/40 to-gray-800 rounded-xl border border-primary-700/30">
<div class="p-6 text-center">
<div class="w-10 h-10 bg-primary-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
</div>
<h3 class="text-sm font-semibold text-white mb-1">
{% if lang == 'fa' %}حمایت از پروژه{% else %}Support MCP Hub{% endif %}
</h3>
<p class="text-xs text-gray-400 mb-4">
{% if lang == 'fa' %}با حمایت مالی به توسعه این پروژه کمک کنید{% else %}Help us keep this project alive and growing{% endif %}
</p>
<a
href="https://nowpayments.io/donation/airano"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors"
>
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% if lang == 'fa' %}حمایت با رمزارز{% else %}Donate with Crypto{% endif %}
</a>
</div>
</div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -212,8 +212,8 @@
required required
rows="3" rows="3"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="https://example.com/callback&#10;https://app.example.com/oauth/callback" >https://claude.ai/api/mcp/auth_callback
></textarea> https://claude.com/api/mcp/auth_callback</textarea>
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
{% if lang == 'fa' %}هر آدرس را در یک خط جداگانه وارد کنید{% else %}Enter one URI per line{% endif %} {% if lang == 'fa' %}هر آدرس را در یک خط جداگانه وارد کنید{% else %}Enter one URI per line{% endif %}
</p> </p>
@@ -227,11 +227,11 @@
<span class="text-sm text-gray-700 dark:text-gray-300">read</span> <span class="text-sm text-gray-700 dark:text-gray-300">read</span>
</label> </label>
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input type="checkbox" name="scopes" value="write" class="rounded bg-gray-700 border-gray-600 text-primary-500"> <input type="checkbox" name="scopes" value="write" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
<span class="text-sm text-gray-700 dark:text-gray-300">write</span> <span class="text-sm text-gray-700 dark:text-gray-300">write</span>
</label> </label>
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input type="checkbox" name="scopes" value="admin" class="rounded bg-gray-700 border-gray-600 text-primary-500"> <input type="checkbox" name="scopes" value="admin" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
<span class="text-sm text-gray-700 dark:text-gray-300">admin</span> <span class="text-sm text-gray-700 dark:text-gray-300">admin</span>
</label> </label>
</div> </div>

View File

@@ -0,0 +1,185 @@
{% extends "dashboard/base.html" %}
{% block title %}{{ service.display_name }} - MCP Hub{% endblock %}
{% block page_title %}{{ service.display_name }}{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Back button + Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/dashboard/services{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="text-gray-400 hover:text-gray-300 transition-colors">
<svg class="w-5 h-5{% if lang == 'fa' %} rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ service.display_name }}</h2>
{% if service.is_public %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">
{% if lang == 'fa' %}عمومی{% else %}Public{% endif %}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">
{% if lang == 'fa' %}فقط مدیر{% else %}Admin Only{% endif %}
</span>
{% endif %}
</div>
<a href="/dashboard/sites/add?plugin_type={{ service.plugin_type }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
+ {{ t.get('add_site', 'Add Site') }}
</a>
</div>
<!-- Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Tools Count -->
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}تعداد ابزار{% else %}Available Tools{% endif %}
</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ service.tools_count }}</p>
</div>
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
</div>
</div>
<!-- Read Tools -->
{% set read_tools = service.tools|selectattr('scope', 'equalto', 'read')|list %}
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}ابزار خواندن{% else %}Read Tools{% endif %}
</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ read_tools|length }}</p>
</div>
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</div>
</div>
</div>
<!-- Write Tools -->
{% set write_tools = service.tools|selectattr('scope', 'equalto', 'write')|list %}
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}ابزار نوشتن{% else %}Write Tools{% endif %}
</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ write_tools|length }}</p>
</div>
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Setup Requirements -->
{% if service.credential_fields %}
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}اطلاعات مورد نیاز{% else %}Setup Requirements{% endif %}
</h3>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in service.credential_fields %}
<div class="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="w-8 h-8 bg-purple-500/20 rounded flex items-center justify-center flex-shrink-0 mt-0.5">
{% if field.type == 'password' %}
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
{% endif %}
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ field.label }}</p>
{% if field.hint %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
{% endif %}
{% if field.required %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 mt-1">
{% if lang == 'fa' %}الزامی{% else %}Required{% endif %}
</span>
{% else %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 mt-1">
{% if lang == 'fa' %}اختیاری{% else %}Optional{% endif %}
</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Tools List -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}لیست ابزارها{% else %}Tools{% endif %}
<span class="text-sm font-normal text-gray-400 dark:text-gray-500">({{ service.tools_count }})</span>
</h3>
<div class="relative">
<input type="text" id="tool-search" placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search tools...{% endif %}"
class="w-48 px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:border-primary-500">
</div>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-700" id="tools-list">
{% for tool in service.tools %}
<div class="tool-item p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" data-name="{{ tool.name }}" data-desc="{{ tool.description|lower }}">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<code class="text-sm font-mono text-primary-600 dark:text-primary-400">{{ tool.name }}</code>
{% if tool.scope == 'read' %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">read</span>
{% elif tool.scope == 'write' %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-300">write</span>
{% else %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">admin</span>
{% endif %}
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 truncate">{{ tool.description }}</p>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('tool-search').addEventListener('input', function(e) {
const q = e.target.value.toLowerCase();
document.querySelectorAll('.tool-item').forEach(function(item) {
const name = item.dataset.name || '';
const desc = item.dataset.desc || '';
item.style.display = (name.includes(q) || desc.includes(q)) ? '' : 'none';
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends "dashboard/base.html" %}
{% block title %}{{ t.get('services', 'Services') }} - MCP Hub{% endblock %}
{% block page_title %}{{ t.get('services', 'Services') }}{% endblock %}
{% block content %}
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}سرویس‌های MCP{% else %}MCP Services{% endif %}
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% set plugin_colors = {
'wordpress': 'blue',
'woocommerce': 'purple',
'supabase': 'emerald',
'gitea': 'green',
'n8n': 'orange',
'openpanel': 'cyan',
'appwrite': 'pink',
'directus': 'violet',
'wordpress_advanced': 'indigo',
} %}
{% for svc in services %}
{% set color = plugin_colors.get(svc.plugin_type, 'gray') %}
<a href="/dashboard/services/{{ svc.plugin_type }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="block bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-all group">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white transition-colors">
{{ svc.display_name }}
</h3>
{% if svc.is_public %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">
{% if lang == 'fa' %}عمومی{% else %}Public{% endif %}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">
{% if lang == 'fa' %}فقط مدیر{% else %}Admin Only{% endif %}
</span>
{% endif %}
</div>
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
<span>{{ svc.tools_count }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
<span>&middot;</span>
{% set read_count = svc.tools|selectattr('scope', 'equalto', 'read')|list|length %}
{% set write_count = svc.tools|selectattr('scope', 'equalto', 'write')|list|length %}
<span class="text-green-600 dark:text-green-400">{{ read_count }} read</span>
<span class="text-orange-600 dark:text-orange-400">{{ write_count }} write</span>
</div>
</a>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -93,6 +93,63 @@
</div> </div>
</div> </div>
<!-- Managed Settings (4C.3) -->
{% if managed_settings %}
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{% if lang == 'fa' %}تنظیمات قابل مدیریت{% else %}Managed Settings{% endif %}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
{% if lang == 'fa' %}این تنظیمات از پنل قابل تغییر هستند. اولویت: دیتابیس > متغیر محیطی > پیش‌فرض{% else %}These settings can be changed from the panel. Priority: Database > Environment > Default{% endif %}
</p>
<div class="space-y-4">
{% for s in managed_settings %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4" id="setting-{{ s.key }}">
<div class="flex items-center justify-between mb-2">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}{{ s.label_fa }}{% else %}{{ s.label }}{% endif %}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{% if lang == 'fa' %}{{ s.hint_fa }}{% else %}{{ s.hint }}{% endif %}
</p>
</div>
<span class="px-2 py-0.5 rounded text-xs font-medium
{% if s.source == 'database' %}bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300
{% elif s.source == 'environment' %}bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300
{% else %}bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400{% endif %}"
id="source-{{ s.key }}">
{{ s.source }}
</span>
</div>
<div class="flex items-center gap-2">
<input type="text" value="{{ s.value }}" id="input-{{ s.key }}"
class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<button onclick="saveSetting('{{ s.key }}')"
class="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
id="save-{{ s.key }}">
{% if lang == 'fa' %}ذخیره{% else %}Save{% endif %}
</button>
{% if s.source == 'database' %}
<button onclick="resetSetting('{{ s.key }}')"
class="px-3 py-2 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-300 text-sm rounded-lg transition-colors"
id="reset-{{ s.key }}"
title="{% if lang == 'fa' %}بازگشت به پیش‌فرض{% else %}Reset to default{% endif %}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
{% endif %}
</div>
<p class="text-xs text-gray-400 mt-1" id="default-{{ s.key }}">
{% if lang == 'fa' %}پیش‌فرض: {{ s.default }}{% else %}Default: {{ s.default }}{% endif %}
</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Registered Plugins --> <!-- Registered Plugins -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6"> <div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@@ -208,3 +265,59 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
async function saveSetting(key) {
const input = document.getElementById('input-' + key);
const btn = document.getElementById('save-' + key);
const value = input.value.trim();
if (!value) return;
btn.textContent = '...';
btn.disabled = true;
try {
const resp = await fetch('/api/dashboard/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value, action: 'save' }),
});
const data = await resp.json();
if (resp.ok) {
btn.textContent = '✓';
btn.className = btn.className.replace('bg-blue-600', 'bg-green-600').replace('hover:bg-blue-700', 'hover:bg-green-700');
const source = document.getElementById('source-' + key);
if (source) { source.textContent = 'database'; source.className = 'px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'; }
setTimeout(() => location.reload(), 1000);
} else {
alert(data.error || 'Failed to save');
btn.textContent = '{% if lang == "fa" %}ذخیره{% else %}Save{% endif %}';
}
} catch (e) {
alert('Error: ' + e.message);
btn.textContent = '{% if lang == "fa" %}ذخیره{% else %}Save{% endif %}';
} finally {
btn.disabled = false;
}
}
async function resetSetting(key) {
if (!confirm('Reset this setting to default?')) return;
try {
const resp = await fetch('/api/dashboard/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, action: 'reset' }),
});
if (resp.ok) {
location.reload();
} else {
const data = await resp.json();
alert(data.error || 'Failed to reset');
}
} catch (e) {
alert('Error: ' + e.message);
}
}
</script>
{% endblock %}

View File

@@ -27,7 +27,7 @@
onchange="updateFields()"> onchange="updateFields()">
<option value="">{{ t.select_plugin }}</option> <option value="">{{ t.select_plugin }}</option>
{% for ptype, pname in plugin_names.items() %} {% for ptype, pname in plugin_names.items() %}
<option value="{{ ptype }}">{{ pname }}</option> <option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -82,11 +82,15 @@
return; return;
} }
const mainFields = pluginFields[ptype].filter(f => !f.advanced);
const advFields = pluginFields[ptype].filter(f => f.advanced);
let html = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>'; let html = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>';
pluginFields[ptype].forEach(field => {
function renderField(field) {
const req = field.required ? 'required' : ''; const req = field.required ? 'required' : '';
const hintHtml = field.hint ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">${field.hint}</p>` : ''; const hintHtml = field.hint ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">${field.hint}</p>` : '';
html += ` return `
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">${field.label}${field.required ? ' *' : ''}</label> <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">${field.label}${field.required ? ' *' : ''}</label>
<input type="${field.type}" name="cred_${field.name}" ${req} <input type="${field.type}" name="cred_${field.name}" ${req}
@@ -94,10 +98,29 @@
placeholder="${field.label}"> placeholder="${field.label}">
${hintHtml} ${hintHtml}
</div>`; </div>`;
}); }
mainFields.forEach(field => { html += renderField(field); });
if (advFields.length > 0) {
html += `
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
Advanced Settings
</summary>
<div class="px-4 pb-4 pt-2">`;
advFields.forEach(field => { html += renderField(field); });
html += `</div></details>`;
}
container.innerHTML = html; container.innerHTML = html;
} }
// Auto-fill credential fields if plugin_type is pre-selected (e.g., from service page)
if (document.getElementById('plugin_type').value) {
updateFields();
}
document.getElementById('add-site-form').addEventListener('submit', async (e) => { document.getElementById('add-site-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const btn = document.getElementById('submit-btn'); const btn = document.getElementById('submit-btn');

View File

@@ -53,18 +53,48 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>
{% set fields = plugin_fields.get(site.plugin_type, []) %} {% set fields = plugin_fields.get(site.plugin_type, []) %}
{% for field in fields %} {% for field in fields %}
{% if not field.get('advanced', False) %}
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"> <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{{ field.label }}{% if field.required %} *{% endif %} {{ field.label }}{% if field.required %} *{% endif %}
</label> </label>
<input type="{{ field.type }}" name="cred_{{ field.name }}" <input type="{{ field.type }}" name="cred_{{ field.name }}"
{% if field.type == 'password' %}placeholder="{{ t.keep_existing }}"{% endif %} placeholder="{% if field.required %}{{ t.keep_existing }}{% else %}{{ t.leave_blank_to_clear }}{% endif %}"
data-required="{{ field.required | lower }}"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"> class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
{% if field.hint %} {% if field.hint %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
{% set has_advanced = fields | map(attribute='advanced') | select | list | length > 0 if fields else false %}
{% if has_advanced %}
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
Advanced Settings
</summary>
<div class="px-4 pb-4 pt-2 space-y-4">
{% for field in fields %}
{% if field.get('advanced', False) %}
<div class="mb-4">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{{ field.label }}
</label>
<input type="{{ field.type }}" name="cred_{{ field.name }}"
placeholder="{{ t.leave_blank_to_clear }}"
data-required="false"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
{% if field.hint %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</details>
{% endif %}
</div> </div>
<!-- Submit --> <!-- Submit -->
@@ -99,12 +129,20 @@
const url = document.getElementById('url').value; const url = document.getElementById('url').value;
// Collect credentials — only include non-empty values // Collect credentials — non-empty values always sent,
// optional fields sent as empty string to allow clearing
const creds = {}; const creds = {};
if (pluginFields[pluginType]) { if (pluginFields[pluginType]) {
pluginFields[pluginType].forEach(field => { pluginFields[pluginType].forEach(field => {
const input = document.querySelector(`[name="cred_${field.name}"]`); const input = document.querySelector(`[name="cred_${field.name}"]`);
if (input) creds[field.name] = input.value; if (!input) return;
const val = input.value.trim();
if (val) {
creds[field.name] = val;
} else if (input.dataset.required === 'false') {
// Send empty string for optional fields so server can clear them
creds[field.name] = '';
}
}); });
} }

View File

@@ -52,19 +52,26 @@
</span> </span>
</td> </td>
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm max-w-xs truncate">{{ site.url }}</td> <td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm max-w-xs truncate">{{ site.url }}</td>
<td class="px-6 py-4"> <td class="px-6 py-4" id="status-cell-{{ site.id }}">
{% if site.status == 'active' %} {% if site.status == 'active' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">{{ t.active }}</span> <span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">{{ t.active }}</span>
{% elif site.status == 'error' %} {% elif site.status == 'error' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">{{ t.error }}</span> <span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">{{ t.error }}</span>
{% elif site.status == 'pending' %} {% elif site.status == 'pending' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-300">Pending</span> <span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-300">Pending</span>
{% else %} {% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">{{ site.status }}</span> <span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">{{ site.status }}</span>
{% endif %} {% endif %}
{% if site.status_msg %} {% if site.status_msg %}
<p class="text-xs text-gray-500 mt-1">{{ site.status_msg[:60] }}</p> <p class="text-xs text-gray-500 mt-1 status-msg">{{ site.status_msg[:60] }}</p>
{% endif %} {% endif %}
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1 last-tested-time">
{% if site.last_tested_at %}
{{ t.last_tested }}: {{ site.last_tested_at[:16] }}
{% else %}
{{ t.never_tested }}
{% endif %}
</p>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -107,30 +114,69 @@
{% block scripts %} {% block scripts %}
<script> <script>
function updateStatusBadge(siteId, status) {
const cell = document.getElementById('status-cell-' + siteId);
if (!cell) return;
const badge = cell.querySelector('.status-badge');
if (badge) {
const base = 'status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
if (status === 'active') {
badge.className = base + ' bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300';
badge.textContent = '{{ t.active }}';
} else {
badge.className = base + ' bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300';
badge.textContent = '{{ t.error }}';
}
}
const msgEl = cell.querySelector('.status-msg');
if (msgEl) msgEl.remove();
const timeEl = cell.querySelector('.last-tested-time');
if (timeEl) {
timeEl.textContent = '{{ t.last_tested }}: {{ t.just_now }}';
}
}
async function testSite(siteId) { async function testSite(siteId) {
const btn = document.getElementById('test-btn-' + siteId); const btn = document.getElementById('test-btn-' + siteId);
btn.textContent = '{{ t.testing }}'; btn.textContent = '{{ t.testing }}';
btn.disabled = true; btn.disabled = true;
try { try {
const resp = await fetch('/api/sites/' + siteId + '/test', { method: 'POST' }); const resp = await fetch('/api/sites/' + siteId + '/test', { method: 'POST' });
if (!resp.ok) {
btn.textContent = '{{ t.connection_failed }}';
btn.className = 'text-sm text-red-600 dark:text-red-400';
updateStatusBadge(siteId, 'error');
return;
}
const ct = resp.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
btn.textContent = '{{ t.connection_failed }}';
btn.className = 'text-sm text-red-600 dark:text-red-400';
updateStatusBadge(siteId, 'error');
return;
}
const data = await resp.json(); const data = await resp.json();
if (data.ok) { if (data.ok) {
btn.textContent = '{{ t.connection_ok }}'; btn.textContent = '{{ t.connection_ok }}';
btn.className = 'text-sm text-green-600 dark:text-green-400'; btn.className = 'text-sm text-green-600 dark:text-green-400';
updateStatusBadge(siteId, data.status || 'active');
} else { } else {
btn.textContent = data.message || '{{ t.connection_failed }}'; btn.textContent = '{{ t.connection_failed }}';
btn.className = 'text-sm text-red-600 dark:text-red-400'; btn.className = 'text-sm text-red-600 dark:text-red-400';
updateStatusBadge(siteId, data.status || 'error');
} }
} catch (e) { } catch (e) {
btn.textContent = '{{ t.error }}'; btn.textContent = '{{ t.connection_failed }}';
btn.className = 'text-sm text-red-600 dark:text-red-400'; btn.className = 'text-sm text-red-600 dark:text-red-400';
} updateStatusBadge(siteId, 'error');
} finally {
setTimeout(() => { setTimeout(() => {
btn.textContent = '{{ t.test_connection }}'; btn.textContent = '{{ t.test_connection }}';
btn.className = 'text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300'; btn.className = 'text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300';
btn.disabled = false; btn.disabled = false;
}, 3000); }, 3000);
} }
}
async function deleteSite(siteId) { async function deleteSite(siteId) {
if (!confirm('Are you sure you want to delete this site?')) return; if (!confirm('Are you sure you want to delete this site?')) return;

View File

@@ -110,8 +110,8 @@
{% if lang == 'fa' %}Redirect URIs (هر URI در یک خط){% else %}Redirect URIs (one per line){% endif %} {% if lang == 'fa' %}Redirect URIs (هر URI در یک خط){% else %}Redirect URIs (one per line){% endif %}
</label> </label>
<textarea id="redirect-uris" rows="3" <textarea id="redirect-uris" rows="3"
placeholder="https://claude.ai/oauth/callback" class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 font-mono text-sm">https://claude.ai/api/mcp/auth_callback
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 font-mono text-sm"></textarea> https://claude.com/api/mcp/auth_callback</textarea>
</div> </div>
</div> </div>
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3"> <div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">

View File

@@ -306,6 +306,17 @@ async def user_mcp_handler(request: Request) -> Response:
status_code=403, status_code=403,
) )
# --- Plugin visibility check ---
from core.plugin_visibility import is_plugin_public
if not is_plugin_public(site["plugin_type"]):
return JSONResponse(
_jsonrpc_error(
None, -32600, f"Plugin '{site['plugin_type']}' is not currently available"
),
status_code=403,
)
# --- Parse JSON-RPC body --- # --- Parse JSON-RPC body ---
try: try:
body = await request.json() body = await request.json()

View File

@@ -56,7 +56,7 @@ class UserKeyManager:
self, self,
user_id: str, user_id: str,
name: str, name: str,
scopes: str = "read write", scopes: str = "read write admin",
expires_in_days: int | None = None, expires_in_days: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create a new API key for a user. """Create a new API key for a user.
@@ -64,7 +64,7 @@ class UserKeyManager:
Args: Args:
user_id: Owner's UUID. user_id: Owner's UUID.
name: Human label (e.g. "Claude Desktop"). name: Human label (e.g. "Claude Desktop").
scopes: Ignored — always "read write" (full access). scopes: Access scopes (default: "read write admin" for full access).
expires_in_days: Optional expiry in days from now. None = never. expires_in_days: Optional expiry in days from now. None = never.
Returns: Returns:
@@ -73,9 +73,6 @@ class UserKeyManager:
""" """
from core.database import get_database from core.database import get_database
# All user API keys get full access — scopes param is ignored
scopes = "read write"
raw_key = KEY_PREFIX_TAG + secrets.token_urlsafe(KEY_RANDOM_BYTES) raw_key = KEY_PREFIX_TAG + secrets.token_urlsafe(KEY_RANDOM_BYTES)
key_prefix = raw_key[len(KEY_PREFIX_TAG) : len(KEY_PREFIX_TAG) + KEY_PREFIX_LEN] key_prefix = raw_key[len(KEY_PREFIX_TAG) : len(KEY_PREFIX_TAG) + KEY_PREFIX_LEN]
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode() key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()

View File

@@ -38,6 +38,10 @@ services:
- PUBLIC_URL=${PUBLIC_URL:-} - PUBLIC_URL=${PUBLIC_URL:-}
- SESSION_EXPIRY_HOURS=${SESSION_EXPIRY_HOURS:-168} - SESSION_EXPIRY_HOURS=${SESSION_EXPIRY_HOURS:-168}
# === ADMIN SYSTEM (F.4) ===
- ADMIN_EMAILS=${ADMIN_EMAILS:-}
- DISABLE_MASTER_KEY_LOGIN=${DISABLE_MASTER_KEY_LOGIN:-false}
# === LOGGING === # === LOGGING ===
- LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_LEVEL=${LOG_LEVEL:-INFO}

View File

@@ -46,7 +46,7 @@ For each WordPress site you want to manage:
The fastest way to try MCP Hub — no installation needed: The fastest way to try MCP Hub — no installation needed:
1. Visit **[mcp.example.com](https://mcp.example.com)** 1. Visit **[mcp.palebluedot.live](https://mcp.palebluedot.live)**
2. Log in with **GitHub** or **Google** 2. Log in with **GitHub** or **Google**
3. Add your sites via the dashboard (**My Sites → Add Service**) 3. Add your sites via the dashboard (**My Sites → Add Service**)
4. Go to **Connect** page to generate your AI client config 4. Go to **Connect** page to generate your AI client config
@@ -54,7 +54,7 @@ The fastest way to try MCP Hub — no installation needed:
Your personal MCP endpoint: Your personal MCP endpoint:
``` ```
https://mcp.example.com/u/{your-user-id}/{alias}/mcp https://mcp.palebluedot.live/u/{your-user-id}/{alias}/mcp
``` ```
**WordPress users:** Install the [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-seo-bridge/) plugin for SEO tools, and create an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/) (Users → Profile) for authentication. **WordPress users:** Install the [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-seo-bridge/) plugin for SEO tools, and create an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/) (Users → Profile) for authentication.

View File

@@ -75,12 +75,11 @@ SUPABASE_SITE1_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SUPABASE_SITE1_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... SUPABASE_SITE1_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SUPABASE_SITE1_ALIAS=mysupabase SUPABASE_SITE1_ALIAS=mysupabase
# Optional: Direct database access for postgres-meta # Optional: Direct postgres-meta URL (when /pg/ is not exposed via Kong)
SUPABASE_SITE1_DB_HOST=db.supabase.example.com # SUPABASE_SITE1_META_URL=http://supabase-meta:8080
SUPABASE_SITE1_DB_PORT=5432 #
SUPABASE_SITE1_DB_NAME=postgres # Optional: Basic Auth for postgres-meta (when exposed via public URL)
SUPABASE_SITE1_DB_USER=postgres # SUPABASE_SITE1_META_AUTH=admin:secretpassword
SUPABASE_SITE1_DB_PASSWORD=your-db-password
# Multiple Instances # Multiple Instances
SUPABASE_SITE2_URL=https://supabase-staging.example.com SUPABASE_SITE2_URL=https://supabase-staging.example.com
@@ -704,6 +703,40 @@ EndpointType.SUPABASE: EndpointConfig(
--- ---
## Security: postgres-meta
**postgres-meta has NO built-in authentication.** It is designed as an internal service
behind Kong API Gateway. When accessed through Kong's `/pg/` route, Kong validates JWT tokens.
### Risk: Public Exposure
If postgres-meta is exposed via a public URL (e.g., on Coolify with a public domain),
**anyone with the URL can execute SQL as `supabase_admin`**.
### Deployment Scenarios
| Deployment | meta_url needed? | meta_auth needed? | Notes |
|-----------|:---:|:---:|-------|
| supabase.com cloud | N/A | N/A | postgres-meta tools disabled |
| Docker Compose (standard) | No | No | Kong handles JWT via `/pg/` |
| Docker Compose (no /pg/) | Yes (internal URL) | No | Internal network is secure |
| Coolify (internal URL) | Yes | No | Internal Docker network |
| Coolify (public domain) | Yes | **Yes** | Exposed without auth |
| MCP Hub hosted users | Yes (public URL) | **Yes** | Must use public URL |
### Recommendations
1. **Prefer internal Docker network URLs** (`http://supabase-meta:8080`) when possible
2. **If public URL required**: Configure Basic Auth via reverse proxy and set `META_AUTH`
3. **Never expose postgres-meta publicly without authentication**
### MCP Hub Support
MCP Hub supports `META_AUTH` (format: `username:password`) for Basic Auth with postgres-meta.
When configured, requests to postgres-meta use `Authorization: Basic ...` instead of JWT.
---
## References ## References
- [Supabase Self-Hosting Docker](https://supabase.com/docs/guides/self-hosting/docker) - [Supabase Self-Hosting Docker](https://supabase.com/docs/guides/self-hosting/docker)

View File

@@ -27,6 +27,27 @@ ENCRYPTION_KEY=
# Generate with: python -c "import secrets; print(secrets.token_hex(32))" # Generate with: python -c "import secrets; print(secrets.token_hex(32))"
DASHBOARD_SESSION_SECRET= DASHBOARD_SESSION_SECRET=
# ============================================
# PLUGIN VISIBILITY (Track F.1)
# ============================================
# Comma-separated list of plugins visible to public (OAuth) users.
# Admin users always see all plugins regardless of this setting.
# Default (if not set): wordpress,woocommerce,supabase
# ENABLED_PLUGINS=wordpress,woocommerce,supabase
# ============================================
# ADMIN SYSTEM (Track F.4)
# ============================================
# Comma-separated list of emails that get admin role on OAuth login.
# These users see the full admin dashboard (projects, API keys, health, etc.)
# If not set, OAuth users only get the standard user dashboard.
# ADMIN_EMAILS=admin@example.com,boss@company.com
# Disable Master Key login to the web dashboard (default: false).
# When true, only OAuth users (with admin email) can access admin dashboard.
# Master Key still works for MCP endpoints — only dashboard login is affected.
# DISABLE_MASTER_KEY_LOGIN=false
# ============================================ # ============================================
# WORDPRESS SITES # WORDPRESS SITES
# ============================================ # ============================================
@@ -118,6 +139,10 @@ WORDPRESS_SITE1_ALIAS=mysite
# #
# Optional: direct postgres-meta URL for self-hosted when /pg/ is not via Kong: # Optional: direct postgres-meta URL for self-hosted when /pg/ is not via Kong:
# SUPABASE_PROJECT1_META_URL=http://localhost:5555 # SUPABASE_PROJECT1_META_URL=http://localhost:5555
#
# Optional: Basic Auth for postgres-meta when exposed via a public URL.
# Format: username:password — not needed for internal Docker networks or Kong.
# SUPABASE_PROJECT1_META_AUTH=admin:secretpassword
# --- OpenPanel --- # --- OpenPanel ---
# OPENPANEL_INSTANCE1_URL=https://openpanel.example.com # OPENPANEL_INSTANCE1_URL=https://openpanel.example.com

View File

@@ -33,6 +33,7 @@ class SupabaseClient:
anon_key: str, anon_key: str,
service_role_key: str, service_role_key: str,
meta_url: str | None = None, meta_url: str | None = None,
meta_auth: str | None = None,
): ):
""" """
Initialize Supabase API client. Initialize Supabase API client.
@@ -45,12 +46,16 @@ class SupabaseClient:
meta_url: Optional direct postgres-meta URL (e.g. http://localhost:5555). meta_url: Optional direct postgres-meta URL (e.g. http://localhost:5555).
When provided, postgres-meta calls hit this URL directly instead of When provided, postgres-meta calls hit this URL directly instead of
the Kong /pg/ route. Useful when /pg/ is not exposed through Kong. the Kong /pg/ route. Useful when /pg/ is not exposed through Kong.
meta_auth: Optional Basic Auth credentials for postgres-meta (username:password).
When provided, requests to meta_base_url use Basic Auth instead of JWT.
Recommended when postgres-meta is exposed via a public URL.
""" """
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.anon_key = anon_key self.anon_key = anon_key
self.service_role_key = service_role_key self.service_role_key = service_role_key
# postgres-meta base: custom URL or Kong /pg/ prefix # postgres-meta base: custom URL or Kong /pg/ prefix
self.meta_base_url = (meta_url or f"{self.base_url}/pg").rstrip("/") self.meta_base_url = (meta_url or f"{self.base_url}/pg").rstrip("/")
self.meta_auth = meta_auth
# Initialize logger # Initialize logger
self.logger = logging.getLogger(f"SupabaseClient.{base_url}") self.logger = logging.getLogger(f"SupabaseClient.{base_url}")
@@ -83,6 +88,23 @@ class SupabaseClient:
return headers return headers
def _get_meta_headers(self, additional_headers: dict | None = None) -> dict[str, str]:
"""Get headers for postgres-meta requests (Basic Auth if configured, else JWT)."""
headers: dict[str, str] = {
"Content-Type": "application/json",
"Accept": "application/json",
}
if self.meta_auth:
encoded = base64.b64encode(self.meta_auth.encode()).decode()
headers["Authorization"] = f"Basic {encoded}"
else:
key = self.service_role_key
headers["apikey"] = key
headers["Authorization"] = f"Bearer {key}"
if additional_headers:
headers.update(additional_headers)
return headers
async def _head_request_headers( async def _head_request_headers(
self, self,
endpoint: str, endpoint: str,
@@ -140,6 +162,10 @@ class SupabaseClient:
""" """
url = f"{base_url_override or self.base_url}{endpoint}" url = f"{base_url_override or self.base_url}{endpoint}"
# Use Basic Auth headers for postgres-meta requests when meta_auth is configured
if base_url_override and base_url_override == self.meta_base_url:
headers = self._get_meta_headers(headers_override)
else:
headers = self._get_headers(use_service_role, headers_override) headers = self._get_headers(use_service_role, headers_override)
# Remove Content-Type for binary data # Remove Content-Type for binary data
@@ -163,7 +189,7 @@ class SupabaseClient:
if params: if params:
kwargs["params"] = params kwargs["params"] = params
if json_data: if json_data is not None:
kwargs["json"] = json_data kwargs["json"] = json_data
if data: if data:
kwargs["data"] = data kwargs["data"] = data
@@ -681,7 +707,10 @@ class SupabaseClient:
async def empty_bucket(self, bucket_id: str) -> dict: async def empty_bucket(self, bucket_id: str) -> dict:
"""Empty a bucket (delete all files).""" """Empty a bucket (delete all files)."""
return await self.request( return await self.request(
"POST", f"/storage/v1/bucket/{bucket_id}/empty", use_service_role=True "POST",
f"/storage/v1/bucket/{bucket_id}/empty",
json_data={},
use_service_role=True,
) )
async def list_files( async def list_files(

View File

@@ -241,13 +241,6 @@ def get_tool_specifications() -> list[dict[str, Any]]:
}, },
"scope": "admin", "scope": "admin",
}, },
{
"name": "get_auth_config",
"method_name": "get_auth_config",
"description": "Get current GoTrue authentication configuration.",
"schema": {"type": "object", "properties": {}},
"scope": "read",
},
{ {
"name": "search_users", "name": "search_users",
"method_name": "search_users", "method_name": "search_users",
@@ -490,17 +483,6 @@ async def delete_user_factor(client: SupabaseClient, user_id: str, factor_id: st
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False) return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_auth_config(client: SupabaseClient) -> str:
"""Get auth configuration"""
try:
# Get health which includes some config info
result = await client.request("GET", "/auth/v1/health", use_service_role=False)
return json.dumps({"success": True, "config": result}, indent=2, ensure_ascii=False)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def search_users( async def search_users(
client: SupabaseClient, query: str, page: int = 1, per_page: int = 50 client: SupabaseClient, query: str, page: int = 1, per_page: int = 50
) -> str: ) -> str:

View File

@@ -59,6 +59,7 @@ class SupabasePlugin(BasePlugin):
- service_role_key: Admin API key (bypasses RLS). Required. - service_role_key: Admin API key (bypasses RLS). Required.
- anon_key: Public API key (RLS protected). Optional. - anon_key: Public API key (RLS protected). Optional.
- meta_url: Direct postgres-meta URL. Optional. - meta_url: Direct postgres-meta URL. Optional.
- meta_auth: Basic Auth for postgres-meta (username:password). Optional.
project_id: Optional project ID (auto-generated if not provided) project_id: Optional project ID (auto-generated if not provided)
""" """
super().__init__(config, project_id=project_id) super().__init__(config, project_id=project_id)
@@ -69,6 +70,7 @@ class SupabasePlugin(BasePlugin):
anon_key=config.get("anon_key", ""), anon_key=config.get("anon_key", ""),
service_role_key=config["service_role_key"], service_role_key=config["service_role_key"],
meta_url=config.get("meta_url"), meta_url=config.get("meta_url"),
meta_auth=config.get("meta_auth"),
) )
@staticmethod @staticmethod

View File

@@ -76,9 +76,13 @@ class WPCLIManager:
""" """
try: try:
# First, test if we have Docker socket access # First, test if we have Docker socket access
test_cmd = "docker version --format '{{.Server.Version}}'" test_process = await asyncio.create_subprocess_exec(
test_process = await asyncio.create_subprocess_shell( "docker",
test_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE "version",
"--format",
"{{.Server.Version}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
test_stdout, test_stderr = await asyncio.wait_for( test_stdout, test_stderr = await asyncio.wait_for(
@@ -98,11 +102,16 @@ class WPCLIManager:
self.logger.debug(f"Docker access OK - Server version: {docker_version}") self.logger.debug(f"Docker access OK - Server version: {docker_version}")
# Now check for our specific container using exact name match # Now check for our specific container using exact name match
# Use --all to include stopped containers and provide better error message process = await asyncio.create_subprocess_exec(
cmd = f"docker ps --all --filter name=^{self.container_name}$ --format '{{{{.Names}}}}|{{{{.Status}}}}'" "docker",
"ps",
process = await asyncio.create_subprocess_shell( "--all",
cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE "--filter",
f"name=^{self.container_name}$",
"--format",
"{{.Names}}|{{.Status}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0) stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0)
@@ -116,9 +125,14 @@ class WPCLIManager:
if not output: if not output:
# Container not found - get list of available containers for helpful error # Container not found - get list of available containers for helpful error
list_cmd = "docker ps --all --format '{{.Names}}' | head -10" list_process = await asyncio.create_subprocess_exec(
list_process = await asyncio.create_subprocess_shell( "docker",
list_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE "ps",
"--all",
"--format",
"{{.Names}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
list_stdout, _ = await list_process.communicate() list_stdout, _ = await list_process.communicate()
available = list_stdout.decode().strip().split("\n") if list_stdout else [] available = list_stdout.decode().strip().split("\n") if list_stdout else []
@@ -169,10 +183,15 @@ class WPCLIManager:
try: try:
# Try to run wp --version # Try to run wp --version
cmd = f"docker exec {self.container_name} wp --version --allow-root" process = await asyncio.create_subprocess_exec(
"docker",
process = await asyncio.create_subprocess_shell( "exec",
cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE self.container_name,
"wp",
"--version",
"--allow-root",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0) stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0)
@@ -290,15 +309,17 @@ class WPCLIManager:
f"Please install WP-CLI in your WordPress container." f"Please install WP-CLI in your WordPress container."
) )
# 4. Build docker exec command # 4. Build docker exec command (split command into args to prevent shell injection)
docker_cmd = f"docker exec {self.container_name} wp {command} --allow-root" cmd_parts = (
["docker", "exec", self.container_name, "wp"] + command.split() + ["--allow-root"]
)
self.logger.info(f"Executing: {docker_cmd}") self.logger.info(f"Executing: {' '.join(cmd_parts)}")
try: try:
# 5. Execute command # 5. Execute command
process = await asyncio.create_subprocess_shell( process = await asyncio.create_subprocess_exec(
docker_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE *cmd_parts, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
) )
# 6. Wait for completion with timeout # 6. Wait for completion with timeout
@@ -580,12 +601,32 @@ class WPCLIManager:
# Get file size if export succeeded # Get file size if export succeeded
# Try to check file size via docker exec # Try to check file size via docker exec
try: try:
size_cmd = f"docker exec {self.container_name} stat -f %z {export_path} 2>/dev/null || docker exec {self.container_name} stat -c %s {export_path} 2>/dev/null" # Try GNU stat first, fall back to BSD stat
process = await asyncio.create_subprocess_exec(
process = await asyncio.create_subprocess_shell( "docker",
size_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE "exec",
self.container_name,
"stat",
"-c",
"%s",
export_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0)
if process.returncode != 0:
# BSD stat fallback
process = await asyncio.create_subprocess_exec(
"docker",
"exec",
self.container_name,
"stat",
"-f",
"%z",
export_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0) stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0)
if process.returncode == 0: if process.returncode == 0:

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "mcphub-server" name = "mcphub-server"
version = "3.2.0" version = "3.3.0"
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)" description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
authors = [ authors = [
{name = "MCP Hub", email = "contact@mcphub.dev"} {name = "MCP Hub", email = "contact@mcphub.dev"}

View File

@@ -66,6 +66,8 @@ from core.dashboard.routes import (
api_get_config, api_get_config,
api_list_keys, api_list_keys,
api_list_sites, api_list_sites,
# K.5: Settings routes
api_save_setting,
api_test_site, api_test_site,
api_update_site, api_update_site,
# E.2: OAuth Social Login routes # E.2: OAuth Social Login routes
@@ -104,7 +106,9 @@ from core.dashboard.routes import (
dashboard_project_health_check, dashboard_project_health_check,
# K.2: Projects routes # K.2: Projects routes
dashboard_projects_list, dashboard_projects_list,
# K.5: Settings routes # F.3: Service pages
dashboard_service_page,
dashboard_services_list,
dashboard_settings_page, dashboard_settings_page,
# E.3: Site Management pages # E.3: Site Management pages
dashboard_sites_add, dashboard_sites_add,
@@ -1707,32 +1711,15 @@ def create_dynamic_tool(name: str, description: str, handler, input_schema: dict
# Create signature # Create signature
sig = inspect.Signature(params) sig = inspect.Signature(params)
# Create wrapper function with dynamic signature # Create wrapper function using closure (no exec)
# We need to use exec to create a function with the right signature async def dynamic_wrapper(**kwargs):
param_names = [p.name for p in params]
param_str = ", ".join(param_names)
# Build the function code
func_code = f"""
async def {name}({param_str}):
'''
{description}
'''
kwargs = {{{', '.join(f'"{p}": {p}' for p in param_names)}}}
return await handler(**kwargs) return await handler(**kwargs)
"""
# Execute the code to create the function # Set function metadata so FastMCP recognizes it correctly
local_vars = {"handler": handler} dynamic_wrapper.__name__ = name
exec(func_code, local_vars) dynamic_wrapper.__qualname__ = name
dynamic_wrapper = local_vars[name] dynamic_wrapper.__doc__ = description
# Attach the correct signature so FastMCP/Pydantic identify optional parameters.
# Without this, all parameters appear required because the exec'd function
# has no default values in its code object.
dynamic_wrapper.__signature__ = sig dynamic_wrapper.__signature__ = sig
# Set annotations
annotations["return"] = str # All our tools return strings annotations["return"] = str # All our tools return strings
dynamic_wrapper.__annotations__ = annotations dynamic_wrapper.__annotations__ = annotations
@@ -2453,9 +2440,9 @@ async def oauth_authorize(request: Request):
) )
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"oauth/authorize.html", "oauth/authorize.html",
{ {
"request": request,
"client_id": validated["client_id"], "client_id": validated["client_id"],
"client_name": client_name, "client_name": client_name,
"redirect_uri": validated["redirect_uri"], "redirect_uri": validated["redirect_uri"],
@@ -2486,9 +2473,9 @@ async def oauth_authorize(request: Request):
translations = get_all_translations(lang) translations = get_all_translations(lang)
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"oauth/error.html", "oauth/error.html",
{ {
"request": request,
"error": e.error, "error": e.error,
"error_description": e.error_description, "error_description": e.error_description,
"redirect_uri": params.get("redirect_uri") if "params" in locals() else None, "redirect_uri": params.get("redirect_uri") if "params" in locals() else None,
@@ -2510,9 +2497,9 @@ async def oauth_authorize(request: Request):
# For unexpected errors, render error page # For unexpected errors, render error page
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"oauth/error.html", "oauth/error.html",
{ {
"request": request,
"error": "server_error", "error": "server_error",
"error_description": str(e), "error_description": str(e),
"redirect_uri": params.get("redirect_uri") if "params" in locals() else None, "redirect_uri": params.get("redirect_uri") if "params" in locals() else None,
@@ -2654,10 +2641,8 @@ async def oauth_authorize_confirm(request: Request):
user_role = user_session.get("role", "user") user_role = user_session.get("role", "user")
api_key_id = f"user:{user_session['user_id']}" api_key_id = f"user:{user_session['user_id']}"
api_key_project_id = "*" api_key_project_id = "*"
if user_role == "admin": # All authenticated users get admin scope for their own services
api_key_scope = "read write admin" api_key_scope = "read write admin"
else:
api_key_scope = "read write"
logger.info( logger.info(
f"OAuth authorization: Session-based consent - " f"OAuth authorization: Session-based consent - "
f"user_id={user_session['user_id']}, " f"user_id={user_session['user_id']}, "
@@ -2756,8 +2741,9 @@ async def oauth_authorize_confirm(request: Request):
else: else:
# No redirect_uri, render error page # No redirect_uri, render error page
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"oauth/error.html", "oauth/error.html",
{"request": request, "error": e.error, "error_description": e.error_description}, {"error": e.error, "error_description": e.error_description},
status_code=e.status_code, status_code=e.status_code,
) )
@@ -2770,8 +2756,9 @@ async def oauth_authorize_confirm(request: Request):
return RedirectResponse(url=error_url, status_code=302) return RedirectResponse(url=error_url, status_code=302)
else: else:
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"oauth/error.html", "oauth/error.html",
{"request": request, "error": "server_error", "error_description": str(e)}, {"error": "server_error", "error_description": str(e)},
status_code=500, status_code=500,
) )
@@ -4716,6 +4703,13 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
try: try:
await initialize_database() await initialize_database()
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
# Load managed settings cache (4C.3)
try:
from core.settings import refresh_plugin_cache
await refresh_plugin_cache()
except Exception as e:
logger.debug("Settings cache init skipped: %s", e)
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize database: {e}") logger.error(f"Failed to initialize database: {e}")
raise raise
@@ -4803,6 +4797,9 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
methods=["GET"], methods=["GET"],
), ),
Route("/dashboard/connect", dashboard_connect_page, methods=["GET"]), Route("/dashboard/connect", dashboard_connect_page, methods=["GET"]),
# F.3: Service pages (must be before /dashboard catch-all)
Route("/dashboard/services", dashboard_services_list, methods=["GET"]),
Route("/dashboard/services/{plugin_type}", dashboard_service_page, methods=["GET"]),
Route("/dashboard", dashboard_home, methods=["GET"]), Route("/dashboard", dashboard_home, methods=["GET"]),
Route("/dashboard/", dashboard_home, methods=["GET"]), Route("/dashboard/", dashboard_home, methods=["GET"]),
Route("/api/dashboard/stats", dashboard_api_stats, methods=["GET"]), Route("/api/dashboard/stats", dashboard_api_stats, methods=["GET"]),
@@ -4856,8 +4853,9 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
Route("/dashboard/health", dashboard_health_page, methods=["GET"]), Route("/dashboard/health", dashboard_health_page, methods=["GET"]),
Route("/api/dashboard/health", dashboard_api_health, methods=["GET"]), Route("/api/dashboard/health", dashboard_api_health, methods=["GET"]),
Route("/api/dashboard/health/projects", dashboard_health_projects_partial, methods=["GET"]), Route("/api/dashboard/health/projects", dashboard_health_projects_partial, methods=["GET"]),
# Dashboard Settings routes (Phase K.5) # Dashboard Settings routes (Phase K.5 + 4C.3)
Route("/dashboard/settings", dashboard_settings_page, methods=["GET"]), Route("/dashboard/settings", dashboard_settings_page, methods=["GET"]),
Route("/api/dashboard/settings", api_save_setting, methods=["POST"]),
# Site Management API (E.3) # Site Management API (E.3)
Route("/api/sites", api_list_sites, methods=["GET"]), Route("/api/sites", api_list_sites, methods=["GET"]),
Route("/api/sites", api_create_site, methods=["POST"]), Route("/api/sites", api_create_site, methods=["POST"]),

View File

@@ -430,19 +430,14 @@ def create_dynamic_tool(
) )
) )
param_names = [p.name for p in params] # Create wrapper using closure (no exec)
param_str = ", ".join(param_names) async def dynamic_wrapper(**kwargs):
func_code = f"""
async def {name}({param_str}):
'''{description}'''
kwargs = {{{', '.join(f'"{p}": {p}' for p in param_names)}}}
return await handler(**kwargs) return await handler(**kwargs)
"""
local_vars = {"handler": handler} dynamic_wrapper.__name__ = name
exec(func_code, local_vars) dynamic_wrapper.__qualname__ = name
dynamic_wrapper = local_vars[name] dynamic_wrapper.__doc__ = description
dynamic_wrapper.__signature__ = inspect.Signature(params)
annotations["return"] = str annotations["return"] = str
dynamic_wrapper.__annotations__ = annotations dynamic_wrapper.__annotations__ = annotations

0
tests/legacy/test_oauth_registration_security.py Normal file → Executable file
View File

188
tests/test_admin_system.py Normal file
View File

@@ -0,0 +1,188 @@
"""Tests for Admin System Unification (F.4)."""
from core.dashboard.auth import (
DashboardSession,
get_session_display_info,
is_admin_session,
)
class TestIsAdminEmail:
"""Test is_admin_email() helper."""
def test_admin_email_matches(self, monkeypatch):
monkeypatch.setenv("ADMIN_EMAILS", "admin@example.com,boss@corp.com")
from core.admin_utils import is_admin_email
assert is_admin_email("admin@example.com") is True
def test_admin_email_case_insensitive(self, monkeypatch):
monkeypatch.setenv("ADMIN_EMAILS", "Admin@Example.com")
from core.admin_utils import is_admin_email
assert is_admin_email("admin@example.com") is True
def test_non_admin_email(self, monkeypatch):
monkeypatch.setenv("ADMIN_EMAILS", "admin@example.com")
from core.admin_utils import is_admin_email
assert is_admin_email("user@example.com") is False
def test_no_env_var_set(self, monkeypatch):
monkeypatch.delenv("ADMIN_EMAILS", raising=False)
from core.admin_utils import is_admin_email
assert is_admin_email("anyone@example.com") is False
def test_empty_env_var(self, monkeypatch):
monkeypatch.setenv("ADMIN_EMAILS", "")
from core.admin_utils import is_admin_email
assert is_admin_email("anyone@example.com") is False
def test_whitespace_in_env_var(self, monkeypatch):
monkeypatch.setenv("ADMIN_EMAILS", " admin@example.com , boss@corp.com ")
from core.admin_utils import is_admin_email
assert is_admin_email("admin@example.com") is True
def test_none_email_returns_false(self, monkeypatch):
monkeypatch.setenv("ADMIN_EMAILS", "admin@example.com")
from core.admin_utils import is_admin_email
assert is_admin_email(None) is False
class TestOAuthAdminRole:
"""Test that OAuth callback assigns admin role based on ADMIN_EMAILS."""
def test_admin_email_gets_admin_role(self, monkeypatch):
monkeypatch.setenv("ADMIN_EMAILS", "admin@example.com")
from core.admin_utils import is_admin_email
email = "admin@example.com"
db_role = "user"
effective_role = "admin" if is_admin_email(email) else db_role
assert effective_role == "admin"
def test_normal_email_gets_user_role(self, monkeypatch):
monkeypatch.setenv("ADMIN_EMAILS", "admin@example.com")
from core.admin_utils import is_admin_email
email = "user@example.com"
db_role = "user"
effective_role = "admin" if is_admin_email(email) else db_role
assert effective_role == "user"
def test_no_admin_emails_env_keeps_user_role(self, monkeypatch):
monkeypatch.delenv("ADMIN_EMAILS", raising=False)
from core.admin_utils import is_admin_email
email = "anyone@example.com"
db_role = "user"
effective_role = "admin" if is_admin_email(email) else db_role
assert effective_role == "user"
class TestIsAdminSession:
"""Test is_admin_session with different session types."""
def test_master_session_is_admin(self):
session = DashboardSession(
session_id="test", created_at=None, expires_at=None, user_type="master"
)
assert is_admin_session(session) is True
def test_api_key_session_is_admin(self):
session = DashboardSession(
session_id="test", created_at=None, expires_at=None, user_type="api_key"
)
assert is_admin_session(session) is True
def test_oauth_admin_is_admin(self):
session = {"user_id": "123", "email": "a@b.com", "role": "admin", "type": "oauth_user"}
assert is_admin_session(session) is True
def test_oauth_user_is_not_admin(self):
session = {"user_id": "123", "email": "a@b.com", "role": "user", "type": "oauth_user"}
assert is_admin_session(session) is False
class TestGetSessionDisplayInfo:
"""Test display info for admin OAuth users."""
def test_oauth_admin_shows_admin_type(self):
session = {
"user_id": "123",
"email": "a@b.com",
"name": "Admin User",
"role": "admin",
"type": "oauth_user",
}
info = get_session_display_info(session)
assert info["type"] == "admin"
assert info["name"] == "Admin User"
assert info["email"] == "a@b.com"
def test_oauth_user_shows_user_type(self):
session = {
"user_id": "123",
"email": "a@b.com",
"name": "Normal User",
"role": "user",
"type": "oauth_user",
}
info = get_session_display_info(session)
assert info["type"] == "user"
def test_master_session_shows_admin(self):
session = DashboardSession(
session_id="test", created_at=None, expires_at=None, user_type="master"
)
info = get_session_display_info(session)
assert info["type"] == "admin"
assert info["name"] == "Admin"
class TestDisableMasterKeyLogin:
"""Test DISABLE_MASTER_KEY_LOGIN env var."""
def test_master_key_works_by_default(self, monkeypatch):
monkeypatch.delenv("DISABLE_MASTER_KEY_LOGIN", raising=False)
monkeypatch.setenv("MASTER_API_KEY", "test-key-123")
from core.dashboard.auth import DashboardAuth
auth = DashboardAuth(master_api_key="test-key-123")
is_valid, user_type, key_id = auth.validate_api_key("test-key-123")
assert is_valid is True
assert user_type == "master"
def test_master_key_blocked_when_disabled(self, monkeypatch):
monkeypatch.setenv("DISABLE_MASTER_KEY_LOGIN", "true")
monkeypatch.setenv("MASTER_API_KEY", "test-key-123")
from core.dashboard.auth import DashboardAuth
auth = DashboardAuth(master_api_key="test-key-123")
is_valid, user_type, key_id = auth.validate_api_key("test-key-123")
assert is_valid is False
def test_master_key_works_when_explicitly_enabled(self, monkeypatch):
monkeypatch.setenv("DISABLE_MASTER_KEY_LOGIN", "false")
monkeypatch.setenv("MASTER_API_KEY", "test-key-123")
from core.dashboard.auth import DashboardAuth
auth = DashboardAuth(master_api_key="test-key-123")
is_valid, user_type, key_id = auth.validate_api_key("test-key-123")
assert is_valid is True
def test_api_key_still_works_when_master_disabled(self, monkeypatch):
monkeypatch.setenv("DISABLE_MASTER_KEY_LOGIN", "true")
from core.dashboard.auth import DashboardAuth
auth = DashboardAuth(master_api_key="test-key-123")
is_valid, user_type, key_id = auth.validate_api_key("test-key-123")
assert is_valid is False # Master key blocked

View File

@@ -0,0 +1,17 @@
"""Tests for admin dashboard stats (Phase F.3)."""
from core.dashboard.routes import get_dashboard_stats
class TestAdminDashboardStats:
"""Test admin dashboard statistics include platform data."""
async def test_stats_include_users_count(self):
"""Verify users_count is present in admin stats."""
stats = await get_dashboard_stats()
assert "users_count" in stats
async def test_stats_include_user_sites_count(self):
"""Verify user_sites_count is present in admin stats."""
stats = await get_dashboard_stats()
assert "user_sites_count" in stats

View File

@@ -0,0 +1,77 @@
"""Tests for last_tested_at site feature (Phase F.3)."""
import base64
import os
import pytest
from core.database import Database
@pytest.fixture(autouse=True)
def _set_encryption_key(monkeypatch):
"""Ensure ENCRYPTION_KEY is set and singleton is reset for tests."""
key = base64.b64encode(os.urandom(32)).decode()
monkeypatch.setenv("ENCRYPTION_KEY", key)
# Reset the module-level singleton so it picks up the new key
import core.encryption as enc_mod
monkeypatch.setattr(enc_mod, "_credential_encryption", None)
@pytest.fixture
async def db(tmp_path):
db = Database(str(tmp_path / "test.db"))
await db.initialize()
yield db
await db.close()
@pytest.fixture
async def test_user(db):
return await db.create_user(
email="test@example.com",
name="Test User",
provider="github",
provider_id="12345",
)
class TestLastTestedAt:
async def test_new_site_has_no_last_tested(self, db, test_user):
from core.encryption import get_credential_encryption
enc = get_credential_encryption()
creds = enc.encrypt_credentials({"username": "admin"}, "test-context")
site = await db.create_site(
user_id=test_user["id"],
plugin_type="wordpress",
alias="myblog",
url="https://example.com",
credentials=creds,
)
assert site is not None
assert site["last_tested_at"] is None
async def test_update_status_sets_last_tested(self, db, test_user):
from core.encryption import get_credential_encryption
enc = get_credential_encryption()
creds = enc.encrypt_credentials({"username": "admin"}, "test-context-2")
site = await db.create_site(
user_id=test_user["id"],
plugin_type="wordpress",
alias="testblog",
url="https://example.com",
credentials=creds,
)
site_id = site["id"]
await db.update_site_status(site_id, "active", "Connection OK", user_id=test_user["id"])
updated = await db.get_site(site_id, test_user["id"])
assert updated["last_tested_at"] is not None
async def test_schema_version_is_current(self, db):
from core.database import SCHEMA_VERSION
row = await db.fetchone("SELECT MAX(version) AS v FROM schema_version")
assert row["v"] == SCHEMA_VERSION

View File

@@ -0,0 +1,35 @@
"""Tests for MCP service pages (Phase F.3)."""
from core.dashboard.routes import get_service_page_data
class TestServicePageData:
async def test_wordpress_service_data(self):
data = await get_service_page_data("wordpress")
assert data is not None
assert data["plugin_type"] == "wordpress"
assert data["display_name"] == "WordPress"
assert "tools" in data
assert len(data["tools"]) > 0
assert "credential_fields" in data
async def test_supabase_service_data(self):
data = await get_service_page_data("supabase")
assert data is not None
assert data["display_name"] == "Supabase"
async def test_unknown_plugin_returns_none(self):
data = await get_service_page_data("nonexistent")
assert data is None
async def test_tools_have_name_and_description(self):
data = await get_service_page_data("wordpress")
for tool in data["tools"]:
assert "name" in tool
assert "description" in tool
async def test_tools_have_scope(self):
data = await get_service_page_data("wordpress")
for tool in data["tools"]:
assert "scope" in tool
assert tool["scope"] in ("read", "write", "admin")

View File

@@ -0,0 +1,148 @@
"""Tests for plugin visibility control (Track F.1)."""
import os
from unittest.mock import patch
from core.plugin_visibility import (
DEFAULT_PUBLIC_PLUGINS,
get_public_plugin_types,
is_plugin_public,
)
class TestGetPublicPluginTypes:
"""Tests for get_public_plugin_types()."""
def test_default_when_env_not_set(self):
"""Returns default set when ENABLED_PLUGINS is not set."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ENABLED_PLUGINS", None)
result = get_public_plugin_types()
assert result == DEFAULT_PUBLIC_PLUGINS
def test_default_when_env_empty(self):
"""Returns default set when ENABLED_PLUGINS is empty string."""
with patch.dict(os.environ, {"ENABLED_PLUGINS": ""}):
result = get_public_plugin_types()
assert result == DEFAULT_PUBLIC_PLUGINS
def test_custom_plugins_from_env(self):
"""Reads custom plugin list from ENABLED_PLUGINS."""
with patch.dict(os.environ, {"ENABLED_PLUGINS": "wordpress,gitea"}):
result = get_public_plugin_types()
assert result == {"wordpress", "gitea"}
def test_whitespace_handling(self):
"""Strips whitespace from plugin names."""
with patch.dict(os.environ, {"ENABLED_PLUGINS": " wordpress , supabase "}):
result = get_public_plugin_types()
assert result == {"wordpress", "supabase"}
def test_case_insensitive(self):
"""Plugin names are lowercased."""
with patch.dict(os.environ, {"ENABLED_PLUGINS": "WordPress,SUPABASE"}):
result = get_public_plugin_types()
assert result == {"wordpress", "supabase"}
def test_single_plugin(self):
"""Works with a single plugin."""
with patch.dict(os.environ, {"ENABLED_PLUGINS": "wordpress"}):
result = get_public_plugin_types()
assert result == {"wordpress"}
def test_ignores_empty_entries(self):
"""Trailing commas or double commas are ignored."""
with patch.dict(os.environ, {"ENABLED_PLUGINS": "wordpress,,supabase,"}):
result = get_public_plugin_types()
assert result == {"wordpress", "supabase"}
class TestIsPluginPublic:
"""Tests for is_plugin_public()."""
def test_default_wordpress_public(self):
"""WordPress is public by default."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ENABLED_PLUGINS", None)
assert is_plugin_public("wordpress") is True
def test_default_woocommerce_public(self):
"""WooCommerce is public by default."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ENABLED_PLUGINS", None)
assert is_plugin_public("woocommerce") is True
def test_default_supabase_public(self):
"""Supabase is public by default."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ENABLED_PLUGINS", None)
assert is_plugin_public("supabase") is True
def test_default_gitea_not_public(self):
"""Gitea is not public by default."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ENABLED_PLUGINS", None)
assert is_plugin_public("gitea") is False
def test_default_wordpress_advanced_not_public(self):
"""WordPress Advanced is not public by default."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ENABLED_PLUGINS", None)
assert is_plugin_public("wordpress_advanced") is False
def test_case_insensitive_check(self):
"""Plugin type check is case insensitive."""
with patch.dict(os.environ, {"ENABLED_PLUGINS": "wordpress"}):
assert is_plugin_public("WordPress") is True
assert is_plugin_public("WORDPRESS") is True
def test_custom_env_overrides_defaults(self):
"""Custom ENABLED_PLUGINS overrides the default set."""
with patch.dict(os.environ, {"ENABLED_PLUGINS": "gitea,n8n"}):
assert is_plugin_public("gitea") is True
assert is_plugin_public("n8n") is True
assert is_plugin_public("wordpress") is False
class TestSiteApiIntegration:
"""Tests that site_api uses plugin_visibility correctly."""
def test_get_user_credential_fields_filtered(self):
"""get_user_credential_fields only returns enabled plugins."""
from core.site_api import get_user_credential_fields
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ENABLED_PLUGINS", None)
fields = get_user_credential_fields()
assert "wordpress" in fields
assert "woocommerce" in fields
assert "supabase" in fields
assert "wordpress_advanced" not in fields
assert "gitea" not in fields
assert "n8n" not in fields
def test_get_user_plugin_names_filtered(self):
"""get_user_plugin_names only returns enabled plugins."""
from core.site_api import get_user_plugin_names
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ENABLED_PLUGINS", None)
names = get_user_plugin_names()
assert "wordpress" in names
assert "woocommerce" in names
assert "supabase" in names
assert "wordpress_advanced" not in names
assert "gitea" not in names
def test_custom_env_changes_fields(self):
"""Custom ENABLED_PLUGINS changes which credential fields are returned."""
from core.site_api import get_user_credential_fields
with patch.dict(os.environ, {"ENABLED_PLUGINS": "wordpress"}):
fields = get_user_credential_fields()
assert "wordpress" in fields
assert "woocommerce" not in fields
assert "supabase" not in fields