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

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

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

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

27
core/admin_utils.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

59
core/plugin_visibility.py Normal file
View File

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

162
core/settings.py Normal file
View File

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

View File

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

View File

@@ -173,7 +173,8 @@
{% endif %}
</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="flex-1 border-t border-gray-600"></div>
<span class="px-4 text-sm text-gray-400">
@@ -182,7 +183,6 @@
<div class="flex-1 border-t border-gray-600"></div>
</div>
<!-- Admin Login Link -->
<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">
<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>
{% if lang == 'fa' %}ورود مدیر با کلید API{% else %}Admin Login with API Key{% endif %}
</a>
{% endif %}
<!-- Language Toggle -->
<div class="mt-6 text-center">

View File

@@ -92,6 +92,7 @@
{# ── Derive RBAC info from session ── #}
{% set _is_admin = is_admin_session(session) %}
{% set _is_oauth_admin = _is_admin and session is mapping %}
{% set _display = get_session_display_info(session) %}
<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
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'),
('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
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>
{% endfor %}
{# Render user nav (non-admin only) #}
{% if not _is_admin %}
{# Render user nav (non-admin users + OAuth admins) #}
{% if not _is_admin or _is_oauth_admin %}
{% for item_id, label, icon_path, url in user_nav %}
<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 %}">
@@ -206,15 +208,28 @@
{% endif %}
</nav>
<!-- Version footer -->
<div x-show="sidebarOpen" class="px-4 py-2 text-xs text-gray-400 dark:text-gray-600">
v{{ project_version | default('') }}
<!-- Support link -->
<div x-show="sidebarOpen" class="px-4 py-2">
<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>
<!-- 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 -->
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
{# Profile link for OAuth users #}
{% if not _is_admin %}
{# Profile link for OAuth users (including OAuth admins) #}
{% if not _is_admin or _is_oauth_admin %}
<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 %}">
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"

View File

@@ -97,7 +97,7 @@
{% endif %}
</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">
<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()"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
{% for site in sites %}
<option value="{{ site.alias }}">{{ site.alias }} ({{ site.plugin_type }})</option>
<option value="{{ site.alias }}" data-plugin="{{ site.plugin_type }}">{{ site.alias }} ({{ site.plugin_type }})</option>
{% endfor %}
</select>
</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>
</div>
<!-- Transport type info -->
<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">
<!-- Transport type info (hidden for web-based clients via JS) -->
<div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
<p class="text-sm text-blue-700 dark:text-blue-400">
{% if lang == 'fa' %}
<strong>نکته:</strong> تنظیمات بالا شامل نوع اتصال (<code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code) می‌باشد. از <code>sse</code> استفاده نکنید — باعث خطای <code>400 Bad Request</code> می‌شود.
@@ -139,76 +139,50 @@
</p>
</div>
<!-- WordPress plugin requirement -->
<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">
<!-- WordPress SEO plugin note (shown/hidden dynamically by JS based on selected site) -->
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
<p class="text-sm text-amber-700 dark:text-amber-400">
{% if lang == 'fa' %}
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
را روی سایت وردپرسی خود نصب کنید. همچنین یک <strong>Application Password</strong> در وردپرس (Users → Profile) ایجاد و هنگام افزودن سایت وارد کنید.
را روی سایت وردپرسی خود نصب کنید.
{% else %}
<strong>WordPress:</strong> For SEO tools, install the
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
plugin on your WordPress site. Also create an <strong>Application Password</strong> in WordPress (Users → Profile) and enter it when adding your site.
plugin on your WordPress site.
{% endif %}
</p>
</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 %}
<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 %}
</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>
{% endblock %}
@@ -254,11 +228,35 @@ async function deleteKey(keyId) {
}
}
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
const WP_TYPES = ['wordpress', 'woocommerce'];
async function updateConfig() {
const alias = document.getElementById('config-site')?.value;
const siteSelect = document.getElementById('config-site');
const alias = siteSelect?.value;
const client = document.getElementById('config-client')?.value;
if (!alias || !client) return;
const isWebClient = WEB_CLIENTS.includes(client);
// Show/hide transport note
const transportNote = document.getElementById('transport-note');
if (transportNote) transportNote.style.display = isWebClient ? 'none' : '';
// Show/hide connection tip (only for web clients)
const connTip = document.getElementById('connection-tip');
if (connTip) connTip.style.display = isWebClient ? '' : 'none';
// Show/hide bearer hint (only for desktop/CLI clients)
const bearerHint = document.getElementById('bearer-hint');
if (bearerHint) bearerHint.style.display = isWebClient ? 'none' : '';
// Show/hide WordPress SEO note based on selected site's plugin type
const selectedOption = siteSelect.options[siteSelect.selectedIndex];
const pluginType = selectedOption?.dataset?.plugin || '';
const wpNote = document.getElementById('wp-seo-note');
if (wpNote) wpNote.style.display = WP_TYPES.includes(pluginType) ? '' : 'none';
try {
const resp = await fetch('/api/config/' + alias + '?client=' + client);
const data = await resp.json();

View File

@@ -12,7 +12,7 @@
{# ══════════════════════════════════════════════════════ #}
<!-- 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 -->
<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">
@@ -94,6 +94,42 @@
</svg>
</a>
</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>
<!-- Admin Main Content Grid -->
@@ -252,33 +288,6 @@
</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>
@@ -288,7 +297,7 @@
{# ══════════════════════════════════════════════════════ #}
<!-- 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 -->
<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">
@@ -349,24 +358,6 @@
</a>
</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>
<!-- User Site Status -->
@@ -439,33 +430,6 @@
</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 %}
</div>

View File

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

View File

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

View File

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

View File

@@ -93,6 +93,63 @@
</div>
</div>
<!-- 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 -->
<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">
@@ -208,3 +265,59 @@
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function saveSetting(key) {
const input = document.getElementById('input-' + key);
const btn = document.getElementById('save-' + key);
const value = input.value.trim();
if (!value) return;
btn.textContent = '...';
btn.disabled = true;
try {
const resp = await fetch('/api/dashboard/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value, action: 'save' }),
});
const data = await resp.json();
if (resp.ok) {
btn.textContent = '✓';
btn.className = btn.className.replace('bg-blue-600', 'bg-green-600').replace('hover:bg-blue-700', 'hover:bg-green-700');
const source = document.getElementById('source-' + key);
if (source) { source.textContent = 'database'; source.className = 'px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'; }
setTimeout(() => location.reload(), 1000);
} else {
alert(data.error || 'Failed to save');
btn.textContent = '{% if lang == "fa" %}ذخیره{% else %}Save{% endif %}';
}
} catch (e) {
alert('Error: ' + e.message);
btn.textContent = '{% if lang == "fa" %}ذخیره{% else %}Save{% endif %}';
} finally {
btn.disabled = false;
}
}
async function resetSetting(key) {
if (!confirm('Reset this setting to default?')) return;
try {
const resp = await fetch('/api/dashboard/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, action: 'reset' }),
});
if (resp.ok) {
location.reload();
} else {
const data = await resp.json();
alert(data.error || 'Failed to reset');
}
} catch (e) {
alert('Error: ' + e.message);
}
}
</script>
{% endblock %}

View File

@@ -27,7 +27,7 @@
onchange="updateFields()">
<option value="">{{ t.select_plugin }}</option>
{% for ptype, pname in plugin_names.items() %}
<option value="{{ ptype }}">{{ pname }}</option>
<option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
{% endfor %}
</select>
</div>
@@ -82,11 +82,15 @@
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>';
pluginFields[ptype].forEach(field => {
function renderField(field) {
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>` : '';
html += `
return `
<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>
<input type="${field.type}" name="cred_${field.name}" ${req}
@@ -94,10 +98,29 @@
placeholder="${field.label}">
${hintHtml}
</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;
}
// Auto-fill credential fields if plugin_type is pre-selected (e.g., from service page)
if (document.getElementById('plugin_type').value) {
updateFields();
}
document.getElementById('add-site-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submit-btn');

View File

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

View File

@@ -52,19 +52,26 @@
</span>
</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' %}
<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' %}
<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' %}
<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 %}
<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 %}
{% 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 %}
<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 class="px-6 py-4">
<div class="flex items-center gap-2">
@@ -107,29 +114,68 @@
{% block scripts %}
<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) {
const btn = document.getElementById('test-btn-' + siteId);
btn.textContent = '{{ t.testing }}';
btn.disabled = true;
try {
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();
if (data.ok) {
btn.textContent = '{{ t.connection_ok }}';
btn.className = 'text-sm text-green-600 dark:text-green-400';
updateStatusBadge(siteId, data.status || 'active');
} else {
btn.textContent = data.message || '{{ t.connection_failed }}';
btn.textContent = '{{ t.connection_failed }}';
btn.className = 'text-sm text-red-600 dark:text-red-400';
updateStatusBadge(siteId, data.status || 'error');
}
} catch (e) {
btn.textContent = '{{ t.error }}';
btn.textContent = '{{ t.connection_failed }}';
btn.className = 'text-sm text-red-600 dark:text-red-400';
updateStatusBadge(siteId, 'error');
} finally {
setTimeout(() => {
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.disabled = false;
}, 3000);
}
setTimeout(() => {
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.disabled = false;
}, 3000);
}
async function deleteSite(siteId) {

View File

@@ -110,8 +110,8 @@
{% if lang == 'fa' %}Redirect URIs (هر URI در یک خط){% else %}Redirect URIs (one per line){% endif %}
</label>
<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"></textarea>
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
https://claude.com/api/mcp/auth_callback</textarea>
</div>
</div>
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">

View File

@@ -306,6 +306,17 @@ async def user_mcp_handler(request: Request) -> Response:
status_code=403,
)
# --- 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 ---
try:
body = await request.json()

View File

@@ -56,7 +56,7 @@ class UserKeyManager:
self,
user_id: str,
name: str,
scopes: str = "read write",
scopes: str = "read write admin",
expires_in_days: int | None = None,
) -> dict[str, Any]:
"""Create a new API key for a user.
@@ -64,7 +64,7 @@ class UserKeyManager:
Args:
user_id: Owner's UUID.
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.
Returns:
@@ -73,9 +73,6 @@ class UserKeyManager:
"""
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)
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()