diff --git a/.gitignore b/.gitignore index c7aacf4..d48fc22 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,4 @@ pytest-cache-files-*/ .worktrees/ worktrees/ /.agents +uv.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index c0874ff..2245027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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.1–F.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 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 9ac1008..8e34f9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,8 @@ plugins/{name}/ **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 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/dashboard/routes.py` | Web UI dashboard (login, projects, API keys, health, audit) | | `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 -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) @@ -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` +## 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 - 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 - `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 +- 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`) - `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) @@ -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` - Persistent volumes: `mcp-data` (API keys, OAuth), `mcp-logs` (audit, health) - 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) diff --git a/README.md b/README.md index 44b4658..873e13f 100644 --- a/README.md +++ b/README.md @@ -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) -**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** 2. Add your sites via the dashboard (My Sites → Add Service) 3. Go to **Connect** page — generate config for your AI client 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` --- diff --git a/core/admin_utils.py b/core/admin_utils.py new file mode 100644 index 0000000..ec9ff1f --- /dev/null +++ b/core/admin_utils.py @@ -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 diff --git a/core/api_keys.py b/core/api_keys.py index 3e3b792..62abd6a 100644 --- a/core/api_keys.py +++ b/core/api_keys.py @@ -14,6 +14,8 @@ from dataclasses import asdict, dataclass from datetime import datetime, timedelta from pathlib import Path +import bcrypt + logger = logging.getLogger(__name__) @@ -79,7 +81,7 @@ class APIKey: """ key_id: str - key_hash: str + key_hash: str # bcrypt hash (new keys) or SHA-256 hex (legacy) project_id: str scope: Scope created_at: str @@ -180,8 +182,16 @@ class APIKeyManager: logger.error(f"Failed to save keys: {e}") def _hash_key(self, api_key: str) -> str: - """Hash API key for storage.""" - return hashlib.sha256(api_key.encode()).hexdigest() + """Hash API key for storage using bcrypt.""" + 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( self, @@ -269,11 +279,9 @@ class APIKeyManager: Returns: Optional[str]: key_id if valid, None otherwise """ - key_hash = self._hash_key(api_key) - - # Find key by hash + # Find key by verifying against stored hash (bcrypt or legacy SHA-256) 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 # Check if valid (not revoked, not expired) @@ -341,10 +349,8 @@ class APIKeyManager: Returns: Optional[APIKey]: The APIKey object if found, None otherwise """ - key_hash = self._hash_key(api_key) - 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") return key diff --git a/core/config_snippets.py b/core/config_snippets.py index 8d48ede..7bb68f7 100644 --- a/core/config_snippets.py +++ b/core/config_snippets.py @@ -20,6 +20,11 @@ import json # Supported MCP client types SUPPORTED_CLIENTS = [ + { + "id": "claude_connectors", + "label": "Claude.ai Connectors", + "description": "claude.ai/customize/connectors", + }, { "id": "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]]: """Return the list of supported MCP client types.""" @@ -112,7 +120,7 @@ def generate_config( } return json.dumps(config, indent=2) - elif client_type == "chatgpt": + elif client_type in ("claude_connectors", "chatgpt"): return endpoint_url else: diff --git a/core/dashboard/auth.py b/core/dashboard/auth.py index cb3a403..793963c 100644 --- a/core/dashboard/auth.py +++ b/core/dashboard/auth.py @@ -90,21 +90,29 @@ class DashboardAuth: return False, "", None 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) - if self.master_api_key and secrets.compare_digest( - api_key_clean, self.master_api_key.strip() + if ( + 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 # Check AuthManager's master key (covers auto-generated temp keys) - try: - from core.auth import get_auth_manager + if not master_login_disabled: + try: + from core.auth import get_auth_manager - auth_mgr = get_auth_manager() - if auth_mgr.validate_master_key(api_key): - return True, "master", None - except Exception as e: - logger.debug(f"AuthManager check skipped: {e}") + auth_mgr = get_auth_manager() + if auth_mgr.validate_master_key(api_key): + return True, "master", None + except Exception as e: + logger.debug(f"AuthManager check skipped: {e}") # Check project API keys with admin scope try: @@ -353,9 +361,10 @@ def get_session_display_info(session) -> dict: if isinstance(session, DashboardSession): return {"name": "Admin", "type": "admin", "email": None, "avatar": None} if isinstance(session, dict): + session_type = "admin" if session.get("role") == "admin" else "user" return { "name": session.get("name") or session.get("email", "User"), - "type": "user", + "type": session_type, "email": session.get("email"), "avatar": None, } diff --git a/core/dashboard/routes.py b/core/dashboard/routes.py index 0a0da53..ddf9f45 100644 --- a/core/dashboard/routes.py +++ b/core/dashboard/routes.py @@ -64,6 +64,7 @@ DASHBOARD_TRANSLATIONS = { "oauth_clients": "OAuth Clients", "audit_logs": "Audit Logs", "health": "Health", + "services": "Services", "settings": "Settings", "logout": "Logout", # Login page @@ -127,8 +128,12 @@ DASHBOARD_TRANSLATIONS = { "edit_site": "Edit Site", "updating_site": "Updating site...", "keep_existing": "Leave blank to keep current value", + "leave_blank_to_clear": "Leave blank to clear (optional field)", "connection_ok": "Connection OK", "connection_failed": "Connection failed", + "last_tested": "Last tested", + "never_tested": "Not tested yet", + "just_now": "just now", "credentials": "Credentials", "select_plugin": "Select plugin type", "adding_site": "Adding site...", @@ -164,6 +169,7 @@ DASHBOARD_TRANSLATIONS = { "oauth_clients": "کلاینت‌های OAuth", "audit_logs": "لاگ‌های ممیزی", "health": "سلامت", + "services": "سرویس‌ها", "settings": "تنظیمات", "logout": "خروج", # Login page @@ -227,8 +233,12 @@ DASHBOARD_TRANSLATIONS = { "edit_site": "ویرایش سایت", "updating_site": "در حال بروزرسانی...", "keep_existing": "خالی بگذارید تا مقدار فعلی حفظ شود", + "leave_blank_to_clear": "خالی بگذارید برای حذف مقدار (فیلد اختیاری)", "connection_ok": "اتصال برقرار", "connection_failed": "اتصال ناموفق", + "last_tested": "آخرین تست", + "never_tested": "هنوز تست نشده", + "just_now": "همین الان", "credentials": "مشخصات دسترسی", "select_plugin": "نوع پلاگین را انتخاب کنید", "adding_site": "در حال افزودن سایت...", @@ -321,6 +331,20 @@ async def get_dashboard_stats() -> dict: except Exception as 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 @@ -330,7 +354,6 @@ async def get_user_dashboard_stats(user_id: str) -> dict: "sites_count": 0, "active_sites_count": 0, "api_keys_count": 0, - "tools_count": 0, } try: 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: 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 @@ -489,9 +504,9 @@ async def dashboard_login_page(request: Request) -> Response: next_url = request.query_params.get("next", "/dashboard") return templates.TemplateResponse( + request, "dashboard/login.html", { - "request": request, "lang": lang, "t": t, "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: """Handle dashboard login form submission.""" auth = get_dashboard_auth() @@ -557,6 +613,17 @@ async def dashboard_login_submit(request: Request) -> Response: # Create session 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 response = RedirectResponse(url=next_url, status_code=303) auth.set_session_cookie(response, token) @@ -623,7 +690,6 @@ async def dashboard_home(request: Request) -> Response: t = get_translations(lang) context = { - "request": request, "lang": lang, "t": t, "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: @@ -781,18 +847,23 @@ async def get_all_projects( site_manager = get_site_manager() sites = site_manager.list_all_sites() - is_master = False + is_admin = False current_user_id = None if user_session: - if hasattr(user_session, "user_type") and user_session.user_type == "master": - is_master = True + if ( + 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: current_user_id = user_session["user_id"] for site in sites: # Tenant isolation checks site_user_id = site.get("user_id") - if not is_master: + if not is_admin: if site_user_id != current_user_id: continue @@ -1003,9 +1074,9 @@ async def dashboard_projects_list(request: Request) -> Response: ) return templates.TemplateResponse( + request, "dashboard/projects/list.html", { - "request": request, "lang": lang, "t": t, "session": session, @@ -1048,9 +1119,9 @@ async def dashboard_project_detail(request: Request) -> Response: if not project: return templates.TemplateResponse( + request, "dashboard/projects/list.html", { - "request": request, "lang": lang, "t": t, "session": session, @@ -1062,9 +1133,9 @@ async def dashboard_project_detail(request: Request) -> Response: ) return templates.TemplateResponse( + request, "dashboard/projects/detail.html", { - "request": request, "lang": lang, "t": t, "session": session, @@ -1284,9 +1355,9 @@ async def dashboard_api_keys_list(request: Request) -> Response: available_projects = site_manager.list_all_sites() return templates.TemplateResponse( + request, "dashboard/api-keys/list.html", { - "request": request, "lang": lang, "t": t, "session": session, @@ -1472,9 +1543,9 @@ async def dashboard_oauth_clients_list(request: Request) -> Response: clients_data = await get_oauth_clients_data() return templates.TemplateResponse( + request, "dashboard/oauth-clients/list.html", { - "request": request, "lang": lang, "t": t, "session": session, @@ -1724,9 +1795,9 @@ async def dashboard_audit_logs_list(request: Request) -> Response: available_projects = site_manager.list_all_sites() return templates.TemplateResponse( + request, "dashboard/audit-logs/list.html", { - "request": request, "lang": lang, "t": t, "session": session, @@ -2040,9 +2111,9 @@ async def dashboard_health_page(request: Request) -> Response: health_data = await get_health_data(live_check=refresh) return templates.TemplateResponse( + request, "dashboard/health/index.html", { - "request": request, "lang": lang, "t": t, "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 return templates.TemplateResponse( + request, "dashboard/health/projects-partial.html", { - "request": request, "lang": lang, "t": t, "system_status": health_data["system_status"], @@ -2245,6 +2316,11 @@ async def dashboard_settings_page(request: Request) -> Response: plugins = get_registered_plugins() 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) if isinstance(session, dict): session_display = { @@ -2260,9 +2336,9 @@ async def dashboard_settings_page(request: Request) -> Response: } return templates.TemplateResponse( + request, "dashboard/settings/index.html", { - "request": request, "lang": lang, "t": t, "session": session, # Original session for RBAC sidebar @@ -2270,11 +2346,43 @@ async def dashboard_settings_page(request: Request) -> Response: "config": config, "plugins": plugins, "about": about, + "managed_settings": managed_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 # ============================================================================= @@ -2289,10 +2397,9 @@ async def auth_login_page(request: Request) -> Response: if session: return RedirectResponse(url="/dashboard", status_code=303) - # Get language - accept_language = request.headers.get("accept-language") + # Get language — default to English for auth page, only override via ?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) error = request.query_params.get("error") @@ -2308,14 +2415,16 @@ async def auth_login_page(request: Request) -> Response: pass return templates.TemplateResponse( + request, "dashboard/auth-login.html", { - "request": request, "lang": lang, "t": t, "error": error, "providers": providers, "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, ) + # 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 token = user_auth.create_user_session( user_id=user["id"], email=user["email"], 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) next_url = request.cookies.get("mcp_auth_next", "") # 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) return templates.TemplateResponse( + request, "dashboard/profile.html", { - "request": request, "lang": lang, "t": t, "session": user_session, @@ -2574,9 +2692,14 @@ def _require_user_session(request: Request): def _require_admin_session(request: Request): """Get admin session or redirect to dashboard. Helper for admin-only routes.""" auth = get_dashboard_auth() + # Check DashboardSession (master key / API key) session = auth.get_session_from_request(request) if session and is_admin_session(session): 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 return None, RedirectResponse(url="/dashboard", status_code=303) @@ -2616,9 +2739,9 @@ async def dashboard_sites_list(request: Request) -> Response: ) return templates.TemplateResponse( + request, "dashboard/sites/list.html", { - "request": request, "lang": lang, "t": t, "session": user_session, @@ -2644,16 +2767,28 @@ async def dashboard_sites_add(request: Request) -> Response: 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) - plugin_fields = get_user_credential_fields() - plugin_names = get_user_plugin_names() + # 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_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( + request, "dashboard/sites/add.html", { - "request": request, "lang": lang, "t": t, "session": user_session, @@ -2661,6 +2796,7 @@ async def dashboard_sites_add(request: Request) -> Response: "plugin_fields_json": json.dumps(plugin_fields), "plugin_names": plugin_names, "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") return templates.TemplateResponse( + request, "dashboard/connect.html", { - "request": request, "lang": lang, "t": t, "session": user_session, @@ -2811,7 +2947,14 @@ async def api_test_site(request: Request) -> Response: try: 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: return JSONResponse({"error": str(e)}, status_code=404) except Exception as e: @@ -2844,9 +2987,9 @@ async def dashboard_sites_edit(request: Request) -> Response: plugin_names = get_user_plugin_names() return templates.TemplateResponse( + request, "dashboard/sites/edit.html", { - "request": request, "lang": lang, "t": t, "session": user_session, @@ -2913,7 +3056,7 @@ async def api_create_key(request: Request) -> Response: result = await key_mgr.create_key( user_id=user_session["user_id"], 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"), ) 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] return templates.TemplateResponse( + request, "dashboard/user-oauth-clients.html", { - "request": request, "lang": lang, "t": t, "session": user_session, @@ -3091,6 +3234,195 @@ async def dashboard_user_oauth_clients_delete(request: Request) -> Response: 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): """ 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/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) mcp.custom_route("/api/sites", methods=["GET"])(api_list_sites) mcp.custom_route("/api/sites", methods=["POST"])(api_create_site) diff --git a/core/database.py b/core/database.py index 8c28088..789f098 100644 --- a/core/database.py +++ b/core/database.py @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) _DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data" # Schema version — increment when adding migrations -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 5 # Initial schema DDL _SCHEMA_SQL = """\ @@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS sites ( status TEXT NOT NULL DEFAULT 'pending', status_msg TEXT, last_health TEXT, + last_tested_at TEXT, created_at TEXT NOT NULL, UNIQUE(user_id, alias) ); @@ -108,6 +109,17 @@ _MIGRATIONS: dict[int, str] = { "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" ), + 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, performs system-level update (e.g., health checks). """ + now = _utc_now() if user_id is not None: await self.execute( - "UPDATE sites SET status = ?, status_msg = ? WHERE id = ? AND user_id = ?", - (status, status_msg, site_id, user_id), + "UPDATE sites SET status = ?, status_msg = ?, last_tested_at = ?" + " WHERE id = ? AND user_id = ?", + (status, status_msg, now, site_id, user_id), ) else: await self.execute( - "UPDATE sites SET status = ?, status_msg = ? WHERE id = ?", - (status, status_msg, site_id), + "UPDATE sites SET status = ?, status_msg = ?, last_tested_at = ?" " WHERE id = ?", + (status, status_msg, now, site_id), ) async def update_site_credentials( @@ -584,6 +598,33 @@ class Database: ) 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 # ------------------------------------------------------------------ diff --git a/core/oauth/client_registry.py b/core/oauth/client_registry.py index bcf3218..0fab07c 100644 --- a/core/oauth/client_registry.py +++ b/core/oauth/client_registry.py @@ -98,7 +98,7 @@ class ClientRegistry: client_name=client_name, redirect_uris=redirect_uris, 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 {}, owner_user_id=owner_user_id, ) diff --git a/core/oauth/schemas.py b/core/oauth/schemas.py index 9c39f47..ee465b9 100644 --- a/core/oauth/schemas.py +++ b/core/oauth/schemas.py @@ -18,9 +18,9 @@ class OAuthClient(BaseModel): default=["authorization_code", "refresh_token"], description="Allowed grant 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( - 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( default="client_secret_post", description="Token endpoint authentication method" diff --git a/core/oauth/server.py b/core/oauth/server.py index 6ac83f3..de129fe 100644 --- a/core/oauth/server.py +++ b/core/oauth/server.py @@ -115,7 +115,7 @@ class OAuthServer: ) # 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: if s not in client.allowed_scopes: raise OAuthError( diff --git a/core/plugin_visibility.py b/core/plugin_visibility.py new file mode 100644 index 0000000..e79add5 --- /dev/null +++ b/core/plugin_visibility.py @@ -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() diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..03ab3fc --- /dev/null +++ b/core/settings.py @@ -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 diff --git a/core/site_api.py b/core/site_api.py index b7eb3d1..6447025 100644 --- a/core/site_api.py +++ b/core/site_api.py @@ -120,9 +120,11 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = { "label": "Anon Key (Optional)", "type": "password", "required": False, + "advanced": True, "hint": ( "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)", "type": "text", "required": False, + "advanced": True, "hint": ( - "Only needed when /pg/ is not exposed through Kong (common on Coolify). " - "Enter the direct postgres-meta container URL, e.g.: " - "http://supabase-meta-:8080 — " - "Leave blank to use the default Kong /pg/ route." + "Only needed if your Supabase setup does not expose /pg/ through Kong. " + "Most self-hosted installs (including Coolify) work without this. " + "Example: http://supabase-meta:8080 or https://your-meta.example.com" + ), + }, + { + "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]]]: - """Get credential fields for regular (non-admin) users. + """Get credential fields for public (non-admin) users. - Excludes plugin types that require admin-level infrastructure - (e.g., wordpress_advanced needs Docker access). + Only includes plugins enabled via ENABLED_PLUGINS env var. Returns: Filtered dict of plugin_type -> field definitions. """ - excluded = {"wordpress_advanced"} - return {k: v for k, v in PLUGIN_CREDENTIAL_FIELDS.items() if k not in excluded} + from core.plugin_visibility import get_public_plugin_types + + 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]: - """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: Filtered dict of plugin_type -> display name. """ - excluded = {"wordpress_advanced"} - return {k: v for k, v in PLUGIN_DISPLAY_NAMES.items() if k not in excluded} + from core.plugin_visibility import get_public_plugin_types + + 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]]: diff --git a/core/templates/dashboard/auth-login.html b/core/templates/dashboard/auth-login.html index fb6f980..8fae31e 100644 --- a/core/templates/dashboard/auth-login.html +++ b/core/templates/dashboard/auth-login.html @@ -173,7 +173,8 @@ {% endif %} - + + {% if not master_login_disabled %}
@@ -182,7 +183,6 @@
- @@ -191,6 +191,7 @@ {% if lang == 'fa' %}ورود مدیر با کلید API{% else %}Admin Login with API Key{% endif %} + {% endif %}
diff --git a/core/templates/dashboard/base.html b/core/templates/dashboard/base.html index 64ac314..f15da31 100644 --- a/core/templates/dashboard/base.html +++ b/core/templates/dashboard/base.html @@ -92,6 +92,7 @@ {# ── Derive RBAC info from 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) %} {% endfor %} - {# Render user nav (non-admin only) #} - {% if not _is_admin %} + {# Render user nav (non-admin users + OAuth admins) #} + {% if not _is_admin or _is_oauth_admin %} {% for item_id, label, icon_path, url in user_nav %} @@ -206,15 +208,28 @@ {% endif %} - -
- v{{ project_version | default('') }} + + + + {% if project_version %} +
+ v{{ project_version }} +
+ {% endif %} +
- {# Profile link for OAuth users #} - {% if not _is_admin %} + {# Profile link for OAuth users (including OAuth admins) #} + {% if not _is_admin or _is_oauth_admin %} - +

{{ t.config_snippets }}

@@ -108,7 +108,7 @@
@@ -128,8 +128,8 @@
- -
+ +

{% if lang == 'fa' %} نکته: تنظیمات بالا شامل نوع اتصال (streamableHttp برای Claude Desktop و http برای VS Code/Claude Code) می‌باشد. از sse استفاده نکنید — باعث خطای 400 Bad Request می‌شود. @@ -139,76 +139,50 @@

- -
+ + + + + + + +
+

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

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

{{ t.no_sites }}. {{ t.add_site }}

{% endif %}
- - -
-

- {% if lang == 'fa' %}اتصال به Claude.ai{% else %}Connect to Claude.ai{% endif %} -

-
    -
  1. - {% if lang == 'fa' %} - در صفحه OAuth Clients یک کلاینت جدید بسازید - {% else %} - Create an OAuth Client on the OAuth Clients page - {% endif %} -
  2. -
  3. - {% if lang == 'fa' %} - در Claude.ai → Settings → Connectors → Add → Custom - {% else %} - Go to Claude.ai → Settings → Connectors → Add → Custom - {% endif %} -
  4. -
  5. - {% if lang == 'fa' %} - آدرس MCP Endpoint خود را وارد کنید: - {% else %} - Enter your MCP Endpoint URL: - {% endif %} - {% if sites %} - - {{ public_url }}/u/{{ session.user_id }}/{{ sites[0].alias }}/mcp - - {% else %} - - {{ public_url }}/u/{{ session.user_id }}/YOUR-ALIAS/mcp - - {% endif %} -
  6. -
  7. - {% if lang == 'fa' %}Client ID و Client Secret را از OAuth Clients وارد کنید{% else %}Enter Client ID and Client Secret from your OAuth Client{% endif %} -
  8. -
  9. - {% if lang == 'fa' %}با GitHub یا Google وارد شوید{% else %}Log in with GitHub or Google when prompted{% endif %} -
  10. -
- - - - - {% if lang == 'fa' %}مدیریت OAuth Clients{% else %}Manage OAuth Clients{% endif %} - -
{% endblock %} @@ -254,11 +228,35 @@ async function deleteKey(keyId) { } } +const WEB_CLIENTS = ['claude_connectors', 'chatgpt']; +const WP_TYPES = ['wordpress', 'woocommerce']; + 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; 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 { const resp = await fetch('/api/config/' + alias + '?client=' + client); const data = await resp.json(); diff --git a/core/templates/dashboard/index.html b/core/templates/dashboard/index.html index d3799f7..f953db6 100644 --- a/core/templates/dashboard/index.html +++ b/core/templates/dashboard/index.html @@ -12,7 +12,7 @@ {# ══════════════════════════════════════════════════════ #} -
+
@@ -94,6 +94,42 @@
+ + +
+
+
+

{% if lang == 'fa' %}کاربران{% else %}Total Users{% endif %}

+

{{ stats.users_count|default(0) }}

+
+
+ + + +
+
+

+ {% if lang == 'fa' %}کاربران ثبت‌نام شده{% else %}Registered users{% endif %} +

+
+ + +
+
+
+

{% if lang == 'fa' %}سایت‌های کاربران{% else %}User Sites{% endif %}

+

{{ stats.user_sites_count|default(0) }}

+
+
+ + + +
+
+

+ {% if lang == 'fa' %}سایت‌های متصل شده{% else %}Connected sites{% endif %} +

+
@@ -252,33 +288,6 @@
- -
-
-
- - - -
-

- {% if lang == 'fa' %}حمایت از پروژه{% else %}Support MCP Hub{% endif %} -

-

- {% if lang == 'fa' %}با حمایت مالی به توسعه این پروژه کمک کنید{% else %}Help us keep this project alive and growing{% endif %} -

- - - - - {% if lang == 'fa' %}حمایت با رمزارز{% else %}Donate with Crypto{% endif %} - -
-
@@ -288,7 +297,7 @@ {# ══════════════════════════════════════════════════════ #} -
+
@@ -349,24 +358,6 @@
- -
-
-
-

{{ t.get('available_tools', 'Available Tools') }}

-

{{ stats.tools_count|default(0) }}

-
-
- - - - -
-
-

- {% if lang == 'fa' %}ابزارهای MCP قابل استفاده{% else %}MCP tools available{% endif %} -

-
@@ -439,33 +430,6 @@
- -
-
-
- - - -
-

- {% if lang == 'fa' %}حمایت از پروژه{% else %}Support MCP Hub{% endif %} -

-

- {% if lang == 'fa' %}با حمایت مالی به توسعه این پروژه کمک کنید{% else %}Help us keep this project alive and growing{% endif %} -

- - - - - {% if lang == 'fa' %}حمایت با رمزارز{% else %}Donate with Crypto{% endif %} - -
-
{% endif %} diff --git a/core/templates/dashboard/oauth-clients/list.html b/core/templates/dashboard/oauth-clients/list.html index 9e9910f..9f4093c 100644 --- a/core/templates/dashboard/oauth-clients/list.html +++ b/core/templates/dashboard/oauth-clients/list.html @@ -212,8 +212,8 @@ required 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" - placeholder="https://example.com/callback https://app.example.com/oauth/callback" - > + >https://claude.ai/api/mcp/auth_callback +https://claude.com/api/mcp/auth_callback

{% if lang == 'fa' %}هر آدرس را در یک خط جداگانه وارد کنید{% else %}Enter one URI per line{% endif %}

@@ -227,11 +227,11 @@ read diff --git a/core/templates/dashboard/service.html b/core/templates/dashboard/service.html new file mode 100644 index 0000000..6c85a75 --- /dev/null +++ b/core/templates/dashboard/service.html @@ -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 %} +
+ +
+
+ + + + + +

{{ service.display_name }}

+ {% if service.is_public %} + + {% if lang == 'fa' %}عمومی{% else %}Public{% endif %} + + {% else %} + + {% if lang == 'fa' %}فقط مدیر{% else %}Admin Only{% endif %} + + {% endif %} +
+ + + {{ t.get('add_site', 'Add Site') }} + +
+ + +
+ +
+
+
+

+ {% if lang == 'fa' %}تعداد ابزار{% else %}Available Tools{% endif %} +

+

{{ service.tools_count }}

+
+
+ + + + +
+
+
+ + + {% set read_tools = service.tools|selectattr('scope', 'equalto', 'read')|list %} +
+
+
+

+ {% if lang == 'fa' %}ابزار خواندن{% else %}Read Tools{% endif %} +

+

{{ read_tools|length }}

+
+
+ + + + +
+
+
+ + + {% set write_tools = service.tools|selectattr('scope', 'equalto', 'write')|list %} +
+
+
+

+ {% if lang == 'fa' %}ابزار نوشتن{% else %}Write Tools{% endif %} +

+

{{ write_tools|length }}

+
+
+ + + +
+
+
+
+ + + {% if service.credential_fields %} +
+
+

+ {% if lang == 'fa' %}اطلاعات مورد نیاز{% else %}Setup Requirements{% endif %} +

+
+
+
+ {% for field in service.credential_fields %} +
+
+ {% if field.type == 'password' %} + + + + {% else %} + + + + {% endif %} +
+
+

{{ field.label }}

+ {% if field.hint %} +

{{ field.hint }}

+ {% endif %} + {% if field.required %} + + {% if lang == 'fa' %}الزامی{% else %}Required{% endif %} + + {% else %} + + {% if lang == 'fa' %}اختیاری{% else %}Optional{% endif %} + + {% endif %} +
+
+ {% endfor %} +
+
+
+ {% endif %} + + +
+
+

+ {% if lang == 'fa' %}لیست ابزارها{% else %}Tools{% endif %} + ({{ service.tools_count }}) +

+
+ +
+
+
+ {% for tool in service.tools %} +
+
+
+
+ {{ tool.name }} + {% if tool.scope == 'read' %} + read + {% elif tool.scope == 'write' %} + write + {% else %} + admin + {% endif %} +
+

{{ tool.description }}

+
+
+
+ {% endfor %} +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/core/templates/dashboard/services_list.html b/core/templates/dashboard/services_list.html new file mode 100644 index 0000000..6d16942 --- /dev/null +++ b/core/templates/dashboard/services_list.html @@ -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 %} +
+
+

+ {% if lang == 'fa' %}سرویس‌های MCP{% else %}MCP Services{% endif %} +

+
+ +
+ {% 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') %} + +
+

+ {{ svc.display_name }} +

+ {% if svc.is_public %} + + {% if lang == 'fa' %}عمومی{% else %}Public{% endif %} + + {% else %} + + {% if lang == 'fa' %}فقط مدیر{% else %}Admin Only{% endif %} + + {% endif %} +
+
+ {{ svc.tools_count }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %} + · + {% set read_count = svc.tools|selectattr('scope', 'equalto', 'read')|list|length %} + {% set write_count = svc.tools|selectattr('scope', 'equalto', 'write')|list|length %} + {{ read_count }} read + {{ write_count }} write +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/core/templates/dashboard/settings/index.html b/core/templates/dashboard/settings/index.html index 4572c1b..95c37ba 100644 --- a/core/templates/dashboard/settings/index.html +++ b/core/templates/dashboard/settings/index.html @@ -93,6 +93,63 @@ + + {% if managed_settings %} +
+

+ {% if lang == 'fa' %}تنظیمات قابل مدیریت{% else %}Managed Settings{% endif %} +

+

+ {% if lang == 'fa' %}این تنظیمات از پنل قابل تغییر هستند. اولویت: دیتابیس > متغیر محیطی > پیش‌فرض{% else %}These settings can be changed from the panel. Priority: Database > Environment > Default{% endif %} +

+
+ {% for s in managed_settings %} +
+
+
+ +

+ {% if lang == 'fa' %}{{ s.hint_fa }}{% else %}{{ s.hint }}{% endif %} +

+
+ + {{ s.source }} + +
+
+ + + {% if s.source == 'database' %} + + {% endif %} +
+

+ {% if lang == 'fa' %}پیش‌فرض: {{ s.default }}{% else %}Default: {{ s.default }}{% endif %} +

+
+ {% endfor %} +
+
+ {% endif %} +
@@ -208,3 +265,59 @@
{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/core/templates/dashboard/sites/add.html b/core/templates/dashboard/sites/add.html index d621d9f..3bbb386 100644 --- a/core/templates/dashboard/sites/add.html +++ b/core/templates/dashboard/sites/add.html @@ -27,7 +27,7 @@ onchange="updateFields()"> {% for ptype, pname in plugin_names.items() %} - + {% endfor %} @@ -82,11 +82,15 @@ return; } + const mainFields = pluginFields[ptype].filter(f => !f.advanced); + const advFields = pluginFields[ptype].filter(f => f.advanced); + let html = ''; - pluginFields[ptype].forEach(field => { + + function renderField(field) { const req = field.required ? 'required' : ''; const hintHtml = field.hint ? `

${field.hint}

` : ''; - html += ` + return `
${hintHtml}
`; - }); + } + + mainFields.forEach(field => { html += renderField(field); }); + + if (advFields.length > 0) { + html += ` +
+ + Advanced Settings + +
`; + advFields.forEach(field => { html += renderField(field); }); + 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) => { e.preventDefault(); const btn = document.getElementById('submit-btn'); diff --git a/core/templates/dashboard/sites/edit.html b/core/templates/dashboard/sites/edit.html index 48bc9cd..0eb3bd3 100644 --- a/core/templates/dashboard/sites/edit.html +++ b/core/templates/dashboard/sites/edit.html @@ -53,18 +53,48 @@ {% set fields = plugin_fields.get(site.plugin_type, []) %} {% for field in fields %} + {% if not field.get('advanced', False) %}
{% if field.hint %}

{{ field.hint }}

{% endif %}
+ {% endif %} {% endfor %} + + {% set has_advanced = fields | map(attribute='advanced') | select | list | length > 0 if fields else false %} + {% if has_advanced %} +
+ + Advanced Settings + +
+ {% for field in fields %} + {% if field.get('advanced', False) %} +
+ + + {% if field.hint %} +

{{ field.hint }}

+ {% endif %} +
+ {% endif %} + {% endfor %} +
+
+ {% endif %} @@ -99,12 +129,20 @@ 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 = {}; if (pluginFields[pluginType]) { pluginFields[pluginType].forEach(field => { 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] = ''; + } }); } diff --git a/core/templates/dashboard/sites/list.html b/core/templates/dashboard/sites/list.html index 417a0eb..4c662f1 100644 --- a/core/templates/dashboard/sites/list.html +++ b/core/templates/dashboard/sites/list.html @@ -52,19 +52,26 @@ {{ site.url }} - + {% if site.status == 'active' %} - {{ t.active }} + {{ t.active }} {% elif site.status == 'error' %} - {{ t.error }} + {{ t.error }} {% elif site.status == 'pending' %} - Pending + Pending {% else %} - {{ site.status }} + {{ site.status }} {% endif %} {% if site.status_msg %} -

{{ site.status_msg[:60] }}

+

{{ site.status_msg[:60] }}

{% endif %} +

+ {% if site.last_tested_at %} + {{ t.last_tested }}: {{ site.last_tested_at[:16] }} + {% else %} + {{ t.never_tested }} + {% endif %} +

@@ -107,29 +114,68 @@ {% block scripts %}