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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -164,3 +164,4 @@ pytest-cache-files-*/
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
worktrees/
|
worktrees/
|
||||||
/.agents
|
/.agents
|
||||||
|
uv.lock
|
||||||
|
|||||||
35
CHANGELOG.md
35
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/),
|
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.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
|
## [3.2.0] — 2026-02-25
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
36
CLAUDE.md
36
CLAUDE.md
@@ -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)
|
||||||
|
|||||||
@@ -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
27
core/admin_utils.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
59
core/plugin_visibility.py
Normal 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
162
core/settings.py
Normal 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
|
||||||
@@ -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]]:
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 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>
|
||||||
|
|||||||
185
core/templates/dashboard/service.html
Normal file
185
core/templates/dashboard/service.html
Normal 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 %}
|
||||||
57
core/templates/dashboard/services_list.html
Normal file
57
core/templates/dashboard/services_list.html
Normal 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>·</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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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] = '';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
25
env.example
25
env.example
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
64
server.py
64
server.py
@@ -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"]),
|
||||||
|
|||||||
@@ -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
0
tests/legacy/test_oauth_registration_security.py
Normal file → Executable file
188
tests/test_admin_system.py
Normal file
188
tests/test_admin_system.py
Normal 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
|
||||||
17
tests/test_f3_admin_stats.py
Normal file
17
tests/test_f3_admin_stats.py
Normal 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
|
||||||
77
tests/test_f3_last_tested.py
Normal file
77
tests/test_f3_last_tested.py
Normal 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
|
||||||
35
tests/test_f3_service_pages.py
Normal file
35
tests/test_f3_service_pages.py
Normal 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")
|
||||||
148
tests/test_plugin_visibility.py
Normal file
148
tests/test_plugin_visibility.py
Normal 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
|
||||||
Reference in New Issue
Block a user