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:
@@ -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",
|
||||
|
||||
@@ -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
345
core/dashboard/spa_routes.py
Normal file
345
core/dashboard/spa_routes.py
Normal 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")
|
||||
@@ -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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ── #}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
'openpanel': 'cyan',
|
||||
'appwrite': 'pink',
|
||||
'directus': 'violet',
|
||||
'wordpress_advanced': 'indigo',
|
||||
'wordpress_specialist': 'indigo',
|
||||
} %}
|
||||
|
||||
{% for svc in services %}
|
||||
|
||||
@@ -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 [] %}
|
||||
|
||||
@@ -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 %}';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user