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:
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 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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
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)",
|
||||
"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]]:
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -212,8 +212,8 @@
|
||||
required
|
||||
rows="3"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="https://example.com/callback https://app.example.com/oauth/callback"
|
||||
></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>
|
||||
|
||||
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>
|
||||
|
||||
<!-- 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 %}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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] = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user