feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats

Settings fixes:
- MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings
  table (DB > ENV > default), so dashboard/settings changes apply without
  restart. Sync cache refreshed on every save or delete.
- /api/me reports the live DB value for max_sites_per_user.

Admin improvements:
- Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS).
- Admin Overview now shows platform stats: registered users, new users (7d),
  total user sites, available tools.

Plugin cleanup:
- Appwrite and Directus plugins removed from the active registry (8 plugins
  now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase,
  OpenPanel, Coolify). Plugin code is retained for future re-enabling.
- Settings page plugin visibility list updated to match.

Mobile onboarding:
- Stepper steps on narrow viewports stack vertically with correct full border
  and rounded corners on each step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 23:33:20 +02:00
parent f203ca88de
commit 43fd2201a0
223 changed files with 36183 additions and 4115 deletions

View File

@@ -13,6 +13,8 @@ from .routes import (
# K.3: API Keys routes
dashboard_api_keys_list,
dashboard_api_keys_revoke,
# K.1: Core routes
dashboard_api_login,
dashboard_api_project_detail,
dashboard_api_projects,
dashboard_api_stats,
@@ -22,7 +24,6 @@ from .routes import (
dashboard_health_page,
dashboard_health_projects_partial,
dashboard_home,
# K.1: Core routes
dashboard_login_page,
dashboard_login_submit,
dashboard_logout,
@@ -44,6 +45,7 @@ __all__ = [
"get_dashboard_auth",
"register_dashboard_routes",
# K.1
"dashboard_api_login",
"dashboard_login_page",
"dashboard_login_submit",
"dashboard_logout",

View File

@@ -316,7 +316,7 @@ class DashboardAuth:
if request.url.query:
next_url += f"?{request.url.query}"
return RedirectResponse(
url=f"/auth/login?next={next_url}",
url=f"/dashboard/login?next={next_url}",
status_code=303,
)
return None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,345 @@
"""
SPA Routes (Track G) — React SPA support on /dashboard/*.
This module exposes:
- ``GET /api/me`` — JSON description of the current session (no redirect side
effects). Used by the React SPA to decide whether to show login or dashboard.
- ``GET /api/i18n/{lang}`` — JSON dump of the existing
``DASHBOARD_TRANSLATIONS`` so the SPA can reuse the copy without duplication.
- ``GET /dashboard`` and ``GET /dashboard/{path:path}`` — catch-all that
serves the SPA's compiled ``index.html``. Static asset handling is wired up
in ``register_spa_routes`` via a ``StaticFiles`` mount.
Coexistence: the legacy Jinja UI lives at ``/dashboard-legacy/*`` while the
SPA owns ``/dashboard/*``. Old ``/dashboard-v2/*`` links are redirected to the
new prefix by the main Starlette app (see Track G.12 in ROADMAP.md).
"""
from __future__ import annotations
import logging
import os
from typing import Any
from starlette.requests import Request
from starlette.responses import FileResponse, JSONResponse, RedirectResponse, Response
from starlette.staticfiles import StaticFiles
from .auth import get_dashboard_auth, is_admin_session
logger = logging.getLogger(__name__)
# Path to the Vite build output. Templates dir lives one level above this file.
_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
SPA_DIST_DIR = os.path.join(_TEMPLATES_DIR, "static", "dist")
# Marker placed just before </head> so the analytics injector has a stable,
# build-tool-agnostic insertion point.
_HEAD_CLOSE = "</head>"
def _spa_index_path() -> str:
return os.path.join(SPA_DIST_DIR, "index.html")
def _analytics_snippet() -> str:
"""Return analytics <script> tags assembled from env vars.
Each provider is independent: leaving its env vars unset silently skips
that block. Returns an empty string when nothing is configured so the
injection is a no-op.
"""
parts: list[str] = []
umami_id = os.environ.get("UMAMI_WEBSITE_ID", "").strip()
umami_url = os.environ.get("UMAMI_URL", "").strip()
if umami_id and umami_url:
parts.append(f'<script defer src="{umami_url}" data-website-id="{umami_id}"></script>')
op_api = os.environ.get("OPENPANEL_API_URL", "").strip()
op_client = os.environ.get("OPENPANEL_CLIENT_ID", "").strip()
if op_api and op_client:
# OpenPanel ships a small bootstrap that loads their tracker SDK.
parts.append(
"<script>"
"(function(){window.op=window.op||function(){(window.op.q=window.op.q||[]).push(arguments);};"
f'window.op("init",{{apiUrl:"{op_api}",clientId:"{op_client}"}});'
"var s=document.createElement('script');s.async=1;"
f's.src="{op_api.rstrip("/")}/script.js";'
"document.head.appendChild(s);})();"
"</script>"
)
return "".join(parts)
def _render_spa_index(index_path: str) -> bytes:
"""Read the built index.html and inject analytics tags before </head>.
Falls back to the unmodified bytes when no provider is configured or
when the </head> marker is somehow missing (older build).
"""
with open(index_path, "rb") as fh:
html = fh.read()
snippet = _analytics_snippet()
if not snippet:
return html
marker = _HEAD_CLOSE.encode("utf-8")
if marker not in html:
return html
return html.replace(marker, snippet.encode("utf-8") + marker, 1)
def _master_key_login_enabled() -> bool:
"""Return True when admin login by master API key is enabled.
Mirrors the env semantics used in ``core/dashboard/auth.py`` and
``core/dashboard/routes.py``: the env var inverts the meaning, so
``DISABLE_MASTER_KEY_LOGIN=true`` → master-key form is hidden.
"""
return os.environ.get("DISABLE_MASTER_KEY_LOGIN", "false").lower() != "true"
async def _max_sites_per_user() -> int:
from core.settings import get_setting
try:
val = await get_setting("MAX_SITES_PER_USER", "10")
return max(0, int(val or "10"))
except (ValueError, TypeError):
return 10
async def api_me(request: Request) -> Response:
"""Return JSON describing the current session.
Returns ``{"authenticated": false}`` with status 200 when the caller has no
valid session, instead of redirecting — this lets the SPA render its public
pages without round-trips.
Also exposes the per-request ``csrf_token`` (set by ``DashboardCSRFMiddleware``
on every request) and the ``master_key_login_enabled`` flag so the SPA can
submit guarded POSTs and conditionally render the admin-key form. The
underlying ``dashboard_csrf`` cookie stays HttpOnly; the JSON copy is the
only path JS has to read it.
"""
# Lazy import: avoids circular import with .routes (which imports from
# spa_routes is fine, but other dependents of routes.py shouldn't trigger
# heavy imports here).
from .routes import detect_language
auth = get_dashboard_auth()
admin_session = auth.get_session_from_request(request)
user_session = auth.get_user_session_from_request(request)
session = admin_session or user_session
accept_language = request.headers.get("accept-language")
query_lang = request.query_params.get("lang")
lang = detect_language(accept_language, query_lang)
csrf_token = getattr(request.state, "csrf_token", None)
master_key_login_enabled = _master_key_login_enabled()
if not session:
return JSONResponse(
{
"authenticated": False,
"is_admin": False,
"lang": lang,
"csrf_token": csrf_token,
"master_key_login_enabled": master_key_login_enabled,
"max_sites_per_user": await _max_sites_per_user(),
}
)
# Check is_admin against the *resolved* session, not just `admin_session`.
# Master-key login intentionally swaps the admin session token for a user
# session token (so the master admin can access My Sites etc.), which
# left `admin_session` as None and forced `is_admin` to False even though
# the user session dict carries role="admin". The SPA's RequireAuth then
# redirected admin-only routes (Health, OAuth clients, Audit logs) back
# to /sites for the master admin. Use `is_admin_session(session)` so the
# check works regardless of which slot the cookie resolved into.
is_admin = is_admin_session(session)
user_id = None
email = None
name = None
role = "user"
sess_type = "oauth_user"
# Extract fields defensively — DashboardSession and user-session dicts
# have different shapes.
if isinstance(session, dict):
user_id = session.get("user_id") or session.get("uid")
email = session.get("email")
name = session.get("name")
role = session.get("role") or ("admin" if is_admin else "user")
sess_type = session.get("type") or "oauth_user"
else:
# DashboardSession dataclass
user_id = getattr(session, "user_id", None) or getattr(session, "sid", None)
email = getattr(session, "email", None)
name = getattr(session, "name", None)
role = getattr(session, "role", None) or ("admin" if is_admin else "user")
sess_type = getattr(session, "type", None) or "master"
if sess_type in {"master", "api_key"}:
is_admin = True
return JSONResponse(
{
"authenticated": True,
"user_id": user_id,
"email": email,
"name": name,
"role": role,
"type": sess_type,
"is_admin": bool(is_admin),
"lang": lang,
"csrf_token": csrf_token,
"master_key_login_enabled": master_key_login_enabled,
"max_sites_per_user": await _max_sites_per_user(),
}
)
async def api_i18n(request: Request) -> Response:
"""Return the dashboard translation dictionary for the requested language."""
from .routes import DASHBOARD_TRANSLATIONS, get_translations
lang = request.path_params.get("lang", "en")
if lang not in DASHBOARD_TRANSLATIONS:
lang = "en"
return JSONResponse(get_translations(lang))
async def api_plugins(request: Request) -> Response:
"""Return the plugin catalog + per-plugin credential field definitions.
Used by the SPA's Site Add/Edit dialog to render the correct form
for the selected plugin without bouncing to the legacy Jinja page.
Admins see every plugin; user sessions see only the ones flagged
public by ``ENABLED_PLUGINS``. The shape mirrors the same map that
the Jinja form binds to so backend validation stays identical.
"""
auth = get_dashboard_auth()
admin_session = auth.get_session_from_request(request)
user_session = auth.get_user_session_from_request(request)
if admin_session is None and user_session is None:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
from core.site_api import get_user_credential_fields, get_user_plugin_names
is_admin = is_admin_session(admin_session or user_session)
fields = get_user_credential_fields(is_admin=is_admin)
names = get_user_plugin_names(is_admin=is_admin)
plugins = [
{"type": ptype, "name": names.get(ptype, ptype), "fields": flds}
for ptype, flds in fields.items()
]
plugins.sort(key=lambda p: p["name"].lower())
return JSONResponse({"plugins": plugins})
async def serve_spa(_request: Request, status_code: int = 200) -> Response:
"""Serve the SPA's compiled ``index.html`` for any /dashboard/* path."""
index = _spa_index_path()
if not os.path.exists(index):
# Friendly fallback when running before the first frontend build.
return Response(
(
"<!doctype html><html><body style='font-family:sans-serif;padding:40px'>"
"<h1>Dashboard SPA not built</h1>"
"<p>Run <code>cd web && npm install && npm run build</code> to "
"generate <code>core/templates/static/dist/index.html</code>, "
"or use the legacy dashboard at "
"<a href='/dashboard-legacy'>/dashboard-legacy</a>.</p>"
"</body></html>"
),
status_code=status_code,
media_type="text/html",
)
return Response(
_render_spa_index(index),
status_code=status_code,
media_type="text/html",
)
async def redirect_dashboard_v2(request: Request) -> Response:
"""308 redirect old /dashboard-v2/* URLs to the new /dashboard/* prefix."""
suffix = request.path_params.get("path", "")
target = "/dashboard"
if suffix:
target += f"/{suffix}"
elif request.url.path.endswith("/"):
target += "/"
if request.url.query:
target += f"?{request.url.query}"
return RedirectResponse(url=target, status_code=308)
def register_spa_routes(mcp: Any) -> None:
"""Register the SPA support routes on a FastMCP instance.
Mounts:
- ``/static/dist`` (StaticFiles) for hashed JS/CSS/asset bundles.
- ``/api/me`` and ``/api/i18n/{lang}`` JSON endpoints.
- ``/dashboard`` and ``/dashboard/{path:path}`` SPA index serving.
"""
logger.info("Registering SPA (/dashboard) routes...")
# JSON helpers
mcp.custom_route("/api/me", methods=["GET"])(api_me)
mcp.custom_route("/api/i18n/{lang}", methods=["GET"])(api_i18n)
mcp.custom_route("/api/plugins", methods=["GET"])(api_plugins)
# SPA catch-alls. Both the bare path and any sub-path resolve to index.html
# so react-router-dom can take over.
mcp.custom_route("/dashboard", methods=["GET"])(serve_spa)
mcp.custom_route("/dashboard/", methods=["GET"])(serve_spa)
mcp.custom_route("/dashboard/{path:path}", methods=["GET"])(serve_spa)
mcp.custom_route("/dashboard-v2", methods=["GET"])(redirect_dashboard_v2)
mcp.custom_route("/dashboard-v2/", methods=["GET"])(redirect_dashboard_v2)
mcp.custom_route("/dashboard-v2/{path:path}", methods=["GET"])(redirect_dashboard_v2)
# Static assets (JS/CSS bundles) — only mount if dist/ exists, otherwise
# FastMCP's underlying Starlette app refuses the mount on a missing dir.
if os.path.isdir(SPA_DIST_DIR):
try:
# FastMCP exposes its inner Starlette app via .http_app() in v3+.
# Fall back to .app if available.
inner_app = None
for attr in ("http_app", "app", "_app"):
candidate = getattr(mcp, attr, None)
inner_app = candidate() if callable(candidate) else candidate
if inner_app is not None:
break
if inner_app is not None and hasattr(inner_app, "mount"):
inner_app.mount(
"/static/dist",
StaticFiles(directory=SPA_DIST_DIR, check_dir=False),
name="spa-dist",
)
logger.info("Mounted SPA static at /static/dist -> %s", SPA_DIST_DIR)
else:
# If we can't mount, fall back to a per-file route handler so
# bundles still load. This is slower but functional.
async def _serve_dist_file(request: Request) -> Response:
rel = request.path_params.get("filename", "")
full = os.path.normpath(os.path.join(SPA_DIST_DIR, rel))
if not full.startswith(SPA_DIST_DIR) or not os.path.isfile(full):
return Response(status_code=404)
return FileResponse(full)
mcp.custom_route("/static/dist/{filename:path}", methods=["GET"])(_serve_dist_file)
logger.info("Registered fallback /static/dist/{path} route")
except Exception as exc: # pragma: no cover — defensive
logger.warning("Could not mount SPA static dir: %s", exc)
else:
logger.info(
"SPA dist not present yet (%s); build with `cd web && npm run build`", SPA_DIST_DIR
)
logger.info("SPA routes registered successfully")

View File

@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
# Schema version — increment when adding migrations
SCHEMA_VERSION = 13
SCHEMA_VERSION = 14
# Initial schema DDL
_SCHEMA_SQL = """\
@@ -281,6 +281,23 @@ _MIGRATIONS: dict[int, str] = {
# callers don't have to pass `model=...` every time.
"ALTER TABLE site_provider_keys ADD COLUMN default_model TEXT;\n"
),
14: (
# F.19.2.2: every user API key gets full-tier scope by design.
# Tool visibility is gated per-site via ``sites.tool_scope`` and
# per-tool toggles, not per-key. Older keys were issued before
# the F.19.2.0 tier system added ``editor`` / ``settings`` /
# ``install`` between ``read`` and ``write``, so their stored
# scope string ("read write admin" or narrower) doesn't list the
# new tiers explicitly. The universal-tier closure already maps
# ``admin`` to every tier, so functionally these keys see every
# tool — but enumerating the scope names explicitly keeps the
# DB consistent with the dashboard preset dropdown and removes
# any risk if a future change checks scope names directly
# without going through UNIVERSAL_SCOPE_TIERS.
"UPDATE user_api_keys "
"SET scopes = 'read editor settings install write admin' "
"WHERE scopes IN ('read write admin', 'read write', 'admin');\n"
),
}
@@ -759,6 +776,27 @@ class Database:
)
return row["cnt"] if row else 0
async def count_all_users(self) -> int:
"""Return total number of registered users."""
row = await self.fetchone("SELECT COUNT(*) AS cnt FROM users")
return row["cnt"] if row else 0
async def count_all_sites(self) -> int:
"""Return total number of user-owned sites across all users."""
row = await self.fetchone("SELECT COUNT(*) AS cnt FROM sites")
return row["cnt"] if row else 0
async def count_recent_users(self, days: int = 7) -> int:
"""Return number of users registered in the last N days."""
import time
cutoff = time.time() - days * 86400
row = await self.fetchone(
"SELECT COUNT(*) AS cnt FROM users WHERE created_at > ?",
(cutoff,),
)
return row["cnt"] if row else 0
# ------------------------------------------------------------------
# Settings CRUD (Phase 4C.3)
# ------------------------------------------------------------------

View File

@@ -7,7 +7,7 @@ Each endpoint exposes only the tools relevant to its purpose.
Endpoints:
/mcp - Admin endpoint (all tools, requires Master API Key)
/mcp/wordpress - WordPress tools only (92 tools)
/mcp/wordpress-advanced - WordPress Advanced tools (22 tools)
/mcp/wordpress-specialist - WordPress Specialist tools (companion-backed)
/mcp/gitea - Gitea tools only (55 tools)
/mcp/project/{id} - Project-specific tools

View File

@@ -16,7 +16,7 @@ class EndpointType(Enum):
SYSTEM = "system" # Phase X.3 - System tools only
WORDPRESS = "wordpress"
WOOCOMMERCE = "woocommerce"
WORDPRESS_ADVANCED = "wordpress_advanced"
WORDPRESS_SPECIALIST = "wordpress_specialist"
GITEA = "gitea"
N8N = "n8n"
SUPABASE = "supabase" # Phase G
@@ -181,15 +181,28 @@ ENDPOINT_CONFIGS = {
},
max_tools=35,
),
# WordPress Advanced endpoint - advanced operations
EndpointType.WORDPRESS_ADVANCED: EndpointConfig(
path="/wordpress-advanced",
name="WordPress Advanced",
description="WordPress advanced operations (database, bulk, system)",
endpoint_type=EndpointType.WORDPRESS_ADVANCED,
plugin_types=["wordpress_advanced"],
# F.19.1 WordPress Specialist endpoint - companion-backed advanced
# management (plugins/themes/users/options/cron/maintenance). No
# Docker socket; only needs Airano MCP Bridge v2.11.0+ on the WP
# side. Currently 6 read-only tools; F.19.2 will expand the surface.
EndpointType.WORDPRESS_SPECIALIST: EndpointConfig(
path="/wordpress-specialist",
name="WordPress Specialist",
description="Specialist WordPress management (plugins, themes, users, options, cron) — companion-backed",
endpoint_type=EndpointType.WORDPRESS_SPECIALIST,
plugin_types=["wordpress_specialist"],
require_master_key=False,
allowed_scopes={"admin"}, # Admin scope required
allowed_scopes={"read", "write", "admin"},
# Same blacklist as the basic wordpress endpoint — keep system
# tools off the per-plugin endpoint regardless of how the tool
# registry expands.
tool_blacklist={
"manage_api_keys_create",
"manage_api_keys_delete",
"manage_api_keys_rotate",
"oauth_register_client",
"oauth_revoke_client",
},
max_tools=30,
),
# Gitea endpoint - Git repository management

View File

@@ -162,10 +162,13 @@ class MCPEndpointFactory:
Returns:
Plugin type or None for system tools
"""
# Check for wordpress_advanced first (before wordpress_)
# Tools are named: wordpress_advanced_wp_db_*, wordpress_advanced_bulk_*, wordpress_advanced_system_*
if tool_name.startswith("wordpress_advanced_"):
return "wordpress_advanced"
# Check the multi-word WordPress variant BEFORE the bare
# ``wordpress_`` prefix — tool names are
# ``wordpress_specialist_wp_plugin_list``,
# ``wordpress_create_post`` etc. (``wordpress_advanced`` was
# sunset in F.19.3.2-.3 / 2026-05-04.)
if tool_name.startswith("wordpress_specialist_"):
return "wordpress_specialist"
if tool_name.startswith("wordpress_"):
return "wordpress"

View File

@@ -58,7 +58,7 @@ class EndpointRegistry:
"""
Initialize the default set of endpoints.
Creates admin, wordpress, wordpress-advanced, and gitea endpoints.
Creates admin, wordpress, wordpress-specialist, and gitea endpoints.
"""
if self._initialized:
logger.warning("Endpoints already initialized")

View File

@@ -520,7 +520,7 @@ class HealthMonitor:
if not self.site_manager:
return {"healthy": False, "message": "SiteManager not available"}
# Look up site info by full_id (handles multi-word plugin types like wordpress_advanced)
# Look up site info by full_id (handles multi-word plugin types like wordpress_specialist)
site_info = self._find_site_info(project_id)
if not site_info:
return {"healthy": False, "message": f"Site not found: {project_id}"}
@@ -547,7 +547,11 @@ class HealthMonitor:
)
# Fallback: authenticated health check for WordPress-based plugins
if plugin_type in ("wordpress", "wordpress_advanced", "woocommerce"):
if plugin_type in (
"wordpress",
"wordpress_specialist",
"woocommerce",
):
try:
import aiohttp

View File

@@ -114,8 +114,13 @@ class OAuthServer:
error_description="Only S256 code_challenge_method is supported (OAuth 2.1)",
)
# Validate scope
requested_scopes = scope.split() if scope else ["read", "write", "admin"]
# Validate scope. F.19.2.2: default to every tier explicitly so
# an OAuth client without a scope hint gets the same surface as a
# dashboard-issued user key. The binding narrowing is per-site
# (intersection with the site's tool_scope preset).
requested_scopes = (
scope.split() if scope else ["read", "editor", "settings", "install", "write", "admin"]
)
for s in requested_scopes:
if s not in client.allowed_scopes:
raise OAuthError(

View File

@@ -7,15 +7,24 @@ 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"}
public_types = get_public_plugin_types()
if is_plugin_public("gitea"): # True
...
"""
import os
# Default plugins available to public (OAuth) users
DEFAULT_PUBLIC_PLUGINS = {"wordpress", "woocommerce", "supabase", "openpanel", "gitea"}
# Default plugins available to public (OAuth) users.
DEFAULT_PUBLIC_PLUGINS = {
"wordpress",
"woocommerce",
"wordpress_specialist",
"supabase",
"openpanel",
"gitea",
"n8n",
"coolify",
}
def _parse_plugins(val: str) -> set[str]:

View File

@@ -3,7 +3,10 @@
Usage:
from core.settings import get_setting
enabled = await get_setting("ENABLED_PLUGINS", "wordpress,woocommerce,supabase,openpanel,gitea")
enabled = await get_setting(
"ENABLED_PLUGINS",
"wordpress,woocommerce,wordpress_specialist,supabase,openpanel,gitea,n8n,coolify",
)
max_sites = int(await get_setting("MAX_SITES_PER_USER", "10"))
"""
@@ -15,9 +18,14 @@ logger = logging.getLogger(__name__)
# Cached plugin set for sync access (updated when settings change)
_cached_plugins: set[str] | None = None
# Cached int settings for sync access (updated when settings change)
_cached_max_sites: int | None = None
_cached_rate_per_min: int | None = None
_cached_rate_per_hr: int | None = None
# Default values for all managed settings
SETTING_DEFAULTS: dict[str, str] = {
"ENABLED_PLUGINS": "wordpress,woocommerce,supabase,openpanel,gitea",
"ENABLED_PLUGINS": "wordpress,woocommerce,wordpress_specialist,supabase,openpanel,gitea,n8n,coolify",
"MAX_SITES_PER_USER": "10",
"USER_RATE_LIMIT_PER_MIN": "30",
"USER_RATE_LIMIT_PER_HR": "500",
@@ -84,15 +92,77 @@ async def get_setting(key: str, default: str | None = None) -> str | None:
return SETTING_DEFAULTS.get(key)
def get_cached_max_sites() -> int:
"""Return the cached MAX_SITES_PER_USER value (sync, uses last refresh)."""
if _cached_max_sites is not None:
return _cached_max_sites
env_val = os.environ.get("MAX_SITES_PER_USER")
if env_val:
try:
return max(1, int(env_val))
except ValueError:
pass
return int(SETTING_DEFAULTS["MAX_SITES_PER_USER"])
def get_cached_rate_per_min() -> int:
"""Return the cached USER_RATE_LIMIT_PER_MIN value (sync, uses last refresh)."""
if _cached_rate_per_min is not None:
return _cached_rate_per_min
env_val = os.environ.get("USER_RATE_LIMIT_PER_MIN")
if env_val:
try:
return max(1, int(env_val))
except ValueError:
pass
return int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_MIN"])
def get_cached_rate_per_hr() -> int:
"""Return the cached USER_RATE_LIMIT_PER_HR value (sync, uses last refresh)."""
if _cached_rate_per_hr is not None:
return _cached_rate_per_hr
env_val = os.environ.get("USER_RATE_LIMIT_PER_HR")
if env_val:
try:
return max(1, int(env_val))
except ValueError:
pass
return int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_HR"])
async def refresh_plugin_cache() -> None:
"""Refresh the cached plugin set from DB/ENV/default."""
global _cached_plugins
"""Refresh all sync-readable setting caches from DB/ENV/default."""
global _cached_plugins, _cached_max_sites, _cached_rate_per_min, _cached_rate_per_hr
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
try:
raw = await get_setting("MAX_SITES_PER_USER")
_cached_max_sites = max(1, int(raw)) if raw else int(SETTING_DEFAULTS["MAX_SITES_PER_USER"])
except (ValueError, TypeError):
_cached_max_sites = int(SETTING_DEFAULTS["MAX_SITES_PER_USER"])
try:
raw = await get_setting("USER_RATE_LIMIT_PER_MIN")
_cached_rate_per_min = (
max(1, int(raw)) if raw else int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_MIN"])
)
except (ValueError, TypeError):
_cached_rate_per_min = int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_MIN"])
try:
raw = await get_setting("USER_RATE_LIMIT_PER_HR")
_cached_rate_per_hr = (
max(1, int(raw)) if raw else int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_HR"])
)
except (ValueError, TypeError):
_cached_rate_per_hr = int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_HR"])
async def save_setting(key: str, value: str) -> None:
"""Save a setting to database and refresh caches."""
@@ -101,8 +171,7 @@ async def save_setting(key: str, value: str) -> None:
db = get_database()
await db.set_setting(key, value)
if key == "ENABLED_PLUGINS":
await refresh_plugin_cache()
await refresh_plugin_cache()
async def delete_setting_value(key: str) -> bool:
@@ -112,8 +181,7 @@ async def delete_setting_value(key: str) -> bool:
db = get_database()
deleted = await db.delete_setting(key)
if key == "ENABLED_PLUGINS":
await refresh_plugin_cache()
await refresh_plugin_cache()
return deleted

View File

@@ -19,8 +19,8 @@ import aiohttp
logger = logging.getLogger(__name__)
# Maximum sites per user (configurable via env var)
MAX_SITES_PER_USER = int(os.getenv("MAX_SITES_PER_USER", "10"))
# Fallback used when the settings DB is unavailable at import time
_MAX_SITES_FALLBACK = int(os.getenv("MAX_SITES_PER_USER", "10"))
# Plugin credential field definitions — drives the dynamic "Add Site" form
# and server-side validation. Each field has:
@@ -104,13 +104,18 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
),
},
],
"wordpress_advanced": [
# F.19.1 WordPress Specialist — companion-backed, no Docker socket.
# Same auth shape as the basic wordpress plugin (Application Password)
# but the WP user behind the password must have ``manage_options``,
# AND Airano MCP Bridge v2.11.0+ must be active on the WP site for
# the admin namespace to exist.
"wordpress_specialist": [
{
"name": "username",
"label": "Username",
"type": "text",
"required": True,
"hint": "Your WordPress admin username (HTTP Basic username for every API call).",
"hint": "WordPress admin username (the user that owns the Application Password).",
},
{
"name": "app_password",
@@ -119,16 +124,11 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"required": True,
"hint": (
"WordPress Admin → Users → Profile → Application Passwords. "
"IS the API credential — no separate API key needed."
"The user MUST have manage_options. Requires Airano MCP "
"Bridge v2.11.0+ on the WP side — install from "
"wordpress-plugin/airano-mcp-bridge.zip in the repo."
),
},
{
"name": "container",
"label": "Docker Container Name",
"type": "text",
"required": False,
"hint": "Docker container running WordPress (for WP-CLI access). Leave empty if unused.",
},
],
"gitea": [
{
@@ -268,7 +268,7 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
PLUGIN_DISPLAY_NAMES: dict[str, str] = {
"wordpress": "WordPress",
"woocommerce": "WooCommerce",
"wordpress_advanced": "WordPress Advanced",
"wordpress_specialist": "WordPress Specialist",
"gitea": "Gitea",
"n8n": "n8n",
"supabase": "Supabase",
@@ -282,7 +282,12 @@ PLUGIN_DISPLAY_NAMES: dict[str, str] = {
_HEALTH_ENDPOINTS: dict[str, dict[str, Any]] = {
"wordpress": {"path": "/wp-json/wp/v2/users/me", "method": "GET"},
"woocommerce": {"path": "/wp-json/wc/v3/system_status", "method": "GET"},
"wordpress_advanced": {"path": "/wp-json/wp/v2/users/me", "method": "GET"},
# F.19.1: hits the companion's lightest admin route. Returns 404 if
# Airano MCP Bridge v2.11.0+ is missing, 403 if the user lacks
# ``manage_options``, 200 with a small JSON body otherwise. This
# makes "Test Connection" surface the actual end-to-end requirement
# instead of just "auth works" (which /users/me would).
"wordpress_specialist": {"path": "/wp-json/airano-mcp/v1/admin/maintenance", "method": "GET"},
"gitea": {"path": "/api/v1/user", "method": "GET"},
"n8n": {"path": "/healthz", "method": "GET"},
"supabase": {"path": "/rest/v1/", "method": "GET"},
@@ -314,28 +319,53 @@ def get_credential_fields(plugin_type: str) -> list[dict[str, Any]]:
return fields
def get_user_credential_fields() -> dict[str, list[dict[str, Any]]]:
"""Get credential fields for public (non-admin) users.
def get_user_credential_fields(is_admin: bool = False) -> dict[str, list[dict[str, Any]]]:
"""Get credential fields for the dashboard's Add/Edit Site forms.
Only includes plugins enabled via ENABLED_PLUGINS env var.
Admin users see every registered plugin so they can use admin-only
plugins (wordpress_specialist) without having to add them to
``ENABLED_PLUGINS`` for everyone. Public users get only the plugins
enabled via ``ENABLED_PLUGINS`` env var (Track F.1).
F.19.1 (2026-05-01) — surfaced when an admin OAuth user added a
wordpress_specialist site and the manage page rendered an empty
credential form on revisit because the unfiltered map was being
filtered for public visibility. The site row exists but the
template can't render its fields.
Args:
is_admin: When True, return every plugin's credential fields.
Defaults to False for backward compatibility.
Returns:
Filtered dict of plugin_type -> field definitions.
Dict of plugin_type -> field definitions.
"""
if is_admin:
return dict(PLUGIN_CREDENTIAL_FIELDS)
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 public (non-admin) users.
def get_user_plugin_names(is_admin: bool = False) -> dict[str, str]:
"""Get plugin display names for the dashboard's plugin pickers.
Only includes plugins enabled via ENABLED_PLUGINS env var.
Admin users see every registered plugin (so the Add Site dropdown
surfaces ``wordpress_specialist`` etc.); public users get only the
plugins enabled via ``ENABLED_PLUGINS`` env var.
Args:
is_admin: When True, return every plugin's display name.
Defaults to False for backward compatibility.
Returns:
Filtered dict of plugin_type -> display name.
Dict of plugin_type -> display name.
"""
if is_admin:
return dict(PLUGIN_DISPLAY_NAMES)
from core.plugin_visibility import get_public_plugin_types
public = get_public_plugin_types()
@@ -387,7 +417,7 @@ async def validate_site_connection(
# Build auth headers per plugin type
headers: dict[str, str] = {}
if plugin_type in ("wordpress", "wordpress_advanced"):
if plugin_type in ("wordpress", "wordpress_specialist"):
import base64
username = credentials.get("username", "")
@@ -502,10 +532,13 @@ async def create_user_site(
db = get_database()
# Check site limit
# Check site limit (reads DB > ENV > default so dashboard/settings changes take effect)
from core.settings import get_cached_max_sites
max_sites = get_cached_max_sites()
count = await db.count_sites_by_user(user_id)
if count >= MAX_SITES_PER_USER:
raise ValueError(f"Site limit reached ({MAX_SITES_PER_USER} sites per user)")
if count >= max_sites:
raise ValueError(f"Site limit reached ({max_sites} sites per user)")
# Check alias uniqueness (DB constraint will also catch this)
existing = await db.get_site_by_alias(user_id, alias)

View File

@@ -1,18 +1,100 @@
<!DOCTYPE html>
<html lang="{{ lang|default('en') }}">
<html lang="{{ lang|default('en') }}" dir="{% if lang|default('en') == 'fa' %}rtl{% else %}ltr{% endif %}" data-theme="dark" data-theme-pref="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark light">
<title>{% block title %}MCP Hub{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
<script>
(function () {
try {
var html = document.documentElement;
var raw = localStorage.getItem("mcphub-ui");
var parsed = raw ? JSON.parse(raw) : null;
var state = parsed && parsed.state ? parsed.state : null;
var serverLang = html.getAttribute("lang") === "fa" ? "fa" : "en";
var storedLang = state && state.lang === "fa" ? "fa" : state && state.lang === "en" ? "en" : null;
var lang = storedLang || serverLang;
html.setAttribute("lang", lang);
html.setAttribute("dir", lang === "fa" ? "rtl" : "ltr");
var themePref = state && (state.theme === "light" || state.theme === "system" || state.theme === "dark")
? state.theme
: "dark";
var resolved = themePref;
if (themePref === "system" && window.matchMedia) {
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
html.setAttribute("data-theme", resolved);
html.setAttribute("data-theme-pref", themePref);
html.classList.toggle("dark", resolved === "dark");
html.style.colorScheme = resolved;
if (state && state.brandHue) {
html.style.setProperty("--brand-hue", String(state.brandHue));
}
if (storedLang && storedLang !== serverLang && location.pathname === "/oauth/authorize") {
var url = new URL(location.href);
if (!url.searchParams.has("lang")) {
url.searchParams.set("lang", storedLang);
location.replace(url.toString());
}
}
} catch (_) {
document.documentElement.classList.add("dark");
}
})();
</script>
<!-- Tailwind CSS from CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: "class" };
</script>
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin>
<link
href="https://fonts.bunny.net/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&family=Vazirmatn:wght@300;400;500;600;700&display=swap"
rel="stylesheet">
<!-- Custom styling -->
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-family: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: rgb(249 250 251);
}
html[lang="fa"] body,
html[lang="fa"] button,
html[lang="fa"] input,
html[lang="fa"] textarea,
html[lang="fa"] select {
font-family: Vazirmatn, Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
}
html[data-theme="dark"] body {
background: rgb(17 24 39);
}
html[data-theme="dark"] .dark\:bg-gray-900 {
background-color: rgb(17 24 39);
}
html[data-theme="dark"] .dark\:bg-gray-800 {
background-color: rgb(31 41 55);
}
html[data-theme="dark"] .dark\:bg-gray-700 {
background-color: rgb(55 65 81);
}
html[data-theme="dark"] .dark\:text-white {
color: rgb(255 255 255);
}
html[data-theme="dark"] .dark\:text-gray-200 {
color: rgb(229 231 235);
}
html[data-theme="dark"] .dark\:text-gray-300 {
color: rgb(209 213 219);
}
html[data-theme="dark"] .dark\:text-gray-400 {
color: rgb(156 163 175);
}
.auth-container {
min-height: 100vh;

View File

@@ -135,9 +135,6 @@
('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'),
('keys', t.get('keys', 'API Keys'), 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/keys'),
('oauth_clients', t.oauth_clients, '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', '/dashboard/oauth-clients'),
] %}
{# ── Admin-only nav items ── #}

View File

@@ -159,10 +159,8 @@
<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/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/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
{% endif %}
</p>
</div>

View File

@@ -209,7 +209,7 @@
{% set plugin_colors = {
'wordpress': 'bg-blue-500',
'woocommerce': 'bg-purple-500',
'wordpress_advanced': 'bg-indigo-500',
'wordpress_specialist': 'bg-indigo-500',
'gitea': 'bg-green-500',
'n8n': 'bg-orange-500',
'supabase': 'bg-emerald-500',
@@ -221,7 +221,7 @@
{% set plugin_icons = {
'wordpress': 'W',
'woocommerce': 'WC',
'wordpress_advanced': 'WA',
'wordpress_specialist': 'WS',
'gitea': 'G',
'n8n': 'n8n',
'supabase': 'SB',
@@ -377,7 +377,7 @@
{% set plugin_colors = {
'wordpress': 'bg-blue-500',
'woocommerce': 'bg-purple-500',
'wordpress_advanced': 'bg-indigo-500',
'wordpress_specialist': 'bg-indigo-500',
'gitea': 'bg-green-500',
'n8n': 'bg-orange-500',
'supabase': 'bg-emerald-500',

View File

@@ -275,10 +275,8 @@
<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/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/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
{% endif %}
</p>
</div>

View File

@@ -93,7 +93,7 @@
{% endif %}
<!-- Login Form -->
<form method="POST" action="/dashboard/login" class="space-y-6">
<form method="POST" action="/dashboard-legacy/login" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ request.state.csrf_token }}">
<input type="hidden" name="next" value="{{ next_url }}">
@@ -160,4 +160,4 @@
</div>
</body>
</html>
</html>

View File

@@ -101,7 +101,7 @@
{% set plugin_colors = {
'wordpress': 'bg-blue-500',
'woocommerce': 'bg-purple-500',
'wordpress_advanced': 'bg-indigo-500',
'wordpress_specialist': 'bg-indigo-500',
'gitea': 'bg-green-500',
'n8n': 'bg-orange-500',
'supabase': 'bg-emerald-500',
@@ -112,7 +112,7 @@
{% set plugin_icons = {
'wordpress': 'W',
'woocommerce': 'WC',
'wordpress_advanced': 'WA',
'wordpress_specialist': 'WS',
'gitea': 'G',
'n8n': 'n8n',
'supabase': 'SB',

View File

@@ -21,7 +21,7 @@
'openpanel': 'cyan',
'appwrite': 'pink',
'directus': 'violet',
'wordpress_advanced': 'indigo',
'wordpress_specialist': 'indigo',
} %}
{% for svc in services %}

View File

@@ -19,7 +19,7 @@ Required context:
companion_download_url — string, may be empty
#}
{% set fit_status = capability_probe.fit.status %}
{% set is_wp = site.plugin_type in ['wordpress', 'wordpress_advanced'] %}
{% set is_wp = site.plugin_type == 'wordpress' %}
{% set is_wc = site.plugin_type == 'woocommerce' %}
{% set is_wp_like = is_wp or is_wc %}
{% set ai_providers = capability_probe.ai_providers_configured or [] %}

View File

@@ -7,7 +7,11 @@
<div class="max-w-3xl mx-auto space-y-6">
<!-- Header -->
<div class="flex items-center gap-4 mb-6">
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
{# When the user arrives from /dashboard-v2, link the back arrow
there so they don't dump out into the legacy list. #}
{% set _qs = request.query_params if request else None %}
{% set _from_v2 = _qs and _qs.get('from') == 'dashboard-v2' %}
<a href="{% if _from_v2 %}/dashboard-v2/sites{% else %}/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}{% endif %}"
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
@@ -244,9 +248,13 @@
});
const data = await resp.json();
if (resp.ok) {
// Redirect to manage page for the new site
// If the user came in from the v2 SPA shell, send them
// back there instead of the legacy Jinja list.
const fromV2 = new URLSearchParams(window.location.search).get('from') === 'dashboard-v2';
const siteId = data.site?.id || data.site_id;
if (siteId) {
if (fromV2) {
window.location.href = '/dashboard-v2/sites?msg={{ t.site_added }}';
} else if (siteId) {
window.location.href = '/dashboard/sites/' + siteId + '{% if lang and lang != "en" %}?lang={{ lang }}{% endif %}';
} else {
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';

View File

@@ -5,8 +5,10 @@
{% block content %}
<div class="max-w-2xl mx-auto space-y-6">
{% set _qs = request.query_params if request else None %}
{% set _from_v2 = _qs and _qs.get('from') == 'dashboard-v2' %}
<div class="flex items-center gap-4 mb-6">
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
<a href="{% if _from_v2 %}/dashboard-v2/sites{% else %}/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}{% endif %}"
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
@@ -195,7 +197,12 @@
});
const data = await resp.json();
if (resp.ok) {
window.location.href = '/dashboard/sites?msg={{ t.site_updated }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
const fromV2 = new URLSearchParams(window.location.search).get('from') === 'dashboard-v2';
if (fromV2) {
window.location.href = '/dashboard-v2/sites?msg={{ t.site_updated }}';
} else {
window.location.href = '/dashboard/sites?msg={{ t.site_updated }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
}
} else {
errorEl.textContent = data.error || 'Failed to update site';
errorEl.classList.remove('hidden');

View File

@@ -6,8 +6,10 @@
{% block content %}
<div class="max-w-3xl mx-auto space-y-6">
<!-- Header -->
{% set _qs = request.query_params if request else None %}
{% set _from_v2 = _qs and _qs.get('from') == 'dashboard-v2' %}
<div class="flex items-center gap-4 mb-6">
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
<a href="{% if _from_v2 %}/dashboard-v2/sites{% else %}/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}{% endif %}"
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
@@ -625,14 +627,22 @@
fa: 'Application Password ثبت‌شده در Connection Settings باید متعلق به کاربر <strong>Administrator</strong> باشد تا CRUD کامل کار کند. ابزارهای SEO و مدیریت افزونه/قالب علاوه بر آن نیازمند نصب افزونه <code class="font-mono">Airano MCP SEO Bridge</code> روی سایت هستند.'
}
},
wordpress_advanced: {
wordpress_specialist: {
read: {
en: 'The Application Password in Connection Settings must belong to an <strong>Editor</strong>-or-lower user. Read-only WP-CLI operations are enforced by role caps.',
fa: 'Application Password ثبت‌شده در Connection Settings باید برای کاربری با نقش <strong>Editor</strong> یا پایین‌تر باشد.'
en: 'The Application Password in Connection Settings must belong to a WordPress user with <strong>manage_options</strong> (Administrator). Companion plugin <code class="font-mono">Airano MCP Bridge v2.11.0+</code> must be installed and active on the site.',
fa: 'Application Password ثبت‌شده باید متعلق به کاربری با مجوز <strong>manage_options</strong> (Administrator) باشد. افزونه همراه <code class="font-mono">Airano MCP Bridge v2.11.0+</code> باید روی سایت نصب و فعال باشد.'
},
editor: {
en: 'Same prerequisites as Read, plus companion <code class="font-mono">Airano MCP Bridge v2.13.0+</code> for page editing (Gutenberg blocks, Elementor, Classic) and <code class="font-mono">v2.14.0+</code> for theme file CRUD. Per-tool capability checks: <code>edit_post</code>, <code>edit_themes</code>.',
fa: 'پیش‌نیاز Read به‌علاوهٔ نسخهٔ <code class="font-mono">Airano MCP Bridge v2.13.0+</code> برای ویرایش صفحه (Gutenberg، Elementor، Classic) و <code class="font-mono">v2.14.0+</code> برای ویرایش فایل قالب. Capability موردنیاز: <code>edit_post</code> و <code>edit_themes</code>.'
},
install: {
en: 'Same prerequisites as Editor, plus companion <code class="font-mono">Airano MCP Bridge v2.14.0+</code> for theme install/activate/delete and <code class="font-mono">v2.15.0+</code> for plugin install/activate/deactivate/update. Per-tool capability checks: <code>install_plugins</code>, <code>activate_plugins</code>, <code>install_themes</code>, <code>switch_themes</code>, <code>update_plugins</code>.',
fa: 'پیش‌نیاز Editor به‌علاوهٔ <code class="font-mono">Airano MCP Bridge v2.14.0+</code> برای نصب/فعال‌سازی/حذف قالب و <code class="font-mono">v2.15.0+</code> برای نصب/فعال‌سازی/غیرفعال‌سازی/به‌روزرسانی پلاگین. Capability موردنیاز: <code>install_plugins</code>, <code>activate_plugins</code>, <code>install_themes</code>, <code>switch_themes</code>, <code>update_plugins</code>.'
},
admin: {
en: 'The Application Password in Connection Settings must belong to an <strong>Administrator</strong> and the Docker container name must be correct for shell-level plugin/theme operations. Install <code class="font-mono">Airano MCP SEO Bridge</code> on the site to unlock SEO + plugin/theme tools.',
fa: 'Application Password ثبت‌شده باید متعلق به کاربر <strong>Administrator</strong> باشد و نام Docker Container درست وارد شده باشد. برای ابزارهای SEO افزونه <code class="font-mono">Airano MCP SEO Bridge</code> روی سایت نصب باشد.'
en: 'Same prerequisites as Installer, plus URL/zip install routes and plugin/theme delete. Per-tool capability checks: <code>delete_plugins</code>, <code>delete_themes</code>. PHP file edits additionally require <code class="font-mono">DISALLOW_FILE_EDIT</code> to be unset (or false) in <code class="font-mono">wp-config.php</code>.',
fa: 'پیش‌نیاز Installer به‌علاوهٔ مسیرهای نصب از URL/zip و حذف پلاگین/قالب. Capability موردنیاز: <code>delete_plugins</code>, <code>delete_themes</code>. ویرایش فایل PHP علاوه بر آن نیازمند تنظیم نشدن (یا false بودن) ثابت <code class="font-mono">DISALLOW_FILE_EDIT</code> در <code class="font-mono">wp-config.php</code> است.'
}
},
woocommerce: {
@@ -697,11 +707,36 @@
}
};
// F.19.2.2 — destructive-tier warnings. Rendered above the credential
// requirement notice when the user picks ``install`` or ``admin``,
// so they understand the blast radius before the click sticks. Only
// these two tiers warrant a strong warning — everything below is
// recoverable (reads, content edits, theme file writes which
// optimistic-concurrency on sha256, settings which can be reset).
// ``install`` is amber: installs reshape the site but the source
// (wp.org) is curated. ``admin`` is red: arbitrary zip + delete +
// user CRUD have no undo.
const TIER_WARNINGS = {
install: {
severity: 'amber',
en: 'Selecting <strong>Installer</strong> grants the AI agent permission to install + activate plugins and themes from wp.org slugs. Source is curated by wp.org, but installations affect the live site immediately. Recommended: test on a staging site first; review installed plugins regularly via <code>wp_plugin_list</code>.',
fa: 'انتخاب <strong>Installer</strong> به agent اجازه می‌دهد پلاگین و قالب را با slug از wp.org نصب و فعال کند. منبع wp.org curated است اما نصب‌ها بلافاصله روی سایت زنده اعمال می‌شوند. توصیه: ابتدا روی سایت staging تست کنید و افزونه‌های نصب‌شده را با <code>wp_plugin_list</code> به‌طور منظم مرور کنید.'
},
admin: {
severity: 'red',
en: 'Selecting <strong>Admin</strong> grants the AI agent the full destructive surface: install plugins/themes from arbitrary URLs or base64 zips, delete plugins (drops their data with no undo), delete themes, and user CRUD. Use only on sites you fully control where an AI mistake is recoverable from backups. <strong>Not recommended for production sites with real users.</strong>',
fa: 'انتخاب <strong>Admin</strong> به agent سطح کامل تخریبی می‌دهد: نصب پلاگین/قالب از URL یا zip دلخواه، حذف پلاگین (داده‌اش بدون undo پاک می‌شود)، حذف قالب، و CRUD کاربر. فقط روی سایت‌هایی که کامل کنترل دارید و خطای agent از بکاپ قابل بازیابی است استفاده کنید. <strong>برای سایت‌های production با کاربر واقعی توصیه نمی‌شود.</strong>'
}
};
function renderScopeNotice() {
const el = document.getElementById('scope-notice');
if (!el) return;
const isFa = {% if lang == 'fa' %}true{% else %}false{% endif %};
const scopeLabels = { read: 'Read', 'read:sensitive': 'Read+Secrets', deploy: 'Deploy', write: 'Write', admin: 'Full Access' };
const scopeLabels = {
read: 'Read', editor: 'Editor', settings: 'Settings', install: 'Installer',
'read:sensitive': 'Read+Secrets', deploy: 'Deploy', write: 'Write', admin: 'Admin'
};
if (currentScope === 'custom') {
el.classList.add('hidden');
@@ -710,22 +745,46 @@
}
const guide = (CREDENTIAL_GUIDES[pluginType] || {})[currentScope];
if (!guide) {
const warning = TIER_WARNINGS[currentScope];
if (!guide && !warning) {
el.classList.add('hidden');
el.innerHTML = '';
return;
}
const title = isFa
? 'پیش‌نیاز Credential برای سطح <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:'
: 'Credential requirement for <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:';
const body = isFa ? guide.fa : guide.en;
const blocks = [];
el.innerHTML =
'<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-lg p-3 text-indigo-700 dark:text-indigo-300">' +
'<p class="mb-1">' + title + '</p>' +
'<p>' + body + '</p>' +
'</div>';
// Destructive-tier warning first (so eyes hit it before requirements).
if (warning) {
const palette = warning.severity === 'red'
? 'bg-red-50 dark:bg-red-500/10 border-red-300 dark:border-red-500/40 text-red-800 dark:text-red-200'
: 'bg-amber-50 dark:bg-amber-500/10 border-amber-300 dark:border-amber-500/40 text-amber-800 dark:text-amber-200';
const icon = warning.severity === 'red' ? '⚠️' : '⚡';
const heading = isFa ? 'هشدار:' : 'Warning:';
blocks.push(
'<div class="' + palette + ' border rounded-lg p-3 mb-2">' +
'<p class="font-semibold mb-1">' + icon + ' ' + heading + '</p>' +
'<p>' + (isFa ? warning.fa : warning.en) + '</p>' +
'</div>'
);
}
// Credential requirement block.
if (guide) {
const title = isFa
? 'پیش‌نیاز Credential برای سطح <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:'
: 'Credential requirement for <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:';
const body = isFa ? guide.fa : guide.en;
blocks.push(
'<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-lg p-3 text-indigo-700 dark:text-indigo-300">' +
'<p class="mb-1">' + title + '</p>' +
'<p>' + body + '</p>' +
'</div>'
);
}
el.innerHTML = blocks.join('');
el.classList.remove('hidden');
}
@@ -734,8 +793,11 @@
const search = (document.getElementById('tool-search').value || '').toLowerCase();
container.innerHTML = '';
// Group by required_scope
const groups = { read: [], write: [], admin: [] };
// Group by required_scope. F.19.2.0 — six tiers now in play
// (read / editor / settings / install / write / admin).
const groups = {
read: [], editor: [], settings: [], install: [], write: [], admin: []
};
for (const tool of allTools) {
const s = tool.required_scope || 'read';
if (!groups[s]) groups[s] = [];
@@ -744,12 +806,15 @@
const scopeLabels = {
read: '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}',
editor: '{% if lang == "fa" %}ویرایشگر{% else %}Editor{% endif %}',
settings: '{% if lang == "fa" %}تنظیمات{% else %}Settings{% endif %}',
install: '{% if lang == "fa" %}نصب‌کننده{% else %}Installer{% endif %}',
write: '{% if lang == "fa" %}نوشتن{% else %}Write{% endif %}',
admin: 'Admin',
};
let visibleCount = 0;
for (const scope of ['read', 'write', 'admin']) {
for (const scope of ['read', 'editor', 'settings', 'install', 'write', 'admin']) {
const tools = (groups[scope] || []).filter(t => !search || t.name.toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search));
if (!tools.length) continue;
@@ -768,11 +833,15 @@
}
}
// Universal scope tiers: maps scope → set of allowed required_scope values
// Universal scope tiers: maps scope → set of allowed required_scope values.
// F.19.2.0 — kept in lockstep with core.tool_access.UNIVERSAL_SCOPE_TIERS.
const SCOPE_TIERS = {
read: new Set(['read']),
write: new Set(['read', 'write']),
admin: new Set(['read', 'write', 'admin']),
read: new Set(['read']),
editor: new Set(['read', 'editor']),
settings: new Set(['read', 'editor', 'settings']),
install: new Set(['read', 'editor', 'settings', 'install']),
write: new Set(['read', 'editor', 'settings', 'install', 'write']),
admin: new Set(['read', 'editor', 'settings', 'install', 'write', 'admin']),
custom: null, // no filter
};
@@ -912,7 +981,8 @@
pill.addEventListener('click', (ev) => {
ev.stopPropagation();
const link = document.createElement('a');
link.href = '/dashboard/sites/' + encodeURIComponent(siteId) + '/edit';
const fromV2 = new URLSearchParams(window.location.search).get('from') === 'dashboard-v2';
link.href = '/dashboard/sites/' + encodeURIComponent(siteId) + '/edit' + (fromV2 ? '?from=dashboard-v2' : '');
link.click();
});
left.appendChild(pill);

View File

@@ -2,36 +2,152 @@
{% block title %}{{ t.page_title }}{% endblock %}
{% block extra_head %}
<style>
.oauth-consent-shell {
min-height: calc(100vh - 96px);
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at 20% 0%, rgba(34, 197, 94, 0.10), transparent 30%),
linear-gradient(180deg, rgba(15, 23, 42, 0.04), transparent 48%);
}
html[data-theme="dark"] .oauth-consent-shell {
background:
radial-gradient(circle at 20% 0%, rgba(34, 197, 94, 0.12), transparent 30%),
linear-gradient(180deg, rgba(148, 163, 184, 0.08), transparent 48%);
}
.oauth-consent-card {
width: min(760px, 100%);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 16px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.14);
overflow: hidden;
}
.dark .oauth-consent-card {
background: rgba(15, 23, 42, 0.96);
border-color: rgba(71, 85, 105, 0.55);
}
.oauth-consent-header {
display: flex;
gap: 16px;
align-items: flex-start;
padding: 28px;
border-bottom: 1px solid rgba(148, 163, 184, 0.22);
}
html[dir="rtl"] .oauth-consent-header { flex-direction: row-reverse; text-align: right; }
.oauth-consent-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: grid;
place-items: center;
background: rgba(59, 130, 246, 0.10);
color: rgb(37, 99, 235);
border: 1px solid rgba(59, 130, 246, 0.22);
flex-shrink: 0;
}
.oauth-consent-body {
padding: 24px 28px 28px;
display: grid;
gap: 18px;
}
.oauth-info-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
padding: 14px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 12px;
background: rgba(248, 250, 252, 0.82);
}
.dark .oauth-info-grid { background: rgba(30, 41, 59, 0.64); }
.oauth-row {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 12px;
align-items: center;
font-size: 13px;
}
.oauth-label { color: rgb(100, 116, 139); font-weight: 600; }
.dark .oauth-label { color: rgb(148, 163, 184); }
.oauth-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
word-break: break-all;
color: rgb(15, 23, 42);
}
.dark .oauth-value { color: rgb(226, 232, 240); }
html[dir="rtl"] .oauth-value { direction: ltr; text-align: left; }
.oauth-scope-list { display: flex; flex-wrap: wrap; gap: 8px; }
.oauth-scope {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
border: 1px solid rgba(59, 130, 246, 0.22);
background: rgba(59, 130, 246, 0.09);
padding: 7px 10px;
font-size: 12px;
font-weight: 600;
color: rgb(30, 64, 175);
}
.dark .oauth-scope { color: rgb(147, 197, 253); }
.oauth-panel {
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 12px;
padding: 14px;
background: rgba(248, 250, 252, 0.72);
}
.dark .oauth-panel { background: rgba(30, 41, 59, 0.48); }
.oauth-actions { display: flex; gap: 10px; padding-top: 4px; }
html[dir="rtl"] .oauth-actions { flex-direction: row-reverse; }
.oauth-actions button { min-height: 44px; border-radius: 10px; }
@media (max-width: 640px) {
.oauth-consent-shell { padding: 12px; align-items: start; }
.oauth-consent-header { padding: 20px; }
.oauth-consent-body { padding: 18px 20px 22px; }
.oauth-row { grid-template-columns: 1fr; gap: 2px; }
.oauth-actions { flex-direction: column; }
html[dir="rtl"] .oauth-actions { flex-direction: column; }
}
</style>
{% endblock %}
{% block content %}
<div class="auth-container px-4">
<div class="auth-card w-full max-w-md bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8">
<div class="oauth-consent-shell" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div class="oauth-consent-card">
<!-- Header with Icon -->
<div class="text-center mb-6">
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-purple-100 dark:bg-purple-900 mb-4">
<svg class="h-10 w-10 text-purple-600 dark:text-purple-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="oauth-consent-header">
<div class="oauth-consent-icon">
<svg class="h-6 w-6" 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>
</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t.auth_required }}</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<span class="font-semibold text-purple-600 dark:text-purple-400">{{ client_name }}</span>
{{ t.wants_access.replace('{client_name}', '') }}
</p>
<div>
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">MCP Hub OAuth</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t.auth_required }}</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">
<span class="font-semibold text-blue-700 dark:text-blue-300">{{ client_name }}</span>
{{ t.wants_access.replace('{client_name}', '') }}
</p>
</div>
</div>
<div class="oauth-consent-body">
<!-- Client Information -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6">
<div class="space-y-2 text-sm" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">{{ t.client_id_label }}</span>
<span class="font-mono text-gray-900 dark:text-white text-xs">{{ client_id }}</span>
<div class="oauth-info-grid">
<div class="space-y-2 text-sm">
<div class="oauth-row">
<span class="oauth-label">{{ t.client_id_label }}</span>
<span class="oauth-value">{{ client_id }}</span>
</div>
{% if redirect_uri %}
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">{{ t.redirect_uri_label }}</span>
<span class="font-mono text-xs text-gray-900 dark:text-white truncate ml-2" title="{{ redirect_uri }}">
{{ redirect_uri[:40] }}...
</span>
<div class="oauth-row">
<span class="oauth-label">{{ t.redirect_uri_label }}</span>
<span class="oauth-value" title="{{ redirect_uri }}">{{ redirect_uri }}</span>
</div>
{% endif %}
</div>
@@ -39,15 +155,15 @@
<!-- Requested Permissions -->
{% if scopes %}
<div class="mb-6" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{{ t.requested_permissions }}</h3>
<div class="space-y-2">
<div class="oauth-scope-list">
{% for scope in scopes %}
<div class="permission-badge flex items-center p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<svg class="h-5 w-5 text-purple-600 dark:text-purple-400 {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="oauth-scope">
<svg class="h-4 w-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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ scope }}</span>
<span>{{ scope }}</span>
</div>
{% endfor %}
</div>
@@ -71,14 +187,15 @@
{% if resource %}
<input type="hidden" name="resource" value="{{ resource }}">
{% endif %}
<input type="hidden" name="lang" value="{{ lang }}" id="oauth_lang">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<!-- Authentication -->
{% if session_user %}
<!-- Session-based consent (user already logged in) -->
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div class="bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4 mb-4">
<div class="flex items-center gap-3">
<div>
<div class="oauth-panel mb-4">
<div class="flex items-center gap-3 {% if lang == 'fa' %}flex-row-reverse text-right{% endif %}">
<svg class="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
@@ -106,20 +223,20 @@
</div>
<!-- Hidden API key input (shown when user clicks "enter an API key instead") -->
<div id="api-key-section" class="hidden" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div id="api-key-section" class="hidden oauth-panel">
<label for="api_key_input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t.enter_api_key }}
</label>
<input type="password" id="api_key_input" name="api_key_manual"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition"
placeholder="{{ t.api_key_placeholder }}" autocomplete="off"
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}>
dir="ltr" {% if lang == 'fa' %}style="text-align: right;"{% endif %}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">{{ t.api_key_note }}</p>
</div>
{% else %}
<!-- API key mode (no active session) -->
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div class="oauth-panel">
<label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t.enter_api_key }}
</label>
@@ -131,9 +248,9 @@
placeholder="{{ t.api_key_placeholder }}"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition"
autocomplete="off"
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}
dir="ltr" {% if lang == 'fa' %}style="text-align: right;"{% endif %}
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
{{ t.api_key_note }}
</p>
@@ -147,7 +264,7 @@
Or log in with your account:
{% endif %}
</p>
<div class="flex gap-2 mt-2">
<div class="flex gap-2 mt-2 {% if lang == 'fa' %}flex-row-reverse{% endif %}">
<a href="/auth/github?next={{ return_url | urlencode }}"
class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/></svg>
@@ -165,7 +282,7 @@
{% endif %}
<!-- Action Buttons -->
<div class="flex gap-3 pt-4" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div class="oauth-actions">
<button
type="submit"
name="action"
@@ -186,9 +303,9 @@
</form>
<!-- Security Notice -->
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div class="flex {% if lang == 'fa' %}flex-row-reverse{% endif %}">
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400 {% if lang == 'fa' %}mr-2{% else %}mr-2{% endif %} flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="oauth-panel">
<div class="flex gap-2 {% if lang == 'fa' %}flex-row-reverse text-right{% endif %}">
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-xs text-blue-800 dark:text-blue-300">
@@ -196,6 +313,7 @@
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -38,13 +38,85 @@ from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__)
# ── Universal 3-tier scope system (F.7c) ─────────────────────────────
# ── Universal scope tier system (F.7c → F.19.2.0) ───────────────────
# Maps a scope tier to the set of ``required_scope`` values it may access.
# Works for ALL plugins because every tool has ``required_scope``.
#
# Ladder (each tier is a strict superset of the one above):
#
# read │ inventory + diagnostics only
# editor │ + content / theme-file edits (F.19.5 + F.19.7)
# settings │ + options / cron / maintenance / site-identity / permalinks (F.19.6)
# install │ + plugin & theme install / activate / update from wp.org (F.19.2)
# write │ catch-all "non-destructive write" tier (legacy WP/WC/Gitea)
# admin │ destructive ops (URL/zip install, delete user/plugin/theme,
# │ user CRUD, db_query, anything that can lose data)
#
# Why ``write`` is between ``install`` and ``admin``: keeps existing
# WordPress/WooCommerce/Gitea/etc. keys (which use plain ``write``)
# working. They retain the same effective access they had pre-F.19.2,
# minus the destructive ops which now require ``admin``.
UNIVERSAL_SCOPE_TIERS: dict[str, set[str]] = {
"read": {"read"},
"write": {"read", "write"},
"admin": {"read", "write", "admin"},
# F.19.5 — ``editor``: page-content editing without unlocking
# destructive ops. wordpress_specialist uses it; other plugins fall
# through to the existing read/write/admin ladder.
"editor": {"read", "editor"},
# F.19.6 — ``settings``: options / cron / maintenance toggle /
# site identity / reading + permalinks. Fits between editor and
# install — settings changes are recoverable, plugin installs can
# reshape the site.
"settings": {"read", "editor", "settings"},
# F.19.2 — ``install``: plugin/theme install + activate + update
# from wp.org slugs. URL/zip install routes belong to ``admin``
# (sees more attack surface). delete_plugin / delete_theme also
# belong to ``admin`` because they can drop data with no undo.
"install": {"read", "editor", "settings", "install"},
# ``write`` is the historical catch-all for non-destructive writes
# (WordPress create_post, WooCommerce update_order, etc.).
"write": {"read", "editor", "settings", "install", "write"},
"admin": {"read", "editor", "settings", "install", "write", "admin"},
}
# Per-tier descriptions for dashboard tooltips + tier toggles. EN + FA
# in lockstep with the rest of the dashboard's i18n.
TIER_DESCRIPTIONS: dict[str, dict[str, str]] = {
"read": {
"label_en": "Read Only",
"label_fa": "فقط خواندن",
"hint_en": "Inventory and diagnostics — no changes.",
"hint_fa": "مشاهده و عیب‌یابی — بدون تغییر.",
},
"editor": {
"label_en": "Editor",
"label_fa": "ویرایشگر",
"hint_en": "Read + page / theme content edits.",
"hint_fa": "خواندن + ویرایش محتوا و فایل قالب.",
},
"settings": {
"label_en": "Settings",
"label_fa": "تنظیمات",
"hint_en": "Editor + site options, cron, identity, permalinks.",
"hint_fa": "ویرایشگر + تنظیمات سایت، کرون، هویت، پرمالینک.",
},
"install": {
"label_en": "Installer",
"label_fa": "نصب‌کننده",
"hint_en": "Settings + install / activate plugins & themes from wp.org.",
"hint_fa": "تنظیمات + نصب و فعال‌سازی پلاگین/قالب از wp.org.",
},
"write": {
"label_en": "Write",
"label_fa": "نوشتن",
"hint_en": "Non-destructive write tier (post/product CRUD, etc.).",
"hint_fa": "تیر نوشتن غیرتخریبی (ایجاد/ویرایش پست، محصول و …).",
},
"admin": {
"label_en": "Admin",
"label_fa": "مدیر",
"hint_en": "Includes destructive ops (delete, URL/zip install, user CRUD).",
"hint_fa": "شامل عملیات تخریبی (حذف، نصب از URL/zip، CRUD کاربر).",
},
}
# ── Legacy Coolify category mapping (kept for ``custom`` overlay) ─────
@@ -214,7 +286,7 @@ def get_scope_presets_for_plugin(plugin_type: str) -> list[dict[str, str]]:
custom,
]
if plugin_type in {"wordpress", "wordpress_advanced"}:
if plugin_type == "wordpress":
# WordPress has no admin-scope tools (read=27, write=40, admin=0).
# SEO + plugin/theme tools require the Airano MCP SEO Bridge plugin
# to be installed on the WP site itself. Present 2 tiers + custom.
@@ -236,6 +308,47 @@ def get_scope_presets_for_plugin(plugin_type: str) -> list[dict[str, str]]:
custom,
]
if plugin_type == "wordpress_specialist":
# F.19.1 ships READ-only tools (inventory + diagnostics).
# F.19.5 adds the EDITOR tier — page editing (Gutenberg + Elementor
# + Classic) on top of read.
# F.19.7 adds the THEME DEV surface on the editor tier (file CRUD)
# and the install/admin tiers for theme install/activate/delete.
# F.19.2.1 adds plugin install/activate/deactivate/update (install
# tier) and plugin install-from-zip + delete (admin tier).
# F.19.6 will surface a SETTINGS tier between editor and install.
return [
{
"value": "read",
"label": "Read Only",
"label_fa": "فقط خواندن",
"hint": "Inventory plugins/themes/users/options/cron — no changes",
"hint_fa": "مشاهده پلاگین‌ها/قالب‌ها/کاربران/تنظیمات/کرون — بدون تغییر",
},
{
"value": "editor",
"label": "Editor",
"label_fa": "ویرایشگر",
"hint": "Read + page editing (Gutenberg blocks, Elementor, Classic) + theme file CRUD",
"hint_fa": "خواندن + ویرایش صفحه (بلوک‌ها، المنتور، کلاسیک) + ویرایش فایل قالب",
},
{
"value": "install",
"label": "Installer",
"label_fa": "نصب‌کننده",
"hint": "Editor + install/activate plugins & themes from wp.org",
"hint_fa": "ویرایشگر + نصب و فعال‌سازی پلاگین/قالب از wp.org",
},
{
"value": "admin",
"label": "Admin",
"label_fa": "مدیر",
"hint": "Installer + URL/zip install, plugin/theme delete (destructive)",
"hint_fa": "نصب‌کننده + نصب از URL/zip، حذف پلاگین/قالب (تخریبی)",
},
custom,
]
if plugin_type == "gitea":
return [
{
@@ -614,7 +727,12 @@ class ToolAccessManager:
# F.X.fix #8 — tools that need a per-site AI provider key to succeed.
# Today only the AI image generator; expand as more AI tools land.
_PROVIDER_KEY_REQUIRED_TOOLS: frozenset[str] = frozenset({"wordpress_generate_and_upload_image"})
_PROVIDER_KEY_REQUIRED_TOOLS: frozenset[str] = frozenset(
{
"wordpress_generate_and_upload_image",
"woocommerce_generate_and_upload_image",
}
)
def _tool_requires_provider_key(tool_name: str) -> bool:

View File

@@ -35,6 +35,16 @@ _GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
_GITHUB_USER_URL = "https://api.github.com/user"
_GITHUB_EMAILS_URL = "https://api.github.com/user/emails"
# api.github.com rejects requests without a User-Agent header and treats
# requests that omit Accept/X-GitHub-Api-Version as forward-compatibility
# violations on some endpoints (observed as 403 on /user and /user/emails
# after httpx upgraded its default UA). Keep these explicit.
_GITHUB_API_HEADERS = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "mcphub-oauth-client",
}
_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
@@ -258,19 +268,25 @@ class UserAuth:
if not access_token:
raise ValueError(f"Failed to exchange GitHub code: {token_data}")
auth_headers = {
"Authorization": f"Bearer {access_token}",
**_GITHUB_API_HEADERS,
}
# Fetch user info
user_resp = await client.get(
_GITHUB_USER_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
user_resp = await client.get(_GITHUB_USER_URL, headers=auth_headers)
if user_resp.status_code != 200:
raise ValueError(f"GitHub /user returned {user_resp.status_code}: {user_resp.text}")
user_data = user_resp.json()
if "id" not in user_data:
raise ValueError(f"GitHub /user response missing 'id' field: {user_data}")
email = user_data.get("email")
if not email:
# Fetch from /user/emails endpoint (private email fallback)
emails_resp = await client.get(
_GITHUB_EMAILS_URL,
headers={"Authorization": f"Bearer {access_token}"},
headers=auth_headers,
)
if emails_resp.status_code == 200:
emails = emails_resp.json()

View File

@@ -19,7 +19,6 @@ Usage:
import json
import logging
import os
import time
from copy import deepcopy
from typing import Any
@@ -31,16 +30,12 @@ from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__)
# Per-user rate limiting defaults
USER_RATE_LIMIT_PER_MIN = int(os.getenv("USER_RATE_LIMIT_PER_MIN", "30"))
USER_RATE_LIMIT_PER_HR = int(os.getenv("USER_RATE_LIMIT_PER_HR", "500"))
# In-memory rate limit tracking: user_id -> list of timestamps
_rate_limits: dict[str, list[float]] = {}
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
"""Check per-user rate limits.
"""Check per-user rate limits using the live cached settings (DB > ENV > default).
Args:
user_id: User UUID.
@@ -48,6 +43,11 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
Returns:
Tuple of (allowed, error_message). error_message is empty if allowed.
"""
from core.settings import get_cached_rate_per_hr, get_cached_rate_per_min
per_min = get_cached_rate_per_min()
per_hr = get_cached_rate_per_hr()
now = time.time()
timestamps = _rate_limits.setdefault(user_id, [])
@@ -59,12 +59,12 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
# Check per-minute limit
cutoff_min = now - 60
recent_min = sum(1 for t in timestamps if t > cutoff_min)
if recent_min >= USER_RATE_LIMIT_PER_MIN:
return False, f"Rate limit exceeded: {USER_RATE_LIMIT_PER_MIN} requests/minute"
if recent_min >= per_min:
return False, f"Rate limit exceeded: {per_min} requests/minute"
# Check per-hour limit
if len(timestamps) >= USER_RATE_LIMIT_PER_HR:
return False, f"Rate limit exceeded: {USER_RATE_LIMIT_PER_HR} requests/hour"
if len(timestamps) >= per_hr:
return False, f"Rate limit exceeded: {per_hr} requests/hour"
timestamps.append(now)
return True, ""
@@ -357,19 +357,40 @@ async def user_mcp_handler(request: Request) -> Response:
status_code=401,
)
# --- Rate Limiting ---
allowed, rate_msg = _check_user_rate_limit(user_id)
if not allowed:
return JSONResponse(
_jsonrpc_error(None, -32600, rate_msg),
status_code=429,
# --- Rate Limiting (admin users are exempt) ---
# Fetch the user record early so we can check the role; it's also needed
# for the plugin-visibility check below, so we hoist it here.
_db_early = None
_user_record_early: dict | None = None
try:
from core.database import get_database
_db_early = get_database()
_user_record_early = await _db_early.get_user_by_id(user_id)
except Exception:
pass
_is_admin_user = False
if _user_record_early:
from core.admin_utils import is_admin_email
_is_admin_user = (_user_record_early.get("role") == "admin") or is_admin_email(
_user_record_early.get("email")
)
if not _is_admin_user:
allowed, rate_msg = _check_user_rate_limit(user_id)
if not allowed:
return JSONResponse(
_jsonrpc_error(None, -32600, rate_msg),
status_code=429,
)
# --- Look up site ---
try:
from core.database import get_database
db = get_database()
db = _db_early if _db_early is not None else get_database()
site = await db.get_site_by_alias(user_id, alias)
except RuntimeError:
return JSONResponse(
@@ -389,13 +410,15 @@ async def user_mcp_handler(request: Request) -> Response:
status_code=403,
)
# --- Plugin visibility check ---
# --- Plugin visibility check (admins bypass entirely) ---
from core.plugin_visibility import is_plugin_public
if not is_plugin_public(site["plugin_type"]):
if not _is_admin_user and not is_plugin_public(site["plugin_type"]):
return JSONResponse(
_jsonrpc_error(
None, -32600, f"Plugin '{site['plugin_type']}' is not currently available"
None,
-32600,
f"Plugin '{site['plugin_type']}' is not currently available",
),
status_code=403,
)
@@ -462,27 +485,59 @@ async def user_mcp_handler(request: Request) -> Response:
# key_scopes is set during authentication (both mhu_ and JWT paths)
# F.7b: enforce category-based scope allowlist in addition to the
# legacy read/write/admin hierarchy. A tool is allowed only if BOTH
# (a) the legacy hierarchy grants it, AND
# (b) the tool's category is in BOTH the key-scope set AND the
# site's stored tool_scope set (the narrower layer wins).
from core.tool_access import KNOWN_CATEGORIES, SCOPE_CUSTOM, scopes_to_categories
# universal scope tier. A tool is allowed only if BOTH
# (a) the universal scope tier grants it (works for every plugin), AND
# (b) for plugins with fine-grained categories (Coolify), the
# tool's category is in BOTH the key-scope set AND the site's
# stored tool_scope set (the narrower layer wins).
from core.tool_access import (
KNOWN_CATEGORIES,
SCOPE_CUSTOM,
UNIVERSAL_SCOPE_TIERS,
scopes_to_categories,
)
scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
required_level = scope_hierarchy.get(required_scope, 0)
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0])
legacy_ok = key_level >= required_level
# F.19.5 introduced the ``editor`` tier (between ``read`` and
# ``write``). The legacy 3-level hierarchy here ({read:1, write:2,
# admin:3}) silently dropped ``editor`` to level 0, which made
# every tool call from an editor-scope key fail "Insufficient
# scope". Use the canonical UNIVERSAL_SCOPE_TIERS map from
# tool_access so this list stays single-sourced as new tiers
# land (F.19.2 introduces ``manage`` / ``install`` next).
allowed_scopes: set[str] = set()
for s in key_scopes:
allowed_scopes |= UNIVERSAL_SCOPE_TIERS.get(s.strip(), set())
legacy_ok = required_scope in allowed_scopes
key_cats = scopes_to_categories(key_scopes)
key_category_ok = tool_def.category not in KNOWN_CATEGORIES or tool_def.category in key_cats
plugin_type = tool_def.plugin_type
# Category check is for plugins with fine-grained categories
# (currently Coolify only). For other plugins ``tool_def.category``
# defaults to ``"read"`` which collides with one of Coolify's
# KNOWN_CATEGORIES — running the check against non-Coolify tools
# mis-rejected wordpress_specialist + wordpress + gitea tools when
# the key scope didn't happen to map to category "read".
if plugin_type == "coolify":
key_cats = scopes_to_categories(key_scopes)
key_category_ok = (
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in key_cats
)
else:
key_category_ok = True
# Site-level scope check (skipped for "custom" preset).
site_scope = site.get("tool_scope") or "admin"
if site_scope and site_scope != SCOPE_CUSTOM:
site_cats = scopes_to_categories([site_scope])
site_category_ok = (
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in site_cats
)
if plugin_type == "coolify":
site_cats = scopes_to_categories([site_scope])
site_category_ok = (
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in site_cats
)
else:
# Non-Coolify plugins use the universal tier at site level
# too — site_scope is one of ``read`` / ``editor`` / ``write``
# / ``admin`` / ``custom``, same vocabulary as the key.
site_allowed = UNIVERSAL_SCOPE_TIERS.get(site_scope, set())
site_category_ok = required_scope in site_allowed
else:
site_category_ok = True

View File

@@ -56,7 +56,7 @@ class UserKeyManager:
self,
user_id: str,
name: str,
scopes: str = "read write admin",
scopes: str = "read editor settings install write admin",
expires_in_days: int | None = None,
site_id: str | None = None,
) -> dict[str, Any]:
@@ -65,7 +65,14 @@ class UserKeyManager:
Args:
user_id: Owner's UUID.
name: Human label (e.g. "Claude Desktop").
scopes: Access scopes (default: "read write admin" for full access).
scopes: Access scopes. Default enumerates every tier explicitly
(read / editor / settings / install / write / admin) so the
key matches the per-site Tool Access dropdown 1:1, even if
``UNIVERSAL_SCOPE_TIERS`` is reorganised in a later phase.
The binding access filter for any tool call is the
**intersection** of these scopes with the per-site
``tool_scope`` preset — narrowing happens per-site, not
per-key, so a single key works for every site the user owns.
expires_in_days: Optional expiry in days from now. None = never.
site_id: Optional site UUID to scope key to a single site.